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.
- mlscratch/__init__.py +56 -0
- mlscratch/__main__.py +118 -0
- mlscratch/bayesian/__init__.py +53 -0
- mlscratch/bayesian/bayesian_linear_regression.py +171 -0
- mlscratch/bayesian/bayesian_network.py +248 -0
- mlscratch/bayesian/bayesian_nn.py +315 -0
- mlscratch/bayesian/gaussian_process.py +207 -0
- mlscratch/bayesian/hmm.py +277 -0
- mlscratch/bayesian/init.py +52 -0
- mlscratch/bayesian/kalman_filter.py +182 -0
- mlscratch/bayesian/naive_bayes.py +209 -0
- mlscratch/metrics/__init__.py +59 -0
- mlscratch/metrics/classification.py +365 -0
- mlscratch/metrics/regression.py +79 -0
- mlscratch/neural/__init__.py +121 -0
- mlscratch/neural/attention.py +420 -0
- mlscratch/neural/autoencoder.py +543 -0
- mlscratch/neural/boltzmann.py +231 -0
- mlscratch/neural/cnn.py +593 -0
- mlscratch/neural/cvnn.py +322 -0
- mlscratch/neural/gan.py +364 -0
- mlscratch/neural/hopfield.py +193 -0
- mlscratch/neural/perceptron.py +398 -0
- mlscratch/neural/rbf_network.py +230 -0
- mlscratch/neural/recurrent.py +569 -0
- mlscratch/preprocessing/__init__.py +38 -0
- mlscratch/preprocessing/encoders.py +140 -0
- mlscratch/preprocessing/model_selection.py +119 -0
- mlscratch/preprocessing/polynomial.py +105 -0
- mlscratch/preprocessing/scalers.py +220 -0
- mlscratch/py.typed +0 -0
- mlscratch/reinforcement/__init__.py +59 -0
- mlscratch/reinforcement/ddpg.py +363 -0
- mlscratch/reinforcement/dqn.py +319 -0
- mlscratch/reinforcement/ppo.py +452 -0
- mlscratch/reinforcement/q_learning.py +352 -0
- mlscratch/reinforcement/sac.py +382 -0
- mlscratch/reinforcement/utils.py +594 -0
- mlscratch/supervised/__init__.py +76 -0
- mlscratch/supervised/_validation.py +50 -0
- mlscratch/supervised/adaboost.py +255 -0
- mlscratch/supervised/decision_tree.py +495 -0
- mlscratch/supervised/gradient_boosting.py +354 -0
- mlscratch/supervised/knn.py +234 -0
- mlscratch/supervised/lasso_regression.py +125 -0
- mlscratch/supervised/linear_models.py +459 -0
- mlscratch/supervised/linear_regression.py +197 -0
- mlscratch/supervised/logistic_regression.py +119 -0
- mlscratch/supervised/naive_bayes.py +113 -0
- mlscratch/supervised/random_forest.py +321 -0
- mlscratch/supervised/ridge_regression.py +93 -0
- mlscratch/supervised/svm.py +356 -0
- mlscratch/unsupervised/__init__.py +39 -0
- mlscratch/unsupervised/apriori.py +178 -0
- mlscratch/unsupervised/dbscan.py +141 -0
- mlscratch/unsupervised/gmm.py +204 -0
- mlscratch/unsupervised/hierarchical_clustering.py +137 -0
- mlscratch/unsupervised/ica.py +167 -0
- mlscratch/unsupervised/kmeans.py +135 -0
- mlscratch/unsupervised/kmedoids.py +133 -0
- mlscratch/unsupervised/pca.py +103 -0
- mlscratch/unsupervised/tsne.py +200 -0
- scratchkit-0.2.0.dist-info/METADATA +241 -0
- scratchkit-0.2.0.dist-info/RECORD +68 -0
- scratchkit-0.2.0.dist-info/WHEEL +5 -0
- scratchkit-0.2.0.dist-info/entry_points.txt +2 -0
- scratchkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- 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)
|