File size: 12,994 Bytes
1b19efc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f21703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b19efc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""A NumPy-only linear classifier trained with mini-batch SGD."""

from __future__ import annotations

from numbers import Integral, Real
from typing import Literal

import numpy as np


SUPPORTED_LOSSES = (
    "hinge",
    "log_loss",
    "modified_huber",
    "squared_hinge",
    "perceptron",
    "squared_error",
    "huber",
    "epsilon_insensitive",
    "squared_epsilon_insensitive",
)


class SGDClassifier:
    """Linear one-vs-rest classifier optimized by mini-batch SGD.

    ``random`` batch selection independently samples each batch without
    replacement. ``permutation`` shuffles the dataset and consumes
    non-overlapping batches, then reshuffles at the start of the next epoch.

    The implementation depends only on NumPy. For multiclass problems, each
    class is fitted against all other classes using a one-vs-rest target.
    """

    def __init__(
        self,
        loss: str = "hinge",
        *,
        batch_size: int = 32,
        batch_selection: Literal["random", "permutation"] = "permutation",
        max_epochs: int = 10,
        learning_rate: float = 0.01,
        penalty: Literal["l2", "l1", "elasticnet"] | None = "l2",
        alpha: float = 0.000, # no regularization by default
        l1_ratio: float = 0.15,
        fit_intercept: bool = True,
        epsilon: float = 0.1,
        random_state: int | None = None,
    ) -> None:
        self.loss = loss
        self.batch_size = batch_size
        self.batch_selection = batch_selection
        self.max_epochs = max_epochs
        self.learning_rate = learning_rate
        self.penalty = penalty
        self.alpha = alpha
        self.l1_ratio = l1_ratio
        self.fit_intercept = fit_intercept
        self.epsilon = epsilon
        self.random_state = random_state

    def _validate_params(self) -> None:
        if self.loss not in SUPPORTED_LOSSES:
            raise ValueError(
                f"Unsupported loss {self.loss!r}; expected one of {SUPPORTED_LOSSES}."
            )
        if not isinstance(self.batch_size, Integral) or self.batch_size <= 0:
            raise ValueError("batch_size must be a positive integer.")
        if self.batch_selection not in {"random", "permutation"}:
            raise ValueError("batch_selection must be 'random' or 'permutation'.")
        if not isinstance(self.max_epochs, Integral) or self.max_epochs <= 0:
            raise ValueError("max_epochs must be a positive integer.")
        if not isinstance(self.learning_rate, Real) or self.learning_rate <= 0:
            raise ValueError("learning_rate must be positive.")
        if self.penalty not in {None, "l1", "l2", "elasticnet"}:
            raise ValueError("penalty must be None, 'l1', 'l2', or 'elasticnet'.")
        if not isinstance(self.alpha, Real) or self.alpha < 0:
            raise ValueError("alpha must be non-negative.")
        if not 0 <= self.l1_ratio <= 1:
            raise ValueError("l1_ratio must be between 0 and 1.")
        if self.epsilon < 0:
            raise ValueError("epsilon must be non-negative.")

    @staticmethod
    def _check_X_y(X, y):
        X = np.asarray(X, dtype=float)
        y = np.asarray(y)
        if X.ndim != 2:
            raise ValueError("X must be a two-dimensional array.")
        if y.ndim != 1:
            y = np.ravel(y)
        if len(X) != len(y) or len(y) == 0:
            raise ValueError("X and y must contain the same non-zero sample count.")
        if not np.all(np.isfinite(X)):
            raise ValueError("X must contain only finite values.")
        return X, y

    def _initialize(self, n_features: int, classes) -> None:
        self.classes_ = np.asarray(classes)
        if self.classes_.ndim != 1 or len(self.classes_) < 2:
            raise ValueError("At least two classes are required.")
        self.coef_ = np.zeros((len(self.classes_), n_features), dtype=float)
        self.intercept_ = np.zeros(len(self.classes_), dtype=float)
        self.n_features_in_ = n_features
        self.n_updates_ = 0

    def _loss_gradient(self, scores, targets):
        """Derivative of each requested loss with respect to the scores."""
        margin = targets * scores
        if self.loss == "hinge": # (1-margin)_{+}
            return np.where(margin < 1, -targets, 0.0)
        if self.loss == "log_loss": # ln()
            # Stable form of -y / (1 + exp(y * score)).
            z = np.clip(margin, -50, 50)
            return -targets / (1.0 + np.exp(z))
        if self.loss == "modified_huber":
            return np.where(
                margin >= 1,
                0.0,
                np.where(margin >= -1, -2 * targets * (1 - margin), -4 * targets),
            )
        if self.loss == "squared_hinge": # (1-margin)^2
            return np.where(margin < 1, -2 * targets * (1 - margin), 0.0)
        if self.loss == "perceptron": # (-margin)_{+}
            return np.where(margin <= 0, -targets, 0.0)

        residual = scores - targets
        if self.loss == "squared_error":
            return residual
        if self.loss == "huber":
            return np.clip(residual, -self.epsilon, self.epsilon)
        outside = np.abs(residual) > self.epsilon
        if self.loss == "epsilon_insensitive":
            return np.where(outside, np.sign(residual), 0.0)
        # squared_epsilon_insensitive
        return np.where(
            outside, 2 * np.sign(residual) * (np.abs(residual) - self.epsilon), 0.0
        )

    def _loss_values(self, scores, targets):
        """Element-wise loss corresponding to :meth:`_loss_gradient`."""
        margin = targets * scores
        if self.loss == "hinge":
            return np.maximum(0.0, 1 - margin)
        if self.loss == "log_loss":
            return np.logaddexp(0.0, -margin)
        if self.loss == "modified_huber":
            return np.where(
                margin >= 1,
                0.0,
                np.where(margin >= -1, (1 - margin) ** 2, -4 * margin),
            )
        if self.loss == "squared_hinge":
            return np.maximum(0.0, 1 - margin) ** 2
        if self.loss == "perceptron":
            return np.maximum(0.0, -margin)

        residual = scores - targets
        absolute = np.abs(residual)
        if self.loss == "squared_error":
            return 0.5 * residual**2
        if self.loss == "huber":
            return np.where(
                absolute <= self.epsilon,
                0.5 * residual**2,
                self.epsilon * (absolute - 0.5 * self.epsilon),
            )
        if self.loss == "epsilon_insensitive":
            return np.maximum(0.0, absolute - self.epsilon)
        return np.maximum(0.0, absolute - self.epsilon) ** 2

    def evaluate(self, X, y):
        """Return mean loss and accuracy over an entire labelled dataset."""
        X, y = self._check_X_y(X, y)
        if not hasattr(self, "coef_"):
            raise RuntimeError("The classifier has not been initialized.")
        if X.shape[1] != self.n_features_in_:
            raise ValueError(f"X must have {self.n_features_in_} features.")
        scores = X @ self.coef_.T + self.intercept_
        targets = np.where(y[:, None] == self.classes_[None, :], 1.0, -1.0)
        loss = float(np.mean(self._loss_values(scores, targets)))
        predictions = self.classes_[np.argmax(scores, axis=1)]
        return {"loss": loss, "accuracy": float(np.mean(predictions == y))}

    def _regularization_gradient(self):
        if self.penalty is None or self.alpha == 0:
            return np.zeros_like(self.coef_)
        if self.penalty == "l2":
            return self.alpha * self.coef_
        if self.penalty == "l1":
            return self.alpha * np.sign(self.coef_)
        return self.alpha * (
            self.l1_ratio * np.sign(self.coef_)
            + (1 - self.l1_ratio) * self.coef_
        )

    def get_update(self, X, y, *, classes=None, sample_weight=None):
        """Return the parameter update for a batch without applying it.

        On the first call, ``classes`` may be supplied to declare labels that
        are absent from this particular batch. Otherwise classes are inferred
        from ``y``. Initializing the parameter arrays is the only state change
        this method can make.
        """
        self._validate_params()
        X, y = self._check_X_y(X, y)
        if not hasattr(self, "coef_"):
            initial_classes = np.unique(y) if classes is None else np.asarray(classes)
            self._initialize(X.shape[1], initial_classes)
        elif X.shape[1] != self.n_features_in_:
            raise ValueError(f"X must have {self.n_features_in_} features.")

        known = np.isin(y, self.classes_)
        if not np.all(known):
            raise ValueError(f"Unknown class labels: {np.unique(y[~known])!r}.")

        if sample_weight is None:
            weights = np.ones(len(y), dtype=float)
        else:
            weights = np.asarray(sample_weight, dtype=float)
            if weights.ndim != 1 or len(weights) != len(y):
                raise ValueError("sample_weight must have one value per sample.")
            if np.any(weights < 0) or not np.all(np.isfinite(weights)):
                raise ValueError("sample_weight must be finite and non-negative.")
        weight_sum = weights.sum()
        if weight_sum <= 0:
            raise ValueError("sample_weight must sum to a positive value.")

        scores = X @ self.coef_.T + self.intercept_
        targets = np.where(y[:, None] == self.classes_[None, :], 1.0, -1.0)
        derivatives = self._loss_gradient(scores, targets) * weights[:, None]
        coef_gradient = derivatives.T @ X / weight_sum
        coef_gradient += self._regularization_gradient()
        coef_update = -self.learning_rate * coef_gradient
        if self.fit_intercept:
            intercept_update = -self.learning_rate * derivatives.sum(axis=0) / weight_sum
        else:
            intercept_update = np.zeros_like(self.intercept_)
        return coef_update, intercept_update

    def train_step(self, X, y, *, classes=None, sample_weight=None):
        """Perform one mini-batch SGD update and return this model."""
        coef_update, intercept_update = self.get_update(
            X, y, classes=classes, sample_weight=sample_weight
        )
        self.coef_ += coef_update
        self.intercept_ += intercept_update
        self.n_updates_ += 1
        return self

    def _batches(self, n_samples: int, rng):
        size = min(int(self.batch_size), n_samples)
        updates = (n_samples + size - 1) // size
        if self.batch_selection == "permutation":
            order = rng.permutation(n_samples)
            for start in range(0, n_samples, size):
                yield order[start : start + size]
        else:
            for _ in range(updates):
                yield rng.choice(n_samples, size=size, replace=False)

    def fit(self, X, y, sample_weight=None):
        """Reset and train the model, returning ``self``."""
        self._validate_params()
        X, y = self._check_X_y(X, y)
        classes = np.unique(y)
        self._initialize(X.shape[1], classes)
        if sample_weight is not None:
            sample_weight = np.asarray(sample_weight, dtype=float)
            if sample_weight.ndim != 1 or len(sample_weight) != len(y):
                raise ValueError("sample_weight must have one value per sample.")

        rng = np.random.default_rng(self.random_state)
        for _ in range(int(self.max_epochs)):
            for indices in self._batches(len(y), rng):
                batch_weights = None if sample_weight is None else sample_weight[indices]
                self.train_step(X[indices], y[indices], sample_weight=batch_weights)
        return self

    def _check_fitted_X(self, X):
        if not hasattr(self, "coef_"):
            raise RuntimeError("The classifier has not been trained.")
        X = np.asarray(X, dtype=float)
        if X.ndim != 2 or X.shape[1] != self.n_features_in_:
            raise ValueError(f"X must be two-dimensional with {self.n_features_in_} features.")
        return X

    def decision_function(self, X):
        X = self._check_fitted_X(X)
        scores = X @ self.coef_.T + self.intercept_
        return scores[:, 1] - scores[:, 0] if len(self.classes_) == 2 else scores

    def predict(self, X):
        X = self._check_fitted_X(X)
        scores = X @ self.coef_.T + self.intercept_
        return self.classes_[np.argmax(scores, axis=1)]

    def predict_proba(self, X):
        """Return softmax-normalized class scores."""
        X = self._check_fitted_X(X)
        scores = X @ self.coef_.T + self.intercept_
        shifted = scores - scores.max(axis=1, keepdims=True)
        probabilities = np.exp(np.clip(shifted, -700, 0))
        return probabilities / probabilities.sum(axis=1, keepdims=True)

    def predict_log_proba(self, X):
        return np.log(np.clip(self.predict_proba(X), np.finfo(float).tiny, 1.0))


__all__ = ["SUPPORTED_LOSSES", "SGDClassifier"]