sklearn-morpho 0.1.0__py2.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.
@@ -0,0 +1,3 @@
1
+ from .classifiers.ldep import LDEP
2
+ from .weighting import *
3
+ from .stopping import *
@@ -0,0 +1,172 @@
1
+ import numpy as np
2
+ from typing import Literal, cast
3
+
4
+ from sklearn.base import BaseEstimator, ClassifierMixin
5
+ from sklearn.preprocessing import StandardScaler
6
+ from sklearn.utils import check_random_state
7
+ from sklearn.utils.validation import validate_data, check_is_fitted
8
+ from sklearn.utils.multiclass import unique_labels
9
+
10
+ from ..dccp.dccp_ldep import LDEPDccpTrainer
11
+ from ..stopping import (
12
+ StoppingMethod,
13
+ CostStoppingMethod,
14
+ EarlyStoppingMethod,
15
+ EpochStoppingMethod,
16
+ TrainStopStoppingMethod,
17
+ )
18
+ from ..weighting import SampleWeighting, NoneSampleWeighting
19
+
20
+ class LDEP(ClassifierMixin, BaseEstimator):
21
+ """
22
+ Scikit-learn estimator wrapper around a l-DEP (linear Dilation-Erosion
23
+ morphological Perceptron) for binary data classification.
24
+
25
+ l-DEP forward pass equation:
26
+
27
+ \\[ y = f(\\lambda \\tau_(R_1(x)) + (1 - \\lambda) \\tau'_(R_2(x))) \\]
28
+
29
+ Where $\\tau$ refers to the activation of a (max, +) morphological
30
+ perceptron and $\\tau'$ to a (min, +) one.
31
+ $R_1, R_2$ are linear transformations to apply to the training data.
32
+ They convert it into a latent space with possibly different dimensions,
33
+ also allowing classification of arbitrarily distributed data.
34
+ A higher dimension for the latent space will result in slower training
35
+ times, but will allow the decision boundary to be more complex.
36
+ """
37
+
38
+ def __init__(self, latent_dims: tuple[int, int] = (10, 10), margin = 1.,
39
+ validation_ratio = .3,
40
+ weighting_method: SampleWeighting | None = None,
41
+ stopping_methods: list[StoppingMethod] | None = None,
42
+ verbose: Literal[0, 1, 2] = 0,
43
+ random_state: np.random.RandomState | None = None) -> None:
44
+ """
45
+ Initialize the classifier, see class help for more.
46
+
47
+ param latent_dims: The dimensions of the latent spaces used for the
48
+ linear transformations output
49
+ param margin: Enforce a margin between the decision boundary
50
+ and the data. May help with linearly separable
51
+ datasets, but generally lower is more accurate.
52
+ param validation_radio: How much of the training set to dedicate to use
53
+ as validation during fitting.
54
+ Must be between 0 and 1 (inclusive, exclusive),
55
+ if set to exactly 0 then incompatible stopping
56
+ methods cannot be used (e.g. early stopping).
57
+ param weighting_method: The weighting method to use: apply weights to
58
+ the cost contribution of each data point to help
59
+ avoid outliers.
60
+ If left to None, will use NoneWeightingMethod()
61
+ param stopping_methods: A list of stopping methods, must not be empty.
62
+ At each epoch, these methods will be
63
+ sequentially asked whether the training should
64
+ stop. In this case, epoch ends by rolling back
65
+ to the epoch with the best validation cost.
66
+ If left to None, will use:
67
+ [
68
+ CostStoppingMethod(1e-6),
69
+ EarlyStoppingMethod(5),
70
+ EpochStoppingMethod(20),
71
+ TrainStopStoppingMethod(),
72
+ ]
73
+ param verbose: Whether to log extra information. 0: no logging,
74
+ 1: basic logging / timing, 2: cvxpy solve() set
75
+ to verbose mode.
76
+ param random_state: A RandomState object or None to allow for seeded
77
+ randomness.
78
+ """
79
+
80
+ self.latent_dims = latent_dims
81
+ self.margin = margin
82
+ self.validation_ratio = validation_ratio
83
+ self.weighting_method = weighting_method
84
+ self.stopping_methods = stopping_methods
85
+ self.verbose: Literal[0, 1, 2] = verbose
86
+ self.random_state = random_state
87
+
88
+ def fit(self, X: np.ndarray, y: np.ndarray) -> LDEP:
89
+ """
90
+ Fit the classifier, create attributes:
91
+ - self.max_perceptron_
92
+ - self.min_perceptron_
93
+ - self.lambda_
94
+ - self.max_matrix_
95
+ - self.min_matrix_
96
+ - self.classes_: Unique labels generated from y
97
+
98
+ X and y must represent binary classifiable data.
99
+ """
100
+
101
+ # input data validation
102
+ random_state = check_random_state(self.random_state)
103
+ X, y = validate_data(self, X, y)
104
+ self.scaler_ = StandardScaler()
105
+ X_scaled = self.scaler_.fit_transform(X)
106
+
107
+ # set unset parameters to their default values
108
+ if self.weighting_method is None:
109
+ weighting_method = NoneSampleWeighting()
110
+ else:
111
+ weighting_method = self.weighting_method
112
+ if self.stopping_methods is None:
113
+ stopping_methods = [
114
+ CostStoppingMethod(1e-6),
115
+ EarlyStoppingMethod(5),
116
+ EpochStoppingMethod(20),
117
+ TrainStopStoppingMethod(),
118
+ ]
119
+ else:
120
+ stopping_methods = self.stopping_methods
121
+
122
+ # create classes and convert them to distinct integers for fitting
123
+ # the classes are persisted inside the object for use in predict
124
+ self.classes_ = unique_labels(y)
125
+ classes_list = list(self.classes_)
126
+ y_integers = np.array([classes_list.index(c) for c in y],
127
+ dtype=np.int32)
128
+
129
+ if len(classes_list) != 2:
130
+ raise ValueError('Only binary classification is supported but '
131
+ f'got {len(classes_list)} class(es).')
132
+
133
+ # create and train perceptrons
134
+ trainer = LDEPDccpTrainer(self.latent_dims, self.margin,
135
+ self.validation_ratio, weighting_method,
136
+ stopping_methods, self.verbose,
137
+ random_state)
138
+
139
+ trainer.train(X_scaled, y_integers)
140
+ self.max_perceptron_ = trainer.max_perceptron
141
+ self.min_perceptron_ = trainer.min_perceptron
142
+ self.lambda_ = trainer.lambda_
143
+ self.max_matrix_ = trainer.max_matrix
144
+ self.min_matrix_ = trainer.min_matrix
145
+
146
+ return self
147
+
148
+ def decision_function(self, X: np.ndarray) -> np.ndarray:
149
+ check_is_fitted(self)
150
+ X = validate_data(self, X, reset=False)
151
+ X_scaled = cast(np.ndarray, self.scaler_.transform(X))
152
+
153
+ expr_max = np.max(self.max_perceptron_ + X_scaled @ self.max_matrix_.T,
154
+ axis=1)
155
+ expr_min = np.min(self.min_perceptron_ + X_scaled @ self.min_matrix_.T,
156
+ axis=1)
157
+ return expr_max * self.lambda_ + expr_min * (1 - self.lambda_)
158
+
159
+ def predict(self, X: np.ndarray) -> np.ndarray:
160
+ check_is_fitted(self)
161
+ return self.classes_[(self.decision_function(X) >= 0).astype(int)]
162
+
163
+ def __sklearn_tags__(self):
164
+ """
165
+ Overriden method to allow check_estimator to not run accuracy tests.
166
+ These are designed for perceptrons with a linear decision boundary,
167
+ which is not the case for a morphological perceptron.
168
+ """
169
+
170
+ tags = super().__sklearn_tags__()
171
+ tags.classifier_tags.multi_class = False
172
+ return tags
@@ -0,0 +1,162 @@
1
+ from typing import Literal
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
+ class LDEPDccpTrainer(DccpTrainer):
10
+ def __init__(self, latent_dims: tuple[int, int],
11
+ margin: float, validation_ratio: float,
12
+ weighting_method: SampleWeighting,
13
+ stopping_methods: list[StoppingMethod],
14
+ verbose: Literal[0, 1, 2],
15
+ random_state: np.random.RandomState) -> None:
16
+ """
17
+ Initialize the l-DEP trainer.
18
+
19
+ param latent_dims: the latent dimensions for the max and min perceptrons
20
+ param [others]: see base class
21
+ """
22
+
23
+ self.latent_dims = latent_dims
24
+
25
+ super().__init__(margin, validation_ratio, weighting_method,
26
+ stopping_methods, verbose, random_state)
27
+
28
+ def at_training_start(self, data_dim: int) -> None:
29
+ N_max, N_min = self.latent_dims
30
+
31
+ self._objective = None
32
+
33
+ # Extracted parameters that will be populated during training but need
34
+ # initial values for linearization
35
+ self.max_perceptron = self.random_state.randn(N_max)
36
+ self.min_perceptron = self.random_state.randn(N_min)
37
+ self.max_matrix = self.random_state.randn(N_max, data_dim)
38
+ self.min_matrix = self.random_state.randn(N_min, data_dim)
39
+
40
+ # Create constraints for linearization derived from real parameters,
41
+ # used to absorbe non-convex parameters that are restored at the end.
42
+ # Method inspired by arXiv:2011.06512v1.
43
+ # TODO: is this representation an issue since the final lambda parameter
44
+ # is not necessarily in [0, 1]?
45
+ self._max_training_weights = cp.Variable(N_max)
46
+ self._min_training_weights = cp.Variable(N_min)
47
+ self._max_training_matrix = cp.Variable((N_max, data_dim))
48
+ self._min_training_matrix = cp.Variable((N_min, data_dim))
49
+
50
+ def get_problem(self, X: np.ndarray, y: np.ndarray,
51
+ cost_weights: np.ndarray) -> cp.Problem:
52
+ K = X.shape[0]
53
+
54
+ # the objective and the slack variables do not change, cache them
55
+ if self._objective is None:
56
+ self._slack = cp.Variable(K)
57
+ self._objective = cp.Minimize(
58
+ cp.sum(cp.multiply(cp.pos(self._slack), cost_weights)))
59
+
60
+ # Constraints: convex constraints are for data points in the first
61
+ # class, while concave ones are for points in the second class.
62
+ # Therefore, linearize things when needed to make the problem convex,
63
+ # sometimes using values from the previous epoch.
64
+
65
+ idx_max = np.argmax(self.max_perceptron + X @ self.max_matrix.T,
66
+ axis=1)
67
+ idx_min = np.argmin(self.min_perceptron + X @ self.min_matrix.T,
68
+ axis=1)
69
+
70
+ # create arrays to regroup the active indices using numpy, to then apply
71
+ # constraints all at once and use AST optimizations inside cvxpy
72
+ M_max = np.zeros((K, self.max_perceptron.size))
73
+ M_min = np.zeros((K, self.min_perceptron.size))
74
+ M_max[np.arange(K), idx_max] = 1
75
+ M_min[np.arange(K), idx_min] = 1
76
+
77
+ constraints = []
78
+ for label in [0, 1]:
79
+ mask = y == label
80
+ if not np.any(mask):
81
+ continue
82
+
83
+ X_ = X[mask]
84
+ K_ = X_.shape[0]
85
+
86
+ # start building the perceptron's outputs before the min/max
87
+ expr_max = X_ @ self._max_training_matrix.T
88
+ expr_min = X_ @ self._min_training_matrix.T
89
+
90
+ # add weights to every row of (X @ matrix.T) using np.ones to create
91
+ # a matrix safely for cvxpy's cpp backend
92
+ ones = np.ones((K_, 1))
93
+ expr_max += ones @ cp.reshape(self._max_training_weights,
94
+ (1, self.max_perceptron.size),
95
+ order='C')
96
+ expr_min += ones @ cp.reshape(self._min_training_weights,
97
+ (1, self.min_perceptron.size),
98
+ order='C')
99
+
100
+ active_max = cp.sum(cp.multiply(M_max[mask], expr_max), axis=1)
101
+ active_min = cp.sum(cp.multiply(M_min[mask], expr_min), axis=1)
102
+
103
+ if label == 0:
104
+ constraints.append(self._slack[mask] >= self.margin
105
+ + cp.max(expr_max, axis=1) + active_min)
106
+ else:
107
+ constraints.append(self._slack[mask] >= self.margin
108
+ - active_max - cp.min(expr_min, axis=1))
109
+ return cp.Problem(self._objective, constraints)
110
+
111
+ def get_cost(self, X: np.ndarray, y: np.ndarray) -> float:
112
+ expr_max = np.max(self.max_perceptron + X @ self.max_matrix.T, axis=1)
113
+ expr_min = np.min(self.min_perceptron + X @ self.min_matrix.T, axis=1)
114
+
115
+ # can use the expressions directly without needing to multiply by lambda
116
+ # since still in the training phase
117
+ cost = self.margin + (expr_max + expr_min) * (1 - 2 * y)
118
+ return np.maximum(0, cost).sum()
119
+
120
+ def after_epoch(self) -> None:
121
+ # update the perceptrons weights from this epoch's results
122
+ if self._max_training_weights.value is None or \
123
+ self._min_training_weights.value is None:
124
+ raise ValueError('CvxPy could not optimize a perceptron')
125
+
126
+ self.max_perceptron = self._max_training_weights.value
127
+ self.min_perceptron = self._min_training_weights.value
128
+
129
+ # extract the matrices
130
+ if self._max_training_matrix.value is None \
131
+ or self._min_training_matrix.value is None:
132
+ raise ValueError('CvxPy could not optimize transformation matrices')
133
+ self.max_matrix = self._max_training_matrix.value
134
+ self.min_matrix = self._min_training_matrix.value
135
+
136
+ def at_training_end(self) -> None:
137
+ max_matrix_norm = np.linalg.norm(self.max_matrix)
138
+ min_matrix_norm = np.linalg.norm(self.min_matrix)
139
+
140
+ div = max_matrix_norm + min_matrix_norm
141
+ if np.isclose(div, 0):
142
+ raise ValueError('Transformation matrices are zero, cannot solve')
143
+
144
+ self.lambda_ = max_matrix_norm / div
145
+ self.max_matrix /= self.lambda_
146
+ self.min_matrix /= 1 - self.lambda_
147
+ self.max_perceptron /= self.lambda_
148
+ self.min_perceptron /= 1 - self.lambda_
149
+
150
+ def save_best(self) -> None:
151
+ self.saved = {
152
+ 'max_w': np.copy(self.max_perceptron),
153
+ 'min_w': np.copy(self.min_perceptron),
154
+ 'max_m': np.copy(self.max_matrix),
155
+ 'min_m': np.copy(self.min_matrix),
156
+ }
157
+
158
+ def rollback_to_best(self) -> None:
159
+ self.max_perceptron = self.saved['max_w']
160
+ self.min_perceptron = self.saved['min_w']
161
+ self.max_matrix = self.saved['max_m']
162
+ self.min_matrix = self.saved['min_m']
@@ -0,0 +1,225 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Literal, cast
3
+ import numpy as np
4
+ import cvxpy as cp
5
+ from time import time
6
+
7
+ from sklearn.model_selection import train_test_split
8
+ from ..weighting import SampleWeighting
9
+ from ..stopping import StoppingMethod
10
+
11
+ class DccpTrainer(ABC):
12
+ """
13
+ Abstract class for DCCP optimization using cvxpy.
14
+ """
15
+
16
+ def __init__(self, margin: float, validation_ratio: float,
17
+ weighting_method: SampleWeighting,
18
+ stopping_methods: list[StoppingMethod],
19
+ verbose: Literal[0, 1, 2],
20
+ random_state: np.random.RandomState) -> None:
21
+ """
22
+ Initialize the trainer.
23
+
24
+ param validation_radio: How much of the training set to dedicate to use
25
+ as validation during fitting.
26
+ param weighting_method: The weighting method to use: apply weights to
27
+ the cost contribution of each data point to help
28
+ avoid outliers.
29
+ param stopping_methods: A list of stopping methods, must not be empty.
30
+ At each epoch, these methods will be
31
+ sequentially asked whether the training should
32
+ stop. In this case, training ends by rolling
33
+ back to the epoch with the best validation cost.
34
+ param verbose: Whether to log extra information. 0: no logging,
35
+ 1: basic logging / timing, 2: cvxpy solve() set
36
+ to verbose mode.
37
+ param random_state: A RandomState object or None to allow for seeded
38
+ randomness.
39
+ """
40
+
41
+ if margin < 0:
42
+ raise ValueError(f'Invalid margin, expected >= 0 but got {margin}')
43
+ if not 0 <= validation_ratio < 1:
44
+ raise ValueError('Invalid validation ratio, expected >= 0 and < 1 '
45
+ f'but got {validation_ratio}')
46
+ if not len(stopping_methods):
47
+ raise ValueError('Empty list of stopping methods, training would '
48
+ 'run indefinitely')
49
+
50
+ self.margin = margin
51
+ self.validation_ratio = validation_ratio
52
+ self.weighting_method = weighting_method
53
+ self.stopping_methods = stopping_methods
54
+ self.verbose = verbose
55
+ self.random_state = random_state
56
+
57
+ def at_training_start(self, data_dim: int) -> None:
58
+ """
59
+ Optional actions to take when the training starts.
60
+ For example, create and persist a list of additional variables to
61
+ optimize.
62
+ """
63
+
64
+ def after_epoch(self) -> None:
65
+ """
66
+ Optional additional actions to take after each epoch, that is after
67
+ cvxpy solve() and during wrapping code.
68
+ """
69
+
70
+ def at_training_end(self) -> None:
71
+ """
72
+ Optional actions to take when the training is over.
73
+ Ran as the very last function call in train, meaning after
74
+ rollback_to_best is called.
75
+ """
76
+
77
+ @abstractmethod
78
+ def save_best(self) -> None:
79
+ """
80
+ Paired with rollback_to_best.
81
+
82
+ Save the data generated by after_epoch somewhere accessible at the
83
+ end of the training process.
84
+
85
+ Used in the training loop to roll back to the best state of the model at
86
+ the end of the loop.
87
+ """
88
+
89
+ @abstractmethod
90
+ def rollback_to_best(self) -> None:
91
+ """
92
+ Paired with save_best.
93
+
94
+ Restore the state saved in save_best, after the training process is
95
+ done, to go back to the best performing model.
96
+ """
97
+
98
+ @abstractmethod
99
+ def get_problem(self, X: np.ndarray, y: np.ndarray,
100
+ cost_weights: np.ndarray) -> cp.Problem:
101
+ """
102
+ Compute a cvxpy Objective and a list of Constraints for use in DCCP.
103
+ The prlblem must be DCP, meaning the constraints must all be convex.
104
+ For concave constraints, they must be linearized beforehand, possibly
105
+ from constant values taken from the previous epoch result.
106
+
107
+ param X: The data points
108
+ param y: The data points classes
109
+ param cost_weights: The wdccp weights, all 1 if not using wdccp, for
110
+ use in computing the objective.
111
+ This is an array with one element corresponding to
112
+ an element in X.
113
+
114
+ return: A tuple containing a cvxpy Objective and a list of
115
+ Constraints.
116
+ """
117
+
118
+ @abstractmethod
119
+ def get_cost(self, X: np.ndarray, y: np.ndarray) -> float:
120
+ """
121
+ Compute the cost for a set of sample points in the same way as the cost
122
+ defined in get_problem, used in train/validation training.
123
+
124
+ If applicable, should use the weights computed in this epoch's
125
+ after_epoch call, as it will have been made before this method's call.
126
+ """
127
+
128
+ def train(self, X: np.ndarray, y: np.ndarray) -> None:
129
+ """
130
+ Train using DCCP.
131
+ This means this function can only solve problems where the cost function
132
+ can be separable in the given convex and concave parts.
133
+
134
+ param X: The set of data points to use for training.
135
+ param y: The set of labels associated with elements of X.
136
+
137
+ return: The final cost, or -1 if no training happened.
138
+ """
139
+
140
+ if self.verbose:
141
+ print('Starting fitting with DCCP')
142
+
143
+ no_validation = self.validation_ratio == 0
144
+ if no_validation:
145
+ for stopping_method in self.stopping_methods:
146
+ if stopping_method.requires_validation():
147
+ raise ValueError('Cannot train, at least one stopping '
148
+ 'method requires validation which is '
149
+ 'disabled by the user')
150
+
151
+ X_train, X_validation = X, np.empty(X.shape)
152
+ y_train, y_validation = y, np.empty(y.shape)
153
+ else:
154
+ X_train, X_validation, y_train, y_validation = train_test_split(
155
+ X, y, test_size=self.validation_ratio,
156
+ random_state=self.random_state)
157
+ X_train = cast(np.ndarray, X_train)
158
+ X_validation = cast(np.ndarray, X_validation)
159
+ y_train = cast(np.ndarray, y_train)
160
+ y_validation = cast(np.ndarray, y_validation)
161
+
162
+ if not X_train.size or (not X_validation.size and not no_validation):
163
+ raise ValueError('Current validation ratio makes unwanted '
164
+ 'degenerate train/validation split: '
165
+ + str(self.validation_ratio))
166
+
167
+ start = time()
168
+ cost_weights, cost_normalizer = \
169
+ self.weighting_method.fit_transform(X_train, y_train)
170
+
171
+ epoch = 1
172
+ validation_cost: float = np.inf
173
+ best_validation_cost: float = np.inf
174
+
175
+ # formulate the cvxpy problem to solve, common to all epochs
176
+ self.at_training_start(X_train.shape[1])
177
+
178
+ while True:
179
+ problem = self.get_problem(X_train, y_train, cost_weights)
180
+
181
+ # solve the problem, normalize the cost when using wdccp
182
+ cvxpy_cost = cast(float, problem.solve(verbose=self.verbose == 2)) \
183
+ * cost_normalizer
184
+
185
+ self.after_epoch()
186
+
187
+ # best score and loop logic
188
+ done = False
189
+ if no_validation:
190
+ train_cost = cvxpy_cost
191
+ validation_cost = cvxpy_cost
192
+ else:
193
+ train_cost = self.get_cost(X_train, y_train)
194
+ validation_cost = self.get_cost(X_validation, y_validation)
195
+ if validation_cost < best_validation_cost:
196
+ best_validation_cost = validation_cost
197
+ self.save_best()
198
+
199
+ if self.verbose:
200
+ if no_validation:
201
+ validation_comment = ' [no validation]'
202
+ else:
203
+ validation_comment = f', validation: {validation_cost:.8f}'
204
+
205
+ print(f'Epoch {epoch}, training cost: {cvxpy_cost:.8f}' +
206
+ validation_comment)
207
+
208
+ for stopping_method in self.stopping_methods:
209
+ if stopping_method.should_stop(
210
+ epoch, train_cost, validation_cost):
211
+ done = True
212
+ break
213
+
214
+ if done:
215
+ break
216
+
217
+ epoch += 1
218
+
219
+ if self.verbose:
220
+ dt = time() - start
221
+ print(f'DCCP done in {epoch} epochs, '
222
+ f'final cost is {validation_cost:.8f} in {dt:.2f}s')
223
+
224
+ self.rollback_to_best()
225
+ self.at_training_end()
@@ -0,0 +1,5 @@
1
+ from .stopping_base import StoppingMethod
2
+ from .stopping_cost import CostStoppingMethod
3
+ from .stopping_early import EarlyStoppingMethod
4
+ from .stopping_epoch import EpochStoppingMethod
5
+ from .stopping_train_stop import TrainStopStoppingMethod
@@ -0,0 +1,20 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class StoppingMethod(ABC):
4
+ """
5
+ Abstract class for a fitting stopping method.
6
+ """
7
+
8
+ @abstractmethod
9
+ def requires_validation(self) -> bool:
10
+ """
11
+ Whether the stopping method requires the training to be split in
12
+ train/validation sets.
13
+ """
14
+
15
+ @abstractmethod
16
+ def should_stop(self, n_epochs: int, train_cost: float,
17
+ validation_cost: float) -> bool:
18
+ """
19
+ Given some information, return whether the fitting process should stop.
20
+ """
@@ -0,0 +1,21 @@
1
+ from . import StoppingMethod
2
+
3
+ class CostStoppingMethod(StoppingMethod):
4
+ """
5
+ Stopping method that triggers whenever the cost gets lower or equal to the
6
+ parameter.
7
+ """
8
+
9
+ def __init__(self, cost_threshold: float):
10
+ if cost_threshold <= 0:
11
+ raise ValueError('invalid done_threshold, expected > 0 but got '
12
+ f'{cost_threshold}')
13
+
14
+ self.cost_threshold = cost_threshold
15
+
16
+ def requires_validation(self) -> bool:
17
+ return False
18
+
19
+ def should_stop(self, n_epochs: int, train_cost: float,
20
+ validation_cost: float) -> bool:
21
+ return validation_cost <= self.cost_threshold
@@ -0,0 +1,35 @@
1
+ from . import StoppingMethod
2
+
3
+ class EarlyStoppingMethod(StoppingMethod):
4
+ """
5
+ Stopping method that triggers whenever the validation cost starts increasing
6
+ while the training cost keeps decreasing, this for a set number of
7
+ consecutive epochs.
8
+ """
9
+
10
+ def __init__(self, delay: int):
11
+ if delay <= 0:
12
+ raise ValueError(f'invalid delay, expected > 0 but got {delay}')
13
+
14
+ self.delay = delay
15
+ self.count = 0
16
+
17
+ self.best_validation_cost: float | None = None
18
+
19
+ def requires_validation(self) -> bool:
20
+ return True
21
+
22
+ def should_stop(self, n_epochs: int, train_cost: float,
23
+ validation_cost: float) -> bool:
24
+ if self.best_validation_cost is None:
25
+ self.best_validation_cost = validation_cost
26
+ else:
27
+ if validation_cost >= self.best_validation_cost:
28
+ self.count += 1
29
+ if self.count >= self.delay:
30
+ return True
31
+ else:
32
+ self.count = 0
33
+ self.best_validation_cost = validation_cost
34
+
35
+ return False
@@ -0,0 +1,21 @@
1
+ from . import StoppingMethod
2
+
3
+ class EpochStoppingMethod(StoppingMethod):
4
+ """
5
+ Stopping method that triggers whenever the number of epochs gets greater
6
+ than the parameter.
7
+ """
8
+
9
+ def __init__(self, max_epochs: int):
10
+ if max_epochs <= 0:
11
+ raise ValueError('invalid max_epochs, expected > 0 but got '
12
+ f'{max_epochs}')
13
+
14
+ self.max_epochs = max_epochs
15
+
16
+ def requires_validation(self) -> bool:
17
+ return False
18
+
19
+ def should_stop(self, n_epochs: int, train_cost: float,
20
+ validation_cost: float) -> bool:
21
+ return n_epochs > self.max_epochs
@@ -0,0 +1,15 @@
1
+ from . import StoppingMethod
2
+
3
+ class TrainStopStoppingMethod(StoppingMethod):
4
+ """
5
+ Stopping method that triggers whenever the training cost gets to 0, that is
6
+ the perceptron found a global minimum and will no longer try to improve,
7
+ rendering validation-related stopping methods unusable.
8
+ """
9
+
10
+ def requires_validation(self) -> bool:
11
+ return False
12
+
13
+ def should_stop(self, n_epochs: int, train_cost: float,
14
+ validation_cost: float) -> bool:
15
+ return train_cost <= 0
@@ -0,0 +1,28 @@
1
+ import numpy as np
2
+
3
+ from sklearn_morpho.classifiers.ldep import LDEP
4
+ from sklearn.datasets import make_moons
5
+
6
+ from sklearn_morpho.stopping import (
7
+ CostStoppingMethod,
8
+ EpochStoppingMethod,
9
+ TrainStopStoppingMethod,
10
+ )
11
+
12
+ def test_moons(runs=10, n_samples=500):
13
+ dep = LDEP(validation_ratio=0, stopping_methods=[
14
+ CostStoppingMethod(1e-6),
15
+ EpochStoppingMethod(20),
16
+ TrainStopStoppingMethod(),
17
+ ])
18
+
19
+ for _ in range(runs):
20
+ X, y = make_moons(n_samples=n_samples)
21
+ dep.fit(X, y)
22
+
23
+ fails = X.shape[0] - np.sum(dep.predict(X) == y)
24
+ assert fails == 0
25
+
26
+ # TODO will most likely fail the test, will get back to this
27
+ # basically sometimes the matrices get really big, but adding their norm to
28
+ # the cost makes things converge slower and epochs very expensive
@@ -0,0 +1,19 @@
1
+ from sklearn.utils.estimator_checks import check_estimator
2
+
3
+ from sklearn_morpho.classifiers.ldep import LDEP
4
+ from sklearn_morpho.stopping import (
5
+ CostStoppingMethod,
6
+ EpochStoppingMethod,
7
+ TrainStopStoppingMethod,
8
+ )
9
+
10
+
11
+ def test_check_estimator():
12
+ ldep = LDEP(validation_ratio=0,
13
+ stopping_methods=[
14
+ CostStoppingMethod(1e-6),
15
+ EpochStoppingMethod(20),
16
+ TrainStopStoppingMethod(),
17
+ ])
18
+
19
+ assert check_estimator(ldep)
@@ -0,0 +1,17 @@
1
+ import numpy as np
2
+
3
+ from sklearn_morpho.classifiers.ldep import LDEP
4
+ from sklearn.datasets import make_classification
5
+
6
+ def test_train_separable_dataset(n_samples=500):
7
+ # use a random state to guarantee a separable dataset
8
+ random_state = np.random.RandomState(11)
9
+ X, y = make_classification(n_samples=n_samples, n_features=2, n_redundant=0,
10
+ n_classes=2, n_clusters_per_class=1,
11
+ random_state=random_state)
12
+
13
+ classifier = LDEP(random_state=random_state)
14
+ classifier.fit(X, y)
15
+
16
+ fails = X.shape[0] - np.sum(classifier.predict(X) == y)
17
+ assert fails == 0
@@ -0,0 +1,3 @@
1
+ from .weighting_base import SampleWeighting
2
+ from .weighting_dist import DistSampleWeighting
3
+ from .weighting_none import NoneSampleWeighting
@@ -0,0 +1,45 @@
1
+ import numpy as np
2
+ from abc import ABC
3
+
4
+ from sklearn.base import OneToOneFeatureMixin, TransformerMixin, BaseEstimator
5
+
6
+ WeightingResult = tuple[np.ndarray, float]
7
+
8
+ class SampleWeighting(ABC, OneToOneFeatureMixin, TransformerMixin,
9
+ BaseEstimator):
10
+ """
11
+ Abstract class for transformers that convert data points to a set of
12
+ respective weights.
13
+ These weights may be used as the cost contribution in a DCCP problem.
14
+
15
+ For example, a sample weighting with functionality `np.full(len(X), 1)`
16
+ corresponds to no weighting at all.
17
+
18
+ The fit() method must create both:
19
+ - self.weights_: np.ndarray
20
+ - self.cost_normalizer_: float
21
+
22
+ If the implementation does not follow these guidelines, the weighting should
23
+ still be able to provide a WeightingResult as the return value of
24
+ transform().
25
+ In this case, you are free to override the transform() and fit_transform()
26
+ methods containing boilerplate code.
27
+ """
28
+
29
+ # for LSPs
30
+ def __init__(self):
31
+ self.weights_: np.ndarray
32
+ self.cost_normalizer_: float
33
+
34
+ def transform(self, X: np.ndarray | None) -> WeightingResult:
35
+ if X is not None:
36
+ raise ValueError(f'{self.__class__.__name__}: only supports '
37
+ 'fitting and transforming the same data, do not '
38
+ 'specify it in transform()')
39
+
40
+ return self.weights_, self.cost_normalizer_
41
+
42
+ # override this to make fit_transform usable but still prevent wrong usage
43
+ def fit_transform(self, X: np.ndarray, y: np.ndarray) -> WeightingResult:
44
+ self.fit(X, y)
45
+ return self.transform(None)
@@ -0,0 +1,33 @@
1
+ import numpy as np
2
+
3
+ from . import SampleWeighting
4
+
5
+ class DistSampleWeighting(SampleWeighting):
6
+ """
7
+ Weighting method that weights its inputs inversely proportionally to the
8
+ distance to their class' centroid.
9
+ """
10
+
11
+ def fit(self, X: np.ndarray, y: np.ndarray) -> DistSampleWeighting:
12
+ # compute centroids, there should be no classes with no elements thanks
13
+ # to sklearn checks if used in the intended way with a compatible
14
+ # estimator
15
+ labels, inv, counts = np.unique(y, return_inverse=True,
16
+ return_counts=True)
17
+ sums = np.zeros((len(labels), X.shape[1]))
18
+ np.add.at(sums, inv, X)
19
+ centroids = sums / counts[:, np.newaxis]
20
+
21
+ # inverse distance from each data point to its respective class centroid
22
+ self.weights_ = 1 / (1e-6 + np.linalg.norm(X - centroids[y], axis=1))
23
+ max_centroid_w = np.array([self.weights_[y == y_].max()
24
+ for y_ in range(2)])
25
+ self.weights_ /= max_centroid_w[y]
26
+
27
+ cost_normalizer = self.weights_.sum()
28
+ if np.isclose(cost_normalizer, 0):
29
+ self.cost_normalizer_ = 1
30
+ else:
31
+ self.cost_normalizer_ = cost_normalizer
32
+
33
+ return self
@@ -0,0 +1,14 @@
1
+ import numpy as np
2
+
3
+ from . import SampleWeighting
4
+
5
+ class NoneSampleWeighting(SampleWeighting):
6
+ """
7
+ Weighting method that does not weight its inputs.
8
+ """
9
+
10
+ def fit(self, X: np.ndarray, y: np.ndarray) -> NoneSampleWeighting:
11
+ self.weights_ = np.ones(len(X))
12
+ self.cost_normalizer_ = 1
13
+
14
+ return self
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: sklearn-morpho
3
+ Version: 0.1.0
4
+ License-File: LICENSE
@@ -0,0 +1,21 @@
1
+ sklearn_morpho/__init__.py,sha256=ecP48uQBEYLb6b2ZOU4M3gd66oO_Nes_ayK6BsQ7XJU,84
2
+ sklearn_morpho/classifiers/ldep.py,sha256=N0FRkQAKvmGcxYbAdzJypn8fCEZs9JuFQCpR8gIs554,7613
3
+ sklearn_morpho/dccp/dccp_ldep.py,sha256=XqbF_kNa_jLy6721IiEUHa5QbwmeIXUACO9lE2I5flY,6986
4
+ sklearn_morpho/dccp/dccp_wrapper.py,sha256=i8GwbEH4q0q3N3yqhY2dcCnL1aeLppXTHy1f7iDWqJE,8814
5
+ sklearn_morpho/stopping/__init__.py,sha256=N4B8zlC7Q0-fJgn5O7h4Uk_CYMTP3yeB9-CaziigfHA,241
6
+ sklearn_morpho/stopping/stopping_base.py,sha256=INPU9XvHEnnOqZ4CMSVm5-xlw7UdPQyGsFoEG6_Vzdo,560
7
+ sklearn_morpho/stopping/stopping_cost.py,sha256=pSrp2NAF0d3H_JoMm3BcIFVszB3fqg_rlOZVuXIg24M,668
8
+ sklearn_morpho/stopping/stopping_early.py,sha256=yUE9EaNRFtmkLbJQIvd5F5Sy0PLka1wbuwD_9IaLOtM,1104
9
+ sklearn_morpho/stopping/stopping_epoch.py,sha256=dkJ7nW60KzfQotQwwnHS9Fvg2_EK-l6AdHgoEFs2mW0,638
10
+ sklearn_morpho/stopping/stopping_train_stop.py,sha256=ElPYiRUjoDTvwTqUszo2RrG9N8fOcZwq7YzHWJ9udoo,520
11
+ sklearn_morpho/tests/moons_test.py,sha256=xxKO3vNjTIcEPTmyS6HB3r_DiD82uVdQ8szfrVl5Pig,840
12
+ sklearn_morpho/tests/sklearn_test.py,sha256=pISK-F6wjVCIzeAfeR7J0ObEozi0lTRAkCpaelotEGY,531
13
+ sklearn_morpho/tests/train_test.py,sha256=GgZRo82wD-1ztmsWt_WwO4YAJSlx70T0_-F1sGemZoc,636
14
+ sklearn_morpho/weighting/__init__.py,sha256=AzWBDUf33vC6hnNHfC6TftpqFODTsbeV-D_TW4Ao5Tc,140
15
+ sklearn_morpho/weighting/weighting_base.py,sha256=928tUWDI3-bj3HahvDuOf_XtjeLk21MQ-ymNTcEc37g,1654
16
+ sklearn_morpho/weighting/weighting_dist.py,sha256=PIwEU7gmwjWz3eRkzbDgqiG5zg894CChMBGRc5AHZUI,1268
17
+ sklearn_morpho/weighting/weighting_none.py,sha256=1AgORveFg8EHtSr-GqdI1GjhWadzwLgryyIo0o0OE7U,333
18
+ sklearn_morpho-0.1.0.dist-info/METADATA,sha256=p8hzjB7yxlN6Tu2P1EuIfrcCM6sdIbbBJ70HSAfWa7w,80
19
+ sklearn_morpho-0.1.0.dist-info/WHEEL,sha256=e22IIVjxDyt0lABi4WpktFIGsmO_ebSDXLnPUbPK0E0,105
20
+ sklearn_morpho-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
21
+ sklearn_morpho-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.