congrads 0.1.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.
congrads/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ # __init__.py
2
+
3
+ # Only expose the submodules, not individual classes
4
+ from . import core
5
+ from . import constraints
6
+ from . import datasets
7
+ from . import descriptor
8
+ from . import learners
9
+ from . import metrics
10
+ from . import networks
11
+
12
+ # Define __all__ to specify that the submodules are accessible, but not classes directly.
13
+ __all__ = [
14
+ "core",
15
+ "constraints",
16
+ "datasets",
17
+ "descriptor",
18
+ "learners",
19
+ "metrics",
20
+ "networks"
21
+ ]
@@ -0,0 +1,507 @@
1
+ from abc import ABC, abstractmethod
2
+ from numbers import Number
3
+ import random
4
+ import string
5
+ from typing import Callable, Dict
6
+ from torch import Tensor, ge, gt, lt, le, zeros, FloatTensor, ones, tensor, float32
7
+ import logging
8
+ from torch.nn.functional import normalize
9
+
10
+ from .descriptor import Descriptor
11
+
12
+
13
+ class Constraint(ABC):
14
+ """
15
+ Abstract base class for defining constraints that can be applied during optimization in the constraint-guided gradient descent process.
16
+
17
+ A constraint guides the optimization by evaluating the model's predictions and adjusting the loss based on certain conditions.
18
+ Constraints can be applied to specific layers or neurons of the model, and they are scaled by a rescale factor to control the influence of the constraint on the overall loss.
19
+
20
+ Attributes:
21
+ descriptor (Descriptor): The descriptor object that provides a mapping of neurons to layers.
22
+ constraint_name (str): A unique name for the constraint, which can be provided or generated automatically.
23
+ rescale_factor (float): A factor used to scale the influence of the constraint on the overall loss.
24
+ neuron_names (set[str]): A set of neuron names that are involved in the constraint.
25
+ layers (set): A set of layers associated with the neurons specified in `neuron_names`.
26
+ """
27
+
28
+ descriptor: Descriptor = None
29
+
30
+ def __init__(
31
+ self,
32
+ neuron_names: set[str],
33
+ constraint_name: str = None,
34
+ rescale_factor: float = 1.5,
35
+ ) -> None:
36
+ """
37
+ Initializes the Constraint object with the given neuron names, constraint name, and rescale factor.
38
+
39
+ Args:
40
+ neuron_names (set[str]): A set of neuron names that are affected by the constraint.
41
+ constraint_name (str, optional): A custom name for the constraint. If not provided, a random name is generated.
42
+ rescale_factor (float, optional): A factor that scales the influence of the constraint. Defaults to 1.5.
43
+
44
+ Raises:
45
+ ValueError: If the descriptor has not been set or if a neuron name is not found in the descriptor.
46
+ """
47
+
48
+ # Init parent class
49
+ super().__init__()
50
+
51
+ # Init object variables
52
+ self.rescale_factor = rescale_factor
53
+ self.neuron_names = neuron_names
54
+
55
+ # Perform checks
56
+ if rescale_factor <= 1:
57
+ logging.warning(
58
+ f"Rescale factor for constraint {constraint_name} is <= 1. The network will favour general loss over the constraint-adjusted loss. Is this intended behaviour? Normally, the loss should always be larger than 1."
59
+ )
60
+
61
+ # If no constraint_name is set, generate one based on the class name and a random suffix
62
+ if constraint_name:
63
+ self.constraint_name = constraint_name
64
+ else:
65
+ random_suffix = "".join(
66
+ random.choices(string.ascii_uppercase + string.digits, k=6)
67
+ )
68
+ self.constraint_name = f"{self.__class__.__name__}_{random_suffix}"
69
+ logging.warning(
70
+ f"Name for constraint is not set. Using {self.constraint_name}."
71
+ )
72
+
73
+ if self.descriptor == None:
74
+ raise ValueError(
75
+ "The descriptor of the base Constraint class in not set. Please assign the descriptor to the general Constraint class with 'Constraint.descriptor = descriptor' before defining network-specific contraints."
76
+ )
77
+
78
+ if not rescale_factor > 1:
79
+ self.rescale_factor = abs(rescale_factor) + 1.5
80
+ logging.warning(
81
+ f"Rescale factor for constraint {constraint_name} is < 1, adjusted value {rescale_factor} to {self.rescale_factor}."
82
+ )
83
+ else:
84
+ self.rescale_factor = rescale_factor
85
+
86
+ self.neuron_names = neuron_names
87
+
88
+ self.run_init_descriptor()
89
+
90
+ def run_init_descriptor(self) -> None:
91
+ """
92
+ Initializes the layers associated with the constraint by mapping the neuron names to their corresponding layers
93
+ from the descriptor.
94
+
95
+ This method populates the `layers` attribute with layers associated with the neuron names provided in the constraint.
96
+
97
+ Raises:
98
+ ValueError: If a neuron name is not found in the descriptor's mapping of neurons to layers.
99
+ """
100
+
101
+ self.layers = set()
102
+ for neuron_name in self.neuron_names:
103
+ if neuron_name in self.descriptor.neuron_to_layer.keys():
104
+ self.layers.add(self.descriptor.neuron_to_layer[neuron_name])
105
+ else:
106
+ raise ValueError(
107
+ f'The neuron name {neuron_name} used with constraint {self.constraint_name} is not defined in the descriptor. Please add it to the correct layer using descriptor.add("layer", ...).'
108
+ )
109
+
110
+ @abstractmethod
111
+ def check_constraint(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
112
+ """
113
+ Abstract method to check if the constraint is satisfied based on the model's predictions.
114
+
115
+ This method should be implemented in subclasses to define the specific logic for evaluating the constraint based on the model's predictions.
116
+
117
+ Args:
118
+ prediction (dict[str, Tensor]): A dictionary of model predictions, indexed by layer names.
119
+
120
+ Returns:
121
+ dict[str, Tensor]: A dictionary containing the satisfaction status of the constraint for each layer or neuron.
122
+
123
+ Raises:
124
+ NotImplementedError: If the method is not implemented in a subclass.
125
+ """
126
+
127
+ raise NotImplementedError
128
+
129
+ @abstractmethod
130
+ def calculate_direction(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
131
+ """
132
+ Abstract method to calculate the direction in which the model's predictions need to be adjusted to satisfy the constraint.
133
+
134
+ This method should be implemented in subclasses to define how to adjust the model's predictions based on the constraint.
135
+
136
+ Args:
137
+ prediction (dict[str, Tensor]): A dictionary of model predictions, indexed by layer names.
138
+
139
+ Returns:
140
+ dict[str, Tensor]: A dictionary containing the direction for each layer or neuron, to adjust the model's predictions.
141
+
142
+ Raises:
143
+ NotImplementedError: If the method is not implemented in a subclass.
144
+ """
145
+ raise NotImplementedError
146
+
147
+
148
+ class ScalarConstraint(Constraint):
149
+ """
150
+ A subclass of the `Constraint` class that applies a scalar constraint on a specific neuron in the model.
151
+
152
+ This constraint compares the value of a specific neuron in the model to a scalar value using a specified comparator (e.g., greater than, less than).
153
+ If the constraint is violated, it adjusts the loss according to the direction defined by the comparator.
154
+
155
+ Attributes:
156
+ comparator (Callable[[Tensor, Number], Tensor]): A comparator function (e.g., greater than, less than) to evaluate the constraint.
157
+ scalar (Number): The scalar value to compare the neuron value against.
158
+ direction (int): The direction in which the constraint should adjust the model's predictions (either 1 or -1 based on the comparator).
159
+ layer (str): The layer associated with the specified neuron.
160
+ index (int): The index of the specified neuron within the layer.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ neuron_name: str,
166
+ comparator: Callable[[Tensor, Number], Tensor],
167
+ scalar: Number,
168
+ name: str = None,
169
+ descriptor: Descriptor = None,
170
+ rescale_factor: float = 1.5,
171
+ ) -> None:
172
+ """
173
+ Initializes the ScalarConstraint with the given neuron name, comparator, scalar value, and other optional parameters.
174
+
175
+ Args:
176
+ neuron_name (str): The name of the neuron that the constraint applies to.
177
+ comparator (Callable[[Tensor, Number], Tensor]): The comparator function used to evaluate the constraint (e.g., ge, le, gt, lt).
178
+ scalar (Number): The scalar value that the neuron value is compared to.
179
+ name (str, optional): A custom name for the constraint. If not provided, a name is generated based on the neuron name, comparator, and scalar.
180
+ descriptor (Descriptor, optional): The descriptor that maps neurons to layers. If not provided, the global descriptor is used.
181
+ rescale_factor (float, optional): A factor that scales the influence of the constraint on the overall loss. Defaults to 1.5.
182
+
183
+ Raises:
184
+ ValueError: If the comparator function is not one of the supported comparison operators (ge, le, gt, lt).
185
+ """
186
+
187
+ # Compose constraint name
188
+ name = f"{neuron_name}_{comparator.__name__}_{str(scalar)}"
189
+
190
+ # Init parent class
191
+ super().__init__({neuron_name}, name, rescale_factor)
192
+
193
+ # Init variables
194
+ self.comparator = comparator
195
+ self.scalar = scalar
196
+
197
+ if descriptor != None:
198
+ self.descriptor = descriptor
199
+ self.run_init_descriptor()
200
+
201
+ # Get layer name and feature index from neuron_name
202
+ self.layer = self.descriptor.neuron_to_layer[neuron_name]
203
+ self.index = self.descriptor.neuron_to_index[neuron_name]
204
+
205
+ # If comparator function is not supported, raise error
206
+ if comparator not in [ge, le, gt, lt]:
207
+ raise ValueError(
208
+ f"Comparator {str(comparator)} used for constraint {name} is not supported. Only ge, le, gt, lt are allowed."
209
+ )
210
+
211
+ # Calculate directions based on constraint operator
212
+ if self.comparator in [lt, le]:
213
+ self.direction = 1
214
+ elif self.comparator in [gt, ge]:
215
+ self.direction = -1
216
+
217
+ def check_constraint(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
218
+ """
219
+ Checks if the constraint is satisfied based on the model's predictions.
220
+
221
+ The constraint is evaluated by applying the comparator to the value of the specified neuron and the scalar value.
222
+
223
+ Args:
224
+ prediction (dict[str, Tensor]): A dictionary of model predictions, indexed by layer names.
225
+
226
+ Returns:
227
+ dict[str, Tensor]: A dictionary containing the constraint satisfaction result for the specified layer.
228
+ """
229
+
230
+ result = ~self.comparator(prediction[self.layer][:, self.index], self.scalar)
231
+
232
+ return {self.layer: result}
233
+
234
+ def calculate_direction(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
235
+ """
236
+ Calculates the direction in which the model's predictions need to be adjusted to satisfy the constraint.
237
+
238
+ The direction is determined by the comparator and represents either a positive or negative adjustment.
239
+
240
+ Args:
241
+ prediction (dict[str, Tensor]): A dictionary of model predictions, indexed by layer names.
242
+
243
+ Returns:
244
+ dict[str, Tensor]: A dictionary containing the direction for each layer or neuron, to adjust the model's predictions.
245
+ """
246
+
247
+ output = zeros(
248
+ prediction[self.layer].size(),
249
+ device=prediction[self.layer].device,
250
+ )
251
+ output[:, self.index] = self.direction
252
+
253
+ return {self.layer: output}
254
+
255
+
256
+ class BinaryConstraint(Constraint):
257
+ """
258
+ A class representing a binary constraint between two neurons in a neural network.
259
+
260
+ This class checks and enforces a constraint between two neurons using a
261
+ comparator function. The constraint is applied between two neurons located
262
+ in different layers of the neural network. The class also calculates the
263
+ direction for gradient adjustment based on the comparator.
264
+
265
+ Attributes:
266
+ neuron_name_left (str): The name of the first neuron involved in the constraint.
267
+ neuron_name_right (str): The name of the second neuron involved in the constraint.
268
+ comparator (Callable[[Tensor, Number], Tensor]): A function that compares the values of the two neurons.
269
+ layer_left (str): The layer name for the first neuron.
270
+ layer_right (str): The layer name for the second neuron.
271
+ index_left (int): The index of the first neuron within its layer.
272
+ index_right (int): The index of the second neuron within its layer.
273
+ direction_left (float): The normalized direction for gradient adjustment of the first neuron.
274
+ direction_right (float): The normalized direction for gradient adjustment of the second neuron.
275
+ """
276
+
277
+ def __init__(
278
+ self,
279
+ neuron_name_left: str,
280
+ comparator: Callable[[Tensor, Number], Tensor],
281
+ neuron_name_right: str,
282
+ name: str = None,
283
+ descriptor: Descriptor = None,
284
+ rescale_factor: float = 1.5,
285
+ ) -> None:
286
+ """
287
+ Initializes the binary constraint with two neurons, a comparator, and other configuration options.
288
+
289
+ Args:
290
+ neuron_name_left (str): The name of the first neuron in the constraint.
291
+ comparator (Callable[[Tensor, Number], Tensor]): A function that compares the values of the two neurons.
292
+ neuron_name_right (str): The name of the second neuron in the constraint.
293
+ name (str, optional): The name of the constraint. If not provided, a default name is generated.
294
+ descriptor (Descriptor, optional): The descriptor containing the mapping of neurons to layers.
295
+ rescale_factor (float, optional): A factor to rescale the constraint value. Default is 1.5.
296
+ """
297
+
298
+ # Compose constraint name
299
+ name = f"{neuron_name_left}_{comparator.__name__}_{neuron_name_right}"
300
+
301
+ # Init parent class
302
+ super().__init__({neuron_name_left, neuron_name_right}, name, rescale_factor)
303
+
304
+ # Init variables
305
+ self.comparator = comparator
306
+
307
+ if descriptor != None:
308
+ self.descriptor = descriptor
309
+ self.run_init_descriptor()
310
+
311
+ # Get layer name and feature index from neuron_name
312
+ self.layer_left = self.descriptor.neuron_to_layer[neuron_name_left]
313
+ self.layer_right = self.descriptor.neuron_to_layer[neuron_name_right]
314
+ self.index_left = self.descriptor.neuron_to_index[neuron_name_left]
315
+ self.index_right = self.descriptor.neuron_to_index[neuron_name_right]
316
+
317
+ # If comparator function is not supported, raise error
318
+ if comparator not in [ge, le, gt, lt]:
319
+ raise RuntimeError(
320
+ f"Comparator {str(comparator)} used for constraint {name} is not supported. Only ge, le, gt, lt are allowed."
321
+ )
322
+
323
+ # Calculate directions based on constraint operator
324
+ if self.comparator in [lt, le]:
325
+ self.direction_left = -1
326
+ self.direction_right = 1
327
+ else:
328
+ self.direction_left = 1
329
+ self.direction_right = -1
330
+
331
+ # Normalize directions
332
+ normalized_directions = normalize(
333
+ tensor([self.direction_left, self.direction_right]).type(float32),
334
+ p=2,
335
+ dim=0,
336
+ )
337
+ self.direction_left = normalized_directions[0]
338
+ self.direction_right = normalized_directions[1]
339
+
340
+ def check_constraint(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
341
+ """
342
+ Checks whether the binary constraint is satisfied between the two neurons.
343
+
344
+ This function applies the comparator to the output values of the two neurons
345
+ and returns a Boolean result for each neuron.
346
+
347
+ Args:
348
+ prediction (dict[str, Tensor]): A dictionary containing the predictions for each layer.
349
+
350
+ Returns:
351
+ dict[str, Tensor]: A dictionary with the layer names as keys and the constraint satisfaction results as values.
352
+ """
353
+
354
+ result = ~self.comparator(
355
+ prediction[self.layer_left][:, self.index_left],
356
+ prediction[self.layer_right][:, self.index_right],
357
+ )
358
+
359
+ return {self.layer_left: result, self.layer_right: result}
360
+
361
+ def calculate_direction(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
362
+ """
363
+ Calculates the direction for gradient adjustment for both neurons involved in the constraint.
364
+
365
+ The directions are normalized and represent the direction in which the constraint should be enforced.
366
+
367
+ Args:
368
+ prediction (dict[str, Tensor]): A dictionary containing the predictions for each layer.
369
+
370
+ Returns:
371
+ dict[str, Tensor]: A dictionary with the layer names as keys and the direction vectors as values.
372
+ """
373
+
374
+ output_left = zeros(
375
+ prediction[self.layer_left].size(),
376
+ device=prediction[self.layer_left].device,
377
+ )
378
+ output_left[:, self.index_left] = self.direction_left
379
+
380
+ output_right = zeros(
381
+ prediction[self.layer_right].size(),
382
+ device=prediction[self.layer_right].device,
383
+ )
384
+ output_right[:, self.index_right] = self.direction_right
385
+
386
+ return {self.layer_left: output_left, self.layer_right: output_right}
387
+
388
+
389
+ # FIXME
390
+ class SumConstraint(Constraint):
391
+ def __init__(
392
+ self,
393
+ neuron_names_left: list[str],
394
+ comparator: Callable[[Tensor, Number], Tensor],
395
+ neuron_names_right: list[str],
396
+ weights_left: list[float] = None,
397
+ weights_right: list[float] = None,
398
+ name: str = None,
399
+ descriptor: Descriptor = None,
400
+ rescale_factor: float = 1.5,
401
+ ) -> None:
402
+
403
+ # Init parent class
404
+ super().__init__(
405
+ set(neuron_names_left) & set(neuron_names_right), name, rescale_factor
406
+ )
407
+
408
+ # Init variables
409
+ self.comparator = comparator
410
+
411
+ if descriptor != None:
412
+ self.descriptor = descriptor
413
+ self.run_init_descriptor()
414
+
415
+ # Get layer names and feature indices from neuron_name
416
+ self.layers_left = []
417
+ self.indices_left = []
418
+ for neuron_name in neuron_names_left:
419
+ self.layers_left.append(self.descriptor.neuron_to_layer[neuron_name])
420
+ self.indices_left.append(self.descriptor.neuron_to_index[neuron_name])
421
+
422
+ self.layers_right = []
423
+ self.indices_right = []
424
+ for neuron_name in neuron_names_right:
425
+ self.layers_right.append(self.descriptor.neuron_to_layer[neuron_name])
426
+ self.indices_right.append(self.descriptor.neuron_to_index[neuron_name])
427
+
428
+ # If comparator function is not supported, raise error
429
+ if comparator not in [ge, le, gt, lt]:
430
+ raise ValueError(
431
+ f"Comparator {str(comparator)} used for constraint {name} is not supported. Only ge, le, gt, lt are allowed."
432
+ )
433
+
434
+ # If feature list dimensions don't match weight list dimensions, raise error
435
+ if weights_left and (len(neuron_names_left) != len(weights_left)):
436
+ raise ValueError(
437
+ "The dimensions of neuron_names_left don't match with the dimensions of weights_left."
438
+ )
439
+ if weights_right and (len(neuron_names_right) != len(weights_right)):
440
+ raise ValueError(
441
+ "The dimensions of neuron_names_right don't match with the dimensions of weights_right."
442
+ )
443
+
444
+ # If weights are provided for summation, transform them to Tensors
445
+ if weights_left:
446
+ self.weights_left = FloatTensor(weights_left)
447
+ else:
448
+ self.weights_left = ones(len(neuron_names_left))
449
+ if weights_right:
450
+ self.weights_right = FloatTensor(weights_right)
451
+ else:
452
+ self.weights_right = ones(len(neuron_names_right))
453
+
454
+ # Calculate directions based on constraint operator
455
+ if self.comparator in [lt, le]:
456
+ self.direction_left = -1
457
+ self.direction_right = 1
458
+ else:
459
+ self.direction_left = 1
460
+ self.direction_right = -1
461
+
462
+ # Normalize directions
463
+ normalized_directions = normalize(
464
+ tensor(self.direction_left, self.direction_right), p=2, dim=0
465
+ )
466
+ self.direction_left = normalized_directions[0]
467
+ self.direction_right = normalized_directions[1]
468
+
469
+ def check_constraint(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
470
+ raise NotImplementedError
471
+ # # TODO remove the dynamic to device conversion and do this in initialization one way or another
472
+ # weighted_sum_left = (
473
+ # prediction[layer_left][:, index_left]
474
+ # * self.weights_left.to(prediction[layer_left].device)
475
+ # ).sum(dim=1)
476
+ # weighted_sum_right = (
477
+ # prediction[layer_right][:, index_right]
478
+ # * self.weights_right.to(prediction[layer_right].device)
479
+ # ).sum(dim=1)
480
+
481
+ # result = ~self.comparator(weighted_sum_left, weighted_sum_right)
482
+
483
+ # return {layer_left: result, layer_right: result}
484
+ pass
485
+
486
+ def calculate_direction(self, prediction: dict[str, Tensor]) -> Dict[str, Tensor]:
487
+ raise NotImplementedError
488
+ # # TODO move this to constructor somehow
489
+ # layer_left = prediction.neuron_to_layer[self.neuron_name_left]
490
+ # layer_right = prediction.neuron_to_layer[self.neuron_name_right]
491
+ # index_left = prediction.neuron_to_index[self.neuron_name_left]
492
+ # index_right = prediction.neuron_to_index[self.neuron_name_right]
493
+
494
+ # output_left = zeros(
495
+ # prediction[layer_left].size(),
496
+ # device=prediction[layer_left].device,
497
+ # )
498
+ # output_left[:, index_left] = self.direction_left
499
+
500
+ # output_right = zeros(
501
+ # prediction.layer_to_data[layer_right].size(),
502
+ # device=prediction.layer_to_data[layer_right].device,
503
+ # )
504
+ # output_right[:, index_right] = self.direction_right
505
+
506
+ # return {layer_left: output_left, layer_right: output_right}
507
+ pass