sklearn-morpho 0.2.3__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.
- sklearn_morpho/__init__.py +5 -0
- sklearn_morpho/classifiers/__init__.py +3 -0
- sklearn_morpho/classifiers/ldep.py +203 -0
- sklearn_morpho/classifiers/rdep.py +197 -0
- sklearn_morpho/classifiers/simple_perceptron.py +187 -0
- sklearn_morpho/stopping/__init__.py +7 -0
- sklearn_morpho/stopping/stopping_base.py +27 -0
- sklearn_morpho/stopping/stopping_cost.py +24 -0
- sklearn_morpho/stopping/stopping_early.py +40 -0
- sklearn_morpho/stopping/stopping_epoch.py +24 -0
- sklearn_morpho/stopping/stopping_train_stop.py +17 -0
- sklearn_morpho/training/__init__.py +6 -0
- sklearn_morpho/training/dccp_ldep.py +270 -0
- sklearn_morpho/training/dccp_rdep.py +285 -0
- sklearn_morpho/training/dccp_simple_perceptron.py +180 -0
- sklearn_morpho/training/dccp_wrapper.py +343 -0
- sklearn_morpho/weighting/__init__.py +3 -0
- sklearn_morpho/weighting/weighting_base.py +53 -0
- sklearn_morpho/weighting/weighting_dist.py +34 -0
- sklearn_morpho/weighting/weighting_none.py +17 -0
- sklearn_morpho-0.2.3.dist-info/METADATA +30 -0
- sklearn_morpho-0.2.3.dist-info/RECORD +24 -0
- sklearn_morpho-0.2.3.dist-info/WHEEL +4 -0
- sklearn_morpho-0.2.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from . import StoppingMethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EarlyStoppingMethod(StoppingMethod):
|
|
5
|
+
"""
|
|
6
|
+
Stopping method that triggers whenever the validation cost starts increasing
|
|
7
|
+
while the training cost keeps decreasing, this for a set number of
|
|
8
|
+
consecutive epochs.
|
|
9
|
+
|
|
10
|
+
It is assumed that the training cost keeps decreasing as should be the case
|
|
11
|
+
during functional training.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, delay=5):
|
|
15
|
+
if delay <= 0:
|
|
16
|
+
raise ValueError(f'invalid delay, expected > 0 but got {delay}')
|
|
17
|
+
|
|
18
|
+
self.delay = delay
|
|
19
|
+
self.count = 0
|
|
20
|
+
|
|
21
|
+
self.best_validation_cost: float | None = None
|
|
22
|
+
|
|
23
|
+
def requires_validation(self) -> bool:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
def should_stop(
|
|
27
|
+
self, n_epochs: int, train_cost: float, validation_cost: float
|
|
28
|
+
) -> bool:
|
|
29
|
+
if self.best_validation_cost is None:
|
|
30
|
+
self.best_validation_cost = validation_cost
|
|
31
|
+
else:
|
|
32
|
+
if validation_cost >= self.best_validation_cost:
|
|
33
|
+
self.count += 1
|
|
34
|
+
if self.count >= self.delay:
|
|
35
|
+
return True
|
|
36
|
+
else:
|
|
37
|
+
self.count = 0
|
|
38
|
+
self.best_validation_cost = validation_cost
|
|
39
|
+
|
|
40
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from . import StoppingMethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EpochStoppingMethod(StoppingMethod):
|
|
5
|
+
"""
|
|
6
|
+
Stopping method that triggers whenever the number of epochs gets greater or
|
|
7
|
+
equal to the parameter.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, max_epochs=20):
|
|
11
|
+
if max_epochs <= 0:
|
|
12
|
+
raise ValueError(
|
|
13
|
+
f'invalid max_epochs, expected > 0 but got {max_epochs}'
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
self.max_epochs = max_epochs
|
|
17
|
+
|
|
18
|
+
def requires_validation(self) -> bool:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
def should_stop(
|
|
22
|
+
self, n_epochs: int, train_cost: float, validation_cost: float
|
|
23
|
+
) -> bool:
|
|
24
|
+
return n_epochs >= self.max_epochs
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from . import StoppingMethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TrainStopStoppingMethod(StoppingMethod):
|
|
5
|
+
"""
|
|
6
|
+
Stopping method that triggers whenever the training cost gets to 0, that is
|
|
7
|
+
the perceptron found a global minimum and will no longer try to improve,
|
|
8
|
+
rendering validation-related stopping methods unusable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def requires_validation(self) -> bool:
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
def should_stop(
|
|
15
|
+
self, n_epochs: int, train_cost: float, validation_cost: float
|
|
16
|
+
) -> bool:
|
|
17
|
+
return train_cost <= 0
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .dccp_ldep import LDEPDccpTrainer as LDEPDccpTrainer
|
|
2
|
+
from .dccp_rdep import RDEPDccpTrainer as RDEPDccpTrainer
|
|
3
|
+
from .dccp_simple_perceptron import (
|
|
4
|
+
SimplePerceptronDccpTrainer as SimplePerceptronDccpTrainer,
|
|
5
|
+
)
|
|
6
|
+
from .dccp_wrapper import DccpTrainer as DccpTrainer
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from typing import Literal, cast
|
|
2
|
+
import numpy as np
|
|
3
|
+
import cvxpy as cp
|
|
4
|
+
|
|
5
|
+
from .dccp_wrapper import DccpTrainer
|
|
6
|
+
from ..weighting import SampleWeighting
|
|
7
|
+
from ..stopping import StoppingMethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LDEPDccpTrainer(DccpTrainer):
|
|
11
|
+
"""
|
|
12
|
+
l-DEP trainer.
|
|
13
|
+
|
|
14
|
+
During training, the final parameters are implicitly embedded into one
|
|
15
|
+
another.
|
|
16
|
+
Some of them (like lambda) are only extracted once the training ends.
|
|
17
|
+
This is not necessary for performance, but is there for readability and
|
|
18
|
+
interpretability reasons once the estimator is trained.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
latent_dims: tuple[int, int],
|
|
24
|
+
margin: float,
|
|
25
|
+
penalty: float,
|
|
26
|
+
validation_ratio: float,
|
|
27
|
+
weighting_method: SampleWeighting,
|
|
28
|
+
stopping_methods: list[StoppingMethod],
|
|
29
|
+
use_dccp_library: bool,
|
|
30
|
+
verbose: Literal[0, 1, 2],
|
|
31
|
+
random_state: np.random.RandomState,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Initialize the l-DEP trainer.
|
|
35
|
+
|
|
36
|
+
param latent_dims: the latent dimensions for the max and min perceptrons
|
|
37
|
+
param [others]: see base class
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
self.latent_dims = latent_dims
|
|
41
|
+
|
|
42
|
+
super().__init__(
|
|
43
|
+
margin,
|
|
44
|
+
penalty,
|
|
45
|
+
validation_ratio,
|
|
46
|
+
weighting_method,
|
|
47
|
+
stopping_methods,
|
|
48
|
+
use_dccp_library,
|
|
49
|
+
verbose,
|
|
50
|
+
random_state,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def at_training_start(self, data_dim: int) -> None:
|
|
54
|
+
N_max, N_min = self.latent_dims
|
|
55
|
+
|
|
56
|
+
self._objective = None
|
|
57
|
+
|
|
58
|
+
# Extracted parameters that will be populated during training but need
|
|
59
|
+
# initial values for linearization
|
|
60
|
+
self.max_perceptron = self.random_state.randn(N_max)
|
|
61
|
+
self.min_perceptron = self.random_state.randn(N_min)
|
|
62
|
+
self.max_matrix = self.random_state.randn(N_max, data_dim)
|
|
63
|
+
self.min_matrix = self.random_state.randn(N_min, data_dim)
|
|
64
|
+
|
|
65
|
+
# Create constraints for linearization derived from real parameters,
|
|
66
|
+
# used to absorbe non-convex parameters that are restored at the end.
|
|
67
|
+
# Method inspired by arXiv:2011.06512v1.
|
|
68
|
+
self._max_training_weights = cp.Variable(N_max)
|
|
69
|
+
self._min_training_weights = cp.Variable(N_min)
|
|
70
|
+
self._max_training_matrix = cp.Variable((N_max, data_dim))
|
|
71
|
+
self._min_training_matrix = cp.Variable((N_min, data_dim))
|
|
72
|
+
|
|
73
|
+
def set_objective(self, K: int, cost_weights: np.ndarray) -> None:
|
|
74
|
+
# the objective and the slack variables do not change, cache them
|
|
75
|
+
if self._objective is not None:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
self._slack = cp.Variable(K)
|
|
79
|
+
|
|
80
|
+
value = cp.sum(cp.multiply(cp.pos(self._slack), cost_weights))
|
|
81
|
+
if self.penalty > 0:
|
|
82
|
+
value += self.penalty * (
|
|
83
|
+
cp.sum_squares(self._max_training_weights)
|
|
84
|
+
+ cp.sum_squares(self._min_training_weights)
|
|
85
|
+
+ cp.sum_squares(self._max_training_matrix)
|
|
86
|
+
+ cp.sum_squares(self._min_training_matrix)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self._objective = cp.Minimize(value)
|
|
90
|
+
|
|
91
|
+
def get_problem_linearized(
|
|
92
|
+
self, X: np.ndarray, y: np.ndarray, cost_weights: np.ndarray
|
|
93
|
+
) -> cp.Problem:
|
|
94
|
+
K = X.shape[0]
|
|
95
|
+
self.set_objective(K, cost_weights)
|
|
96
|
+
|
|
97
|
+
# Constraints: convex constraints are for data points in the first
|
|
98
|
+
# class, while concave ones are for points in the second class.
|
|
99
|
+
# Therefore, linearize things when needed to make the problem convex,
|
|
100
|
+
# sometimes using values from the previous epoch.
|
|
101
|
+
|
|
102
|
+
idx_max = np.argmax(self.max_perceptron + X @ self.max_matrix.T, axis=1)
|
|
103
|
+
idx_min = np.argmin(self.min_perceptron + X @ self.min_matrix.T, axis=1)
|
|
104
|
+
|
|
105
|
+
# create arrays to regroup the active indices using numpy, to then apply
|
|
106
|
+
# constraints all at once and use AST optimizations inside cvxpy
|
|
107
|
+
M_max = np.zeros((K, self.max_perceptron.size))
|
|
108
|
+
M_min = np.zeros((K, self.min_perceptron.size))
|
|
109
|
+
M_max[np.arange(K), idx_max] = 1
|
|
110
|
+
M_min[np.arange(K), idx_min] = 1
|
|
111
|
+
|
|
112
|
+
constraints = []
|
|
113
|
+
for label in [0, 1]:
|
|
114
|
+
mask = y == label
|
|
115
|
+
if not np.any(mask):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
X_ = X[mask]
|
|
119
|
+
K_ = X_.shape[0]
|
|
120
|
+
|
|
121
|
+
# start building the perceptron's outputs before the min/max
|
|
122
|
+
expr_max = X_ @ self._max_training_matrix.T
|
|
123
|
+
expr_min = X_ @ self._min_training_matrix.T
|
|
124
|
+
|
|
125
|
+
# add weights to every row of (X @ matrix.T) using np.ones to create
|
|
126
|
+
# a matrix safely for cvxpy's cpp backend
|
|
127
|
+
ones = np.ones((K_, 1))
|
|
128
|
+
expr_max += ones @ cp.reshape(
|
|
129
|
+
self._max_training_weights,
|
|
130
|
+
(1, self.max_perceptron.size),
|
|
131
|
+
order='C',
|
|
132
|
+
)
|
|
133
|
+
expr_min += ones @ cp.reshape(
|
|
134
|
+
self._min_training_weights,
|
|
135
|
+
(1, self.min_perceptron.size),
|
|
136
|
+
order='C',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
active_max = cp.sum(cp.multiply(M_max[mask], expr_max), axis=1)
|
|
140
|
+
active_min = cp.sum(cp.multiply(M_min[mask], expr_min), axis=1)
|
|
141
|
+
|
|
142
|
+
if label == 0:
|
|
143
|
+
constraints.append(
|
|
144
|
+
self._slack[mask] - active_min
|
|
145
|
+
>= self.margin + cp.max(expr_max, axis=1)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
constraints.append(
|
|
149
|
+
self._slack[mask] + active_max
|
|
150
|
+
>= self.margin - cp.min(expr_min, axis=1)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return cp.Problem(cast(cp.Minimize, self._objective), constraints)
|
|
154
|
+
|
|
155
|
+
def get_problem_dccp(
|
|
156
|
+
self, X: np.ndarray, y: np.ndarray, cost_weights: np.ndarray
|
|
157
|
+
) -> cp.Problem:
|
|
158
|
+
K = X.shape[0]
|
|
159
|
+
self.set_objective(K, cost_weights)
|
|
160
|
+
|
|
161
|
+
# Constraints: convex constraints are for data points in the first
|
|
162
|
+
# class, while concave ones are for points in the second class.
|
|
163
|
+
|
|
164
|
+
constraints = []
|
|
165
|
+
for label in [0, 1]:
|
|
166
|
+
mask = y == label
|
|
167
|
+
if not np.any(mask):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
X_ = X[mask]
|
|
171
|
+
K_ = X_.shape[0]
|
|
172
|
+
|
|
173
|
+
# start building the perceptron's outputs before the min/max
|
|
174
|
+
expr_max = X_ @ self._max_training_matrix.T
|
|
175
|
+
expr_min = X_ @ self._min_training_matrix.T
|
|
176
|
+
|
|
177
|
+
# add weights to every row of (X @ matrix.T) using np.ones to create
|
|
178
|
+
# a matrix safely for cvxpy's cpp backend
|
|
179
|
+
ones = np.ones((K_, 1))
|
|
180
|
+
expr_max += ones @ cp.reshape(
|
|
181
|
+
self._max_training_weights,
|
|
182
|
+
(1, self.max_perceptron.size),
|
|
183
|
+
order='C',
|
|
184
|
+
)
|
|
185
|
+
expr_min += ones @ cp.reshape(
|
|
186
|
+
self._min_training_weights,
|
|
187
|
+
(1, self.min_perceptron.size),
|
|
188
|
+
order='C',
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if label == 0:
|
|
192
|
+
constraints.append(
|
|
193
|
+
self.margin + cp.max(expr_max, axis=1)
|
|
194
|
+
<= self._slack[mask] - cp.min(expr_min, axis=1)
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
constraints.append(
|
|
198
|
+
self.margin - cp.min(expr_min, axis=1)
|
|
199
|
+
<= self._slack[mask] + cp.max(expr_max, axis=1)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return cp.Problem(cast(cp.Minimize, self._objective), constraints)
|
|
203
|
+
|
|
204
|
+
def get_cost(self, X: np.ndarray, y: np.ndarray) -> float:
|
|
205
|
+
expr_max = np.max(self.max_perceptron + X @ self.max_matrix.T, axis=1)
|
|
206
|
+
expr_min = np.min(self.min_perceptron + X @ self.min_matrix.T, axis=1)
|
|
207
|
+
|
|
208
|
+
# can use the expressions directly without needing to multiply by lambda
|
|
209
|
+
# since still in the training phase
|
|
210
|
+
cost = (expr_max + expr_min) * (1 - 2 * y)
|
|
211
|
+
return np.maximum(0, cost).sum()
|
|
212
|
+
|
|
213
|
+
def after_epoch(self) -> None:
|
|
214
|
+
# update the perceptrons weights from this epoch's results
|
|
215
|
+
if (
|
|
216
|
+
self._max_training_weights.value is None
|
|
217
|
+
or self._min_training_weights.value is None
|
|
218
|
+
):
|
|
219
|
+
raise ValueError('CvxPy could not optimize a perceptron')
|
|
220
|
+
|
|
221
|
+
self.max_perceptron = self._max_training_weights.value
|
|
222
|
+
self.min_perceptron = self._min_training_weights.value
|
|
223
|
+
|
|
224
|
+
# extract the matrices
|
|
225
|
+
if (
|
|
226
|
+
self._max_training_matrix.value is None
|
|
227
|
+
or self._min_training_matrix.value is None
|
|
228
|
+
):
|
|
229
|
+
raise ValueError('CvxPy could not optimize transformation matrices')
|
|
230
|
+
self.max_matrix = self._max_training_matrix.value
|
|
231
|
+
self.min_matrix = self._min_training_matrix.value
|
|
232
|
+
|
|
233
|
+
def at_training_end(self) -> None:
|
|
234
|
+
max_matrix_norm = np.linalg.norm(self.max_matrix)
|
|
235
|
+
min_matrix_norm = np.linalg.norm(self.min_matrix)
|
|
236
|
+
|
|
237
|
+
div = max_matrix_norm + min_matrix_norm
|
|
238
|
+
if np.isclose(div, 0):
|
|
239
|
+
raise ValueError('Transformation matrices are zero, cannot solve')
|
|
240
|
+
|
|
241
|
+
self.lambda_ = max_matrix_norm / div
|
|
242
|
+
|
|
243
|
+
# if lambda is close to a number that creates divisions by zero, it is
|
|
244
|
+
# safe to nullify the affected elements, that will not contribute anyway
|
|
245
|
+
if np.isclose(self.lambda_, 0):
|
|
246
|
+
self.max_matrix = np.zeros_like(self.max_matrix)
|
|
247
|
+
self.max_perceptron = np.zeros_like(self.max_perceptron)
|
|
248
|
+
else:
|
|
249
|
+
self.max_matrix /= self.lambda_
|
|
250
|
+
self.max_perceptron /= self.lambda_
|
|
251
|
+
if np.isclose(self.lambda_, 1):
|
|
252
|
+
self.min_matrix = np.zeros_like(self.max_matrix)
|
|
253
|
+
self.min_perceptron = np.zeros_like(self.min_perceptron)
|
|
254
|
+
else:
|
|
255
|
+
self.min_matrix /= 1 - self.lambda_
|
|
256
|
+
self.min_perceptron /= 1 - self.lambda_
|
|
257
|
+
|
|
258
|
+
def save_best(self) -> None:
|
|
259
|
+
self.saved = {
|
|
260
|
+
'max_w': np.copy(self.max_perceptron),
|
|
261
|
+
'min_w': np.copy(self.min_perceptron),
|
|
262
|
+
'max_m': np.copy(self.max_matrix),
|
|
263
|
+
'min_m': np.copy(self.min_matrix),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
def rollback_to_best(self) -> None:
|
|
267
|
+
self.max_perceptron = self.saved['max_w']
|
|
268
|
+
self.min_perceptron = self.saved['min_w']
|
|
269
|
+
self.max_matrix = self.saved['max_m']
|
|
270
|
+
self.min_matrix = self.saved['min_m']
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import cvxpy as cp
|
|
3
|
+
from typing import Literal, cast
|
|
4
|
+
from warnings import warn
|
|
5
|
+
|
|
6
|
+
from .dccp_wrapper import DccpTrainer
|
|
7
|
+
from ..weighting import SampleWeighting
|
|
8
|
+
from ..stopping import StoppingMethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RDEPDccpTrainer(DccpTrainer):
|
|
12
|
+
"""
|
|
13
|
+
r-DEP trainer.
|
|
14
|
+
|
|
15
|
+
During training, the final parameters are implicitly embedded into one
|
|
16
|
+
another.
|
|
17
|
+
Some of them (like lambda) are only extracted once the training ends.
|
|
18
|
+
This is not necessary for performance, but is there for readability and
|
|
19
|
+
interpretability reasons once the estimator is trained.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
lambda_bounds: tuple[float, float],
|
|
25
|
+
margin: float,
|
|
26
|
+
penalty: float,
|
|
27
|
+
validation_ratio: float,
|
|
28
|
+
weighting_method: SampleWeighting,
|
|
29
|
+
stopping_methods: list[StoppingMethod],
|
|
30
|
+
use_dccp_library: bool,
|
|
31
|
+
verbose: Literal[0, 1, 2],
|
|
32
|
+
random_state: np.random.RandomState,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Initialize the r-DEP trainer.
|
|
36
|
+
|
|
37
|
+
param lambda_bounds: A pair of min and max values for lambda, to avoid
|
|
38
|
+
solvers (especially dccp) from failing to optimize.
|
|
39
|
+
To keep the constraints at the right convexity,
|
|
40
|
+
the bounds must be inside [0, 1].
|
|
41
|
+
param [others]: See base class.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
super().__init__(
|
|
45
|
+
margin,
|
|
46
|
+
penalty,
|
|
47
|
+
validation_ratio,
|
|
48
|
+
weighting_method,
|
|
49
|
+
stopping_methods,
|
|
50
|
+
use_dccp_library,
|
|
51
|
+
verbose,
|
|
52
|
+
random_state,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if lambda_bounds[0] < 0 or lambda_bounds[1] > 1:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
'Invalid lambda_bounds, expected within [0, 1] '
|
|
58
|
+
f'got {list(lambda_bounds)}'
|
|
59
|
+
)
|
|
60
|
+
if (
|
|
61
|
+
lambda_bounds[0] == 0 or lambda_bounds[1] == 1
|
|
62
|
+
) and use_dccp_library:
|
|
63
|
+
warn(
|
|
64
|
+
'Warning: lambda_bounds may be inappropriate for dccp solver: '
|
|
65
|
+
f'{list(lambda_bounds)}'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.lambda_bounds = lambda_bounds
|
|
69
|
+
|
|
70
|
+
def at_training_start(self, data_dim: int) -> None:
|
|
71
|
+
# similar to l-DEP, see corresponding files for implementation comments
|
|
72
|
+
self._objective = None
|
|
73
|
+
|
|
74
|
+
# Extracted parameters that will be populated during training but need
|
|
75
|
+
# initial values for linearization
|
|
76
|
+
self.max_perceptron = self.random_state.randn(data_dim)
|
|
77
|
+
self.min_perceptron = self.random_state.randn(data_dim)
|
|
78
|
+
self.lambda_ = 0.5
|
|
79
|
+
|
|
80
|
+
# to make the problem DCP, the training weights absorb the
|
|
81
|
+
# multiplications involving lambda_, they are explicited at the end of
|
|
82
|
+
# epochs
|
|
83
|
+
self._max_training_weights = cp.Variable(data_dim)
|
|
84
|
+
self._min_training_weights = cp.Variable(data_dim)
|
|
85
|
+
self._training_lambda = cp.Variable()
|
|
86
|
+
|
|
87
|
+
def set_objective(
|
|
88
|
+
self, X: np.ndarray, y: np.ndarray, cost_weights: np.ndarray
|
|
89
|
+
) -> None:
|
|
90
|
+
# the objective and the slack variables do not change, cache them
|
|
91
|
+
if self._objective is not None:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
n_features = len(np.unique(y))
|
|
95
|
+
if n_features != 2:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
'Detected degenerate dataset, perhaps after '
|
|
98
|
+
'train/validation split. Expected 2 features, '
|
|
99
|
+
f'found only {n_features} feature(s)'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# figure out whether we should invert the perceptron's output, since
|
|
103
|
+
# the lower class should be lower in coordinates
|
|
104
|
+
labels, inv, counts = np.unique(
|
|
105
|
+
y, return_inverse=True, return_counts=True
|
|
106
|
+
)
|
|
107
|
+
sums = np.zeros((len(labels), X.shape[1]))
|
|
108
|
+
np.add.at(sums, inv, X)
|
|
109
|
+
centroids = sums / counts[:, np.newaxis]
|
|
110
|
+
|
|
111
|
+
# set the objective
|
|
112
|
+
self.invert_res = centroids[0].sum() > centroids[1].sum()
|
|
113
|
+
|
|
114
|
+
self._slack = cp.Variable(len(X))
|
|
115
|
+
|
|
116
|
+
value = cp.sum(cp.multiply(cp.pos(self._slack), cost_weights))
|
|
117
|
+
if self.penalty > 0:
|
|
118
|
+
value += self.penalty * (
|
|
119
|
+
cp.sum_squares(self._max_training_weights)
|
|
120
|
+
+ cp.sum_squares(self._min_training_weights)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
self._objective = cp.Minimize(value)
|
|
124
|
+
|
|
125
|
+
def get_problem_linearized(
|
|
126
|
+
self, X: np.ndarray, y: np.ndarray, cost_weights: np.ndarray
|
|
127
|
+
) -> cp.Problem:
|
|
128
|
+
K = X.shape[0]
|
|
129
|
+
self.set_objective(X, y, cost_weights)
|
|
130
|
+
|
|
131
|
+
# Constraints: convex constraints are for data points in the first
|
|
132
|
+
# class, while concave ones are for points in the second class.
|
|
133
|
+
# Therefore, linearize things when needed to make the problem convex,
|
|
134
|
+
# sometimes using values from the previous epoch.
|
|
135
|
+
|
|
136
|
+
idx_max = np.argmax(self.lambda_ * X + self.max_perceptron, axis=1)
|
|
137
|
+
idx_min = np.argmin(
|
|
138
|
+
(1 - self.lambda_) * X + self.min_perceptron, axis=1
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# create arrays to regroup the active indices using numpy, to then apply
|
|
142
|
+
# constraints all at once and use AST optimizations inside cvxpy
|
|
143
|
+
M_max = np.zeros((K, self.max_perceptron.size))
|
|
144
|
+
M_min = np.zeros((K, self.min_perceptron.size))
|
|
145
|
+
M_max[np.arange(K), idx_max] = 1
|
|
146
|
+
M_min[np.arange(K), idx_min] = 1
|
|
147
|
+
|
|
148
|
+
constraints = []
|
|
149
|
+
for label in [0, 1]:
|
|
150
|
+
mask = y == label
|
|
151
|
+
if not np.any(mask):
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
X_ = X[mask]
|
|
155
|
+
K_ = X_.shape[0]
|
|
156
|
+
|
|
157
|
+
# add weights to every row of (X @ matrix.T) using np.ones to create
|
|
158
|
+
# a matrix safely for cvxpy's cpp backend
|
|
159
|
+
ones = np.ones((K_, 1))
|
|
160
|
+
expr_max = self._training_lambda * X_
|
|
161
|
+
expr_min = (1 - self._training_lambda) * X_
|
|
162
|
+
expr_max += ones @ cp.reshape(
|
|
163
|
+
self._max_training_weights,
|
|
164
|
+
(1, self.max_perceptron.size),
|
|
165
|
+
order='C',
|
|
166
|
+
)
|
|
167
|
+
expr_min += ones @ cp.reshape(
|
|
168
|
+
self._min_training_weights,
|
|
169
|
+
(1, self.min_perceptron.size),
|
|
170
|
+
order='C',
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
active_max = cp.sum(cp.multiply(M_max[mask], expr_max), axis=1)
|
|
174
|
+
active_min = cp.sum(cp.multiply(M_min[mask], expr_min), axis=1)
|
|
175
|
+
|
|
176
|
+
if (label == 0) ^ self.invert_res:
|
|
177
|
+
constraints.append(
|
|
178
|
+
self._slack[mask] - active_min
|
|
179
|
+
>= self.margin + cp.max(expr_max, axis=1)
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
constraints.append(
|
|
183
|
+
self._slack[mask] + active_max
|
|
184
|
+
>= self.margin - cp.min(expr_min, axis=1)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# avoid lambda making some constraints convex when they should be
|
|
188
|
+
# concave and vice versa
|
|
189
|
+
constraints += [
|
|
190
|
+
self.lambda_bounds[0] <= self._training_lambda,
|
|
191
|
+
self._training_lambda <= self.lambda_bounds[1],
|
|
192
|
+
]
|
|
193
|
+
return cp.Problem(cast(cp.Minimize, self._objective), constraints)
|
|
194
|
+
|
|
195
|
+
def get_problem_dccp(
|
|
196
|
+
self, X: np.ndarray, y: np.ndarray, cost_weights: np.ndarray
|
|
197
|
+
) -> cp.Problem:
|
|
198
|
+
self.set_objective(X, y, cost_weights)
|
|
199
|
+
|
|
200
|
+
constraints = []
|
|
201
|
+
for label in [0, 1]:
|
|
202
|
+
mask = y == label
|
|
203
|
+
if not np.any(mask):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
X_ = X[mask]
|
|
207
|
+
K_ = X_.shape[0]
|
|
208
|
+
|
|
209
|
+
ones = np.ones((K_, 1))
|
|
210
|
+
expr_max = self._training_lambda * X_
|
|
211
|
+
expr_min = (1 - self._training_lambda) * X_
|
|
212
|
+
expr_max += ones @ cp.reshape(
|
|
213
|
+
self._max_training_weights,
|
|
214
|
+
(1, self.max_perceptron.size),
|
|
215
|
+
order='C',
|
|
216
|
+
)
|
|
217
|
+
expr_min += ones @ cp.reshape(
|
|
218
|
+
self._min_training_weights,
|
|
219
|
+
(1, self.min_perceptron.size),
|
|
220
|
+
order='C',
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
expr_max = cp.max(expr_max, axis=1)
|
|
224
|
+
expr_min = cp.min(expr_min, axis=1)
|
|
225
|
+
|
|
226
|
+
if (label == 0) ^ self.invert_res:
|
|
227
|
+
constraints.append(
|
|
228
|
+
self.margin + expr_max <= self._slack[mask] - expr_min
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
constraints.append(
|
|
232
|
+
self.margin - expr_min <= self._slack[mask] + expr_max
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
constraints += [
|
|
236
|
+
self.lambda_bounds[0] <= self._training_lambda,
|
|
237
|
+
self._training_lambda <= self.lambda_bounds[1],
|
|
238
|
+
]
|
|
239
|
+
return cp.Problem(cast(cp.Minimize, self._objective), constraints)
|
|
240
|
+
|
|
241
|
+
def get_cost(self, X: np.ndarray, y: np.ndarray) -> float:
|
|
242
|
+
expr_max = np.max(self.lambda_ * X + self.max_perceptron, axis=1)
|
|
243
|
+
expr_min = np.min((1 - self.lambda_) * X + self.min_perceptron, axis=1)
|
|
244
|
+
|
|
245
|
+
cost = (expr_max + expr_min) * (1 - 2 * y) * (1 - 2 * self.invert_res)
|
|
246
|
+
|
|
247
|
+
return np.maximum(0, cost).sum()
|
|
248
|
+
|
|
249
|
+
def after_epoch(self) -> None:
|
|
250
|
+
# update the perceptrons weights from this epoch's results
|
|
251
|
+
if (
|
|
252
|
+
self._max_training_weights.value is None
|
|
253
|
+
or self._min_training_weights.value is None
|
|
254
|
+
):
|
|
255
|
+
raise ValueError('CvxPy could not optimize a perceptron')
|
|
256
|
+
if self._training_lambda.value is None:
|
|
257
|
+
raise ValueError('CvxPy could not optimize lambda')
|
|
258
|
+
|
|
259
|
+
self.max_perceptron = self._max_training_weights.value
|
|
260
|
+
self.min_perceptron = self._min_training_weights.value
|
|
261
|
+
self.lambda_ = self._training_lambda.value
|
|
262
|
+
|
|
263
|
+
# if lambda is close to a number that creates divisions by zero, it is
|
|
264
|
+
# safe to nullify the affected elements, that will not contribute anyway
|
|
265
|
+
if np.isclose(self.lambda_, 0):
|
|
266
|
+
self.max_perceptron = np.zeros_like(self.max_perceptron)
|
|
267
|
+
else:
|
|
268
|
+
self.max_perceptron /= self.lambda_
|
|
269
|
+
|
|
270
|
+
if np.isclose(self.lambda_, 1):
|
|
271
|
+
self.min_perceptron = np.zeros_like(self.min_perceptron)
|
|
272
|
+
else:
|
|
273
|
+
self.min_perceptron /= 1 - self.lambda_
|
|
274
|
+
|
|
275
|
+
def save_best(self) -> None:
|
|
276
|
+
self.saved = {
|
|
277
|
+
'max_w': np.copy(self.max_perceptron),
|
|
278
|
+
'min_w': np.copy(self.min_perceptron),
|
|
279
|
+
'lambda': self.lambda_,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
def rollback_to_best(self) -> None:
|
|
283
|
+
self.max_perceptron = self.saved['max_w']
|
|
284
|
+
self.min_perceptron = self.saved['min_w']
|
|
285
|
+
self.lambda_ = self.saved['lambda']
|