humancompatible-train 0.3.0__tar.gz → 0.3.0.1__tar.gz

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.
Files changed (46) hide show
  1. {humancompatible_train-0.3.0/src/humancompatible_train.egg-info → humancompatible_train-0.3.0.1}/PKG-INFO +1 -3
  2. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/README.md +0 -2
  3. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/pyproject.toml +1 -1
  4. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/dual_optim/pbm.py +126 -88
  5. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/PBM.py +1 -1
  6. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1/src/humancompatible_train.egg-info}/PKG-INFO +1 -3
  7. humancompatible_train-0.3.0.1/tests/test_pbm.py +532 -0
  8. humancompatible_train-0.3.0/tests/test_pbm.py +0 -94
  9. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/LICENCE.txt +0 -0
  10. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/setup.cfg +0 -0
  11. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/__init__.py +0 -0
  12. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/__init__.py +0 -0
  13. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/__init__.py +0 -0
  14. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/Algorithm.py +0 -0
  15. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/__init__.py +0 -0
  16. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/ghost.py +0 -0
  17. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/optim_wrapper.py +0 -0
  18. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/sgd.py +0 -0
  19. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/ssl_alm.py +0 -0
  20. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/switching_subgradient.py +0 -0
  21. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/algorithms/utils.py +0 -0
  22. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/constraints/__init__.py +0 -0
  23. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/constraints/constraint.py +0 -0
  24. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/constraints/constraint_fns.py +0 -0
  25. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/constraints/torch/__init__.py +0 -0
  26. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/benchmark/constraints/torch/constraints.py +0 -0
  27. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/dual_optim/__init__.py +0 -0
  28. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/dual_optim/alm.py +0 -0
  29. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/dual_optim/barrier.py +0 -0
  30. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/dual_optim/moreau.py +0 -0
  31. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/fairness/__init__.py +0 -0
  32. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/fairness/utils/__init__.py +0 -0
  33. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/fairness/utils/balanced_batch_sampler.py +0 -0
  34. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/__init__.py +0 -0
  35. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/barrier.py +0 -0
  36. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/ssl_alm.py +0 -0
  37. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/ssl_alm_adam.py +0 -0
  38. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/ssl_alm_adam_moment.py +0 -0
  39. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/ssw.py +0 -0
  40. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible/train/optim/ssw_barrier.py +0 -0
  41. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible_train.egg-info/SOURCES.txt +0 -0
  42. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible_train.egg-info/dependency_links.txt +0 -0
  43. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible_train.egg-info/requires.txt +0 -0
  44. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/src/humancompatible_train.egg-info/top_level.txt +0 -0
  45. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/tests/test_alm.py +0 -0
  46. {humancompatible_train-0.3.0 → humancompatible_train-0.3.0.1}/tests/test_balanced_batch_sampler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humancompatible-train
3
- Version: 0.3.0
3
+ Version: 0.3.0.1
4
4
  Summary: PyTorch-based package for constrained training of neural networks
5
5
  Author: Gilles Bareilles, Adam Bosak, Jana Lepsova, Jakub Marecek
6
6
  Author-email: Andrii Kliachkin <kliacand@fel.cvut.cz>
@@ -156,8 +156,6 @@ The constraint options are:
156
156
  - `equalized_odds_vec`: constraint on the Positive Rate of each group as defined by `fairret.NormLoss`;
157
157
  - `weight_norm`: constraint on the Frobenius norm of the weights and biases of each layer of the neural network.
158
158
 
159
- The above command will run the benchmark and save the logs and models in the `benchmark/results/[dataset]_[task]` directory; it will also plot the trajectories into a .png file saved in the same location.
160
-
161
159
  The benchmarking code (all of which is contained in the `benchmark` directory) is easy to parse and extend with other datasets and constraints.
162
160
 
163
161
 
@@ -124,8 +124,6 @@ The constraint options are:
124
124
  - `equalized_odds_vec`: constraint on the Positive Rate of each group as defined by `fairret.NormLoss`;
125
125
  - `weight_norm`: constraint on the Frobenius norm of the weights and biases of each layer of the neural network.
126
126
 
127
- The above command will run the benchmark and save the logs and models in the `benchmark/results/[dataset]_[task]` directory; it will also plot the trajectories into a .png file saved in the same location.
128
-
129
127
  The benchmarking code (all of which is contained in the `benchmark` directory) is easy to parse and extend with other datasets and constraints.
130
128
 
131
129
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "humancompatible-train"
7
- version = "0.3.0"
7
+ version = "0.3.0.1"
8
8
  dependencies = [
9
9
  "torch",
10
10
  "numpy",
@@ -11,34 +11,35 @@ from .barrier import (
11
11
  quad_recipr_der
12
12
  )
13
13
 
14
-
15
-
16
-
17
14
  class PBM(Optimizer):
18
15
  def __init__(
19
16
  self,
20
17
  m: int = None,
21
- mu: float = 0.3,
22
- lr: float = None,
23
- penalty_update: str = 'dimin',
24
- pbf: str = 'quadratic_logarithmic',
25
- init_duals: float | Tensor = 0.01,
26
- init_penalties: float | Tensor = 10,
18
+ penalty_mult: float = 0.1,
19
+ gamma: float = 0.9,
20
+ delta: float = 0.9,
21
+ penalty_update: str = 'dimin_adapt',
27
22
  *,
28
- dual_range: Tuple[float, float] = (1e-4, 100.),
29
- momentum: float = 0.,
30
- dampening: float = 0.,
31
- penalty_range: Tuple[float, float] = (1e-1, 100.),
32
- device = None
23
+ pbf: str = 'quadratic_logarithmic',
24
+ init_duals: float | Tensor = None,
25
+ init_penalties: float | Tensor = None,
26
+ dual_range: Tuple[float, float] = (0.0001, 100.),
27
+ penalty_range: Tuple[float, float] = (0.1, 100.),
28
+ device = None,
29
+ primal_update_process_length=1, # length of the primal update process - if =1, is the original algorithm
33
30
  ) -> None:
34
31
  """
35
32
  A wrapper over a PyTorch`Optimizer` that works on the dual maximization tasks according to the Penalty-Barrier Method rule. Creates and updates dual variables.
36
33
 
37
34
  :param m: Number of constraints (determines the number of dual variables to create)
38
35
  :type m: int
39
- :param mu: Multiplier for penalty update.
40
- :type mu: float
41
- :param penalty_update: Penalty update strategy; must be one of `dimin`,`dimin_dual`,`const`. Defaults to`dimin`.
36
+ :param penalty_mult: Multiplier for penalty update (K1 or K2). For K2 (adaptive penalty update), values close to 1 correspond to a high "momentum".
37
+ :type penalty_mult: float
38
+ :param gamma: Multiplier for dual parameter update. Values close to 1 correspond to a high "momentum".
39
+ :type gamma: float
40
+ :param delta: Violation/satisfaction parameter for penalty update; values > 1 make the penalties decrease faster on violated constraints and vice versa.
41
+ :type delta: float
42
+ :param penalty_update: Penalty update strategy; must be one of `dimin`,`dimin_dual`,`dimin_adapt`,`const`. Defaults to`dimin_adapt`.
42
43
  :type penalty_update: str
43
44
  :param pbf: Penalty-Barrier Function to use. Must be one of `quadratic_logarithmic`,`quadratic_reciprocal`
44
45
  :type pbf: str
@@ -48,40 +49,28 @@ class PBM(Optimizer):
48
49
  :type init_penalties: float | Tensor
49
50
  :param dual_range: Safeguarding range for dual variables; they will be`clamp`-ed to this range.
50
51
  :type dual_range: Tuple[float, float]
51
- :param momentum: Momentum/Smoothing factor for dual variables. Set to `0` to disable.
52
- :type momentum: float
53
- :param dampening: Dampening for momentum. Defaults to 1 - momentum if momentum is not 0 and 0 otherwise. Set to `0` to disable.
54
- :type dampening: float
55
52
  """
56
53
 
57
- # ## checks ##
58
-
59
54
  self.dual_range = dual_range
60
55
  self.penalty_range = penalty_range
61
56
 
62
- if lr is None:
63
- lr = mu
64
-
65
- if momentum > 0 and dampening == 0:
66
- dampening = momentum
67
-
68
- params, defaults = self._init_constraint_group(m, mu, lr, penalty_update, pbf, init_duals, init_penalties, momentum, dampening, dual_range, penalty_range, device)
69
-
57
+ params, defaults = self._init_constraint_group(m, penalty_mult, penalty_update, delta, pbf, init_duals, init_penalties, gamma, dual_range, penalty_range, primal_update_process_length, device)
58
+ self.iter = 0
70
59
  super().__init__(params, defaults)
71
60
 
72
61
  @staticmethod
73
62
  def _init_constraint_group(
74
63
  m: int,
75
- mu: float = None,
76
- lr: float = None,
64
+ p_mult: float = None,
77
65
  penalty_update: str = None,
66
+ delta: float = None,
78
67
  pbf: str = None,
79
68
  init_duals: float | Tensor = None,
80
69
  init_penalties: float | Tensor = None,
81
- momentum: float = None,
82
- dampening: float = None,
70
+ dual_momentum: float = None,
83
71
  dual_range: Tuple[float, float] = None,
84
72
  penalty_range: Tuple[float, float] = None,
73
+ primal_update_process_length: int = 1,
85
74
  device = None
86
75
  ):
87
76
  if init_duals is None and m is None:
@@ -95,23 +84,29 @@ class PBM(Optimizer):
95
84
  duals = Parameter(init_duals, requires_grad=False)
96
85
  penalties = Parameter(init_penalties, requires_grad=False)
97
86
 
87
+ primal_update_process_length = primal_update_process_length
88
+
98
89
  if penalty_update == 'dimin':
99
90
  penalty_update_f = _update_penalties_dimin
100
91
  elif penalty_update == 'dimin_dual':
101
92
  penalty_update_f = _update_penalties_dimin_dual
93
+ elif penalty_update == 'dimin_adapt':
94
+ penalty_update_f = _update_penalties_adapt
102
95
  elif penalty_update == 'const':
103
96
  penalty_update_f = _update_penalties_const
104
- else:
97
+ elif penalty_update is None:
105
98
  penalty_update_f = None
99
+ else:
100
+ raise ValueError(f'Unknown penalty update function: {penalty_update}!')
106
101
 
107
102
  settings_dict = {
108
- "mu": mu,
109
- "lr": lr,
103
+ "p_mult": p_mult,
110
104
  "penalty_update": penalty_update_f,
105
+ "delta": delta,
111
106
  "pbf": pbf,
112
- "momentum": momentum,
113
- "dampening": dampening,
114
- "momentum_buffer": torch.zeros_like(init_duals, requires_grad = False, device=device),
107
+ "dual_momentum": dual_momentum,
108
+ "primal_update_process_length": primal_update_process_length,
109
+ "dual_momentum_buffer": torch.zeros_like(init_duals, requires_grad = False, device=device),
115
110
  }
116
111
  settings_dict = {k:v for k,v in settings_dict.items() if v is not None}
117
112
 
@@ -122,6 +117,8 @@ class PBM(Optimizer):
122
117
  @property
123
118
  def duals(self) -> Tensor:
124
119
  """
120
+ Returns all dual variables concatenated from all constraint groups.
121
+
125
122
  :return: Dual variables, concatenated into a single tensor.
126
123
  :rtype: Tensor
127
124
  """
@@ -130,6 +127,8 @@ class PBM(Optimizer):
130
127
  @property
131
128
  def penalties(self) -> Tensor:
132
129
  """
130
+ Returns all penalty variables concatenated from all constraint groups.
131
+
133
132
  :return: Penalties, concatenated into a single tensor.
134
133
  :rtype: Tensor
135
134
  """
@@ -138,35 +137,41 @@ class PBM(Optimizer):
138
137
  def add_constraint_group(
139
138
  self,
140
139
  m: int,
141
- mu: float = None,
142
- lr: float = None,
140
+ penalty_mult: float = None,
143
141
  penalty_update: str = None,
142
+ delta: float = None,
144
143
  pbf: str = None,
145
144
  init_duals: float | Tensor = None,
146
145
  init_penalties: float | Tensor = None,
147
146
  *,
148
147
  momentum: float = None,
149
- dampening: float = None,
148
+ primal_update_process_length: int = 1
150
149
  ) -> None:
151
150
  """
152
- Allows to add a group of dual variables with separate initial values and learning rates.
151
+ Adds an additional group of dual variables with separate hyperparameters and barrier functions.
153
152
 
154
- :param m: Size of group (number of dual variables to add)
153
+ :param m: Number of constraints in this group (determines the number of dual variables to add)
155
154
  :type m: int
156
- :param mu: Multiplier for penalty update.
157
- :type mu: float
158
- :param penalty_update: Penalty update strategy; must be one of `dimin`,`dimin_dual`,`const`. Defaults to`dimin`.
155
+ :param penalty_mult: Multiplier for penalty update (K1 or K2). If None, inherits from parent. For adaptive penalty update, values close to 1 correspond to high "momentum".
156
+ :type penalty_mult: float
157
+ :param penalty_update: Penalty update strategy; must be one of `dimin`, `dimin_dual`, `dimin_adapt`, `const`. If None, defaults to `dimin`.
159
158
  :type penalty_update: str
160
- :param pbf: Penalty-Barrier Function to use. Can be one of `"quadratic_logarithmic", "quadratic_reciprocal"`
159
+ :param delta: Violation/satisfaction parameter for penalty update. If None, inherits from parent.
160
+ :type delta: float
161
+ :param pbf: Penalty-Barrier Function to use. Must be one of `quadratic_logarithmic`, `quadratic_reciprocal`.
161
162
  :type pbf: str
162
- :param init_duals: Initial values for the dual variables. Defaults to the lower bound for all.
163
+ :param init_duals: Initial values for the dual variables in this group. Defaults to dual lower bound for all.
163
164
  :type init_duals: float | Tensor
164
- :param init_penalties: Initial values for the penalty variables. Defaults to min(10, lower_bound) for all.
165
+ :param init_penalties: Initial values for the penalty variables in this group. Defaults to penalty upper bound for all.
165
166
  :type init_penalties: float | Tensor
167
+ :param momentum: Multiplier for dual parameter update in this group. Values close to 1 correspond to high "momentum". If None, inherits from parent.
168
+ :type momentum: float
169
+ :param primal_update_process_length: Length of the primal update process for this group. If 1 (default), uses original algorithm variant.
170
+ :type primal_update_process_length: int
166
171
  """
167
172
 
168
173
 
169
- params, settings_dict = self._init_constraint_group(m, mu, lr, penalty_update, pbf, init_duals, init_penalties, momentum, dampening, self.dual_range, self.penalty_range)
174
+ params, settings_dict = self._init_constraint_group(m, penalty_mult, penalty_update, delta, pbf, init_duals, init_penalties, momentum, self.dual_range, self.penalty_range, primal_update_process_length)
170
175
  param_group_dict = {"params": params, **settings_dict}
171
176
  self.add_param_group(param_group_dict)
172
177
 
@@ -181,14 +186,14 @@ class PBM(Optimizer):
181
186
  """
182
187
 
183
188
  for i, group in enumerate(self.param_groups):
184
- duals, penalties, mu, lr, penalty_update, pbf, momentum, dampening, buffer = group["params"][0], group["params"][1], group["mu"], group["lr"], group["penalty_update"], group["pbf"], group['momentum'], group['dampening'], group['momentum_buffer']
189
+ duals, penalties, p_mult, _update_penalties, delta, pbf, momentum, primal_update_process_length = group["params"][0], group["params"][1], group["p_mult"], group["penalty_update"], group["delta"], group["pbf"], group['dual_momentum'], group["primal_update_process_length"]
185
190
  group_constraints = constraints[i * len(duals) : (i + 1) * len(duals)]
186
191
  cdivp = group_constraints.div(penalties)
187
192
  with torch.no_grad():
188
- _update_duals(duals, cdivp, penalty_barrier_funcs[pbf]['d'], mu, momentum, dampening, buffer)
193
+ _update_duals(duals, cdivp, penalty_barrier_funcs[pbf]['d'], momentum)
189
194
  clamp_(duals, min=self.dual_range[0], max=self.dual_range[1])
190
- # penalty_update(penalties, lr, duals)
191
- # clamp_(penalties, min=self.penalty_range[0], max=self.penalty_range[1])
195
+ _update_penalties(penalties, p_mult, duals, penalty_barrier_funcs[pbf]['d'](group_constraints), delta)
196
+ clamp_(penalties, min=self.penalty_range[0], max=self.penalty_range[1])
192
197
 
193
198
  def forward(self, loss: Tensor, constraints: Tensor) -> Tensor:
194
199
  """
@@ -221,7 +226,7 @@ class PBM(Optimizer):
221
226
  Evaluates the Penalty-Barrier Lagrangian and updates the dual variables and penalties.
222
227
 
223
228
  Combines the computation of the Lagrangian and the update of dual variables and penalties
224
- in a single step, which can be more efficient than calling `forward` and `update` separately.
229
+ in a single step.
225
230
 
226
231
  :param loss: Loss (objective function) value.
227
232
  :type loss: torch.Tensor
@@ -233,45 +238,71 @@ class PBM(Optimizer):
233
238
 
234
239
  lagrangian = torch.zeros_like(loss)
235
240
  lagrangian.add_(loss)
241
+ _last_c_group_index = 0
236
242
  for i, group in enumerate(self.param_groups):
237
- duals, penalties, mu, lr, _update_penalties, pbf, momentum, dampening, buffer = group["params"][0], group["params"][1], group["mu"], group["lr"], group["penalty_update"], group["pbf"], group['momentum'], group['dampening'], group['momentum_buffer']
238
- group_constraints = constraints[i * len(duals) : (i + 1) * len(duals)]
243
+ duals, penalties, p_mult, _update_penalties, delta, pbf, momentum, primal_update_process_length = group["params"][0], group["params"][1], group["p_mult"], group["penalty_update"], group["delta"], group["pbf"], group['dual_momentum'], group["primal_update_process_length"]
244
+ group_constraints = constraints[_last_c_group_index : _last_c_group_index + len(duals)]
245
+ _last_c_group_index = _last_c_group_index + len(duals)
239
246
  # calculate lagrangian
240
- cdivp = group_constraints.div(penalties)
241
- # update duals and penalties
242
- with torch.no_grad():
243
- _update_duals(duals, cdivp, penalty_barrier_funcs[pbf]['d'], mu, momentum, dampening, buffer)
244
- clamp_(duals, min=self.dual_range[0], max=self.dual_range[1])
245
- # _update_penalties(penalties, lr, duals)
246
- # clamp_(penalties, min=self.penalty_range[0], max=self.penalty_range[1])
247
+ if self.iter + 1 == primal_update_process_length: # this enables a second variant of the algorithm
248
+ # update duals and penalties
249
+ cdivp = group_constraints.div(penalties)
250
+ with torch.no_grad():
251
+ _update_duals(duals, cdivp, penalty_barrier_funcs[pbf]['d'], momentum)
252
+ clamp_(duals, min=self.dual_range[0], max=self.dual_range[1])
253
+ _update_penalties(penalties, p_mult, duals, penalty_barrier_funcs[pbf]['d'](group_constraints), delta)
254
+ clamp_(penalties, min=self.penalty_range[0], max=self.penalty_range[1])
247
255
 
248
256
  cdivp = group_constraints.div(penalties)
249
257
  pbf_val = penalty_barrier_funcs[pbf]['f'](cdivp)
250
258
  lagrangian.add_(duals.mul(penalties) @ pbf_val)
251
259
 
260
+ # update the iter
261
+ self.iter = (self.iter + 1) % primal_update_process_length
262
+
252
263
  return lagrangian
253
264
 
254
-
255
- def update_penalties(self):
265
+ def update_penalties(self, constraints: Tensor) -> None:
256
266
  """
257
- Performs the penalty update according to`penalty_update`.
267
+ Updates penalties according to the specified penalty update strategy for each constraint group.
268
+
269
+ :param constraints: Tensor of constraint violations.
270
+ :type constraints: torch.Tensor
271
+ :return: None
272
+ :rtype: None
258
273
  """
259
- for group in self.param_groups:
260
- duals, penalties, lr, _update_penalties = group["params"][0], group["params"][1], group["lr"], group["penalty_update"]
261
- _update_penalties(penalties, lr, duals)
274
+ for i, group in enumerate(self.param_groups):
275
+ duals, penalties, p_mult, _update_penalties, pbf = group["params"][0], group["params"][1], group["p_mult"], group["penalty_update"], group['pbf']
276
+ group_constraints = constraints[i * len(duals) : (i + 1) * len(duals)]
277
+ _update_penalties(penalties, p_mult, duals, penalty_barrier_funcs[pbf]['d'](group_constraints))
262
278
  clamp_(penalties, min=self.penalty_range[0], max=self.penalty_range[1])
263
279
 
280
+
264
281
  def state_dict(self) -> dict[str, Any]:
282
+ """
283
+ Returns the state of the optimizer as a dictionary, including dual and penalty ranges and all constraint groups.
284
+
285
+ :return: Dictionary containing optimizer state with param groups and configuration.
286
+ :rtype: dict[str, Any]
287
+ """
265
288
 
266
289
  state_dict = super().state_dict()
267
290
  state_dict["state"]["penalty_range"] = self.penalty_range
268
291
  state_dict["state"]["dual_range"] = self.dual_range
269
292
  # save params themselves in state_dict instead of param ID in default PyTorch
270
293
  for id_pg, pg in enumerate(state_dict['param_groups']):
271
- pg['params'] = [self.param_groups[id_pg]['params'][param_id] for param_id in pg['params'] ]
294
+ pg['params'] = self.param_groups[id_pg]['params']
272
295
  return state_dict
273
296
 
274
297
  def load_state_dict(self, state_dict: dict[str, Any]) -> None:
298
+ """
299
+ Loads the optimizer state from a dictionary, including ranges and all constraint groups.
300
+
301
+ :param state_dict: Dictionary containing optimizer state (as returned by state_dict).
302
+ :type state_dict: dict[str, Any]
303
+ :return: None
304
+ :rtype: None
305
+ """
275
306
  self.penalty_range = state_dict["state"]["penalty_range"]
276
307
  self.dual_range = state_dict["state"]["dual_range"]
277
308
  params = state_dict["param_groups"]
@@ -286,22 +317,29 @@ penalty_barrier_funcs = {
286
317
  'quadratic_reciprocal': {'f': quad_recipr, 'd': quad_recipr_der}
287
318
  }
288
319
 
289
- def _update_duals(duals: Tensor, cdivp: Tensor, pbf_der: Callable, mu: float, momentum: float, dampening: float, buffer: Tensor) -> None:
320
+ def _update_duals(duals: Tensor, cdivp: Tensor, pbf_der: Callable, gamma: float) -> None:
290
321
  pbf_der_val = pbf_der(cdivp)
291
- if momentum == 0 or not buffer.any():
292
- buffer = pbf_der_val
293
- else:
294
- buffer.mul_(momentum).add_(pbf_der_val, alpha = 1 - dampening)
322
+ upd = pbf_der_val.mul(duals)
323
+ duals.mul_(gamma).add_(upd, alpha=1-gamma)
295
324
 
296
- buffer.clamp_(mu, 1/mu)
297
- duals.mul_(buffer)
298
-
299
-
300
- def _update_penalties_const(penalties: Tensor, mu: Tensor, duals: Tensor):
325
+ def _update_penalties_const(penalties: Tensor, p_mult: Tensor = None, duals: Tensor = None, phi_der: Tensor = None, delta: float = None):
301
326
  pass
302
327
 
303
- def _update_penalties_dimin(penalties: Tensor, mu: Tensor, duals: Tensor):
304
- penalties.mul_(mu)
328
+ def _update_penalties_dimin(penalties: Tensor, p_mult: Tensor, duals: Tensor = None, phi_der: Tensor = None, delta: float = None):
329
+ penalties.mul_(p_mult)
330
+
331
+ def _update_penalties_adapt(penalties: Tensor, p_mult: Tensor, duals: Tensor, phi_der: Tensor, delta: float):
332
+ d_phd = torch.where(phi_der < 1., phi_der, torch.clamp(delta * phi_der, min=1.0))
333
+ b = (1-p_mult)*penalties/(d_phd + 1e-8)
334
+ penalties.mul_(p_mult).add_(b)
335
+
336
+ def _update_penalties_dimin_dual(penalties: Tensor, p_mult: Tensor, duals: Tensor, phi_der: Tensor = None, delta: float = None):
337
+ penalties.mul_(p_mult).mul_(duals)
338
+
305
339
 
306
- def _update_penalties_dimin_dual(penalties: Tensor, mu: Tensor, duals: Tensor):
307
- penalties.mul_(mu).mul_(duals)
340
+ penalty_update_funcs = {
341
+ 'const': _update_penalties_const,
342
+ 'dimin': _update_penalties_dimin,
343
+ 'adapt': _update_penalties_adapt,
344
+ 'dimin_dual': _update_penalties_dimin_dual
345
+ }
@@ -556,7 +556,7 @@ class PBM(Optimizer):
556
556
  # define the augmented F and backpropagate
557
557
  F_loss = loss + self._dual_vars @ self.constraints
558
558
 
559
- # TODO: implement smoothing here
559
+ # TODO: implement smoothing herew
560
560
 
561
561
  # clean the gradients
562
562
  # self.constraints = torch.zeros(self.m, device=self.device)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humancompatible-train
3
- Version: 0.3.0
3
+ Version: 0.3.0.1
4
4
  Summary: PyTorch-based package for constrained training of neural networks
5
5
  Author: Gilles Bareilles, Adam Bosak, Jana Lepsova, Jakub Marecek
6
6
  Author-email: Andrii Kliachkin <kliacand@fel.cvut.cz>
@@ -156,8 +156,6 @@ The constraint options are:
156
156
  - `equalized_odds_vec`: constraint on the Positive Rate of each group as defined by `fairret.NormLoss`;
157
157
  - `weight_norm`: constraint on the Frobenius norm of the weights and biases of each layer of the neural network.
158
158
 
159
- The above command will run the benchmark and save the logs and models in the `benchmark/results/[dataset]_[task]` directory; it will also plot the trajectories into a .png file saved in the same location.
160
-
161
159
  The benchmarking code (all of which is contained in the `benchmark` directory) is easy to parse and extend with other datasets and constraints.
162
160
 
163
161
 
@@ -0,0 +1,532 @@
1
+ import unittest
2
+ import torch
3
+ from humancompatible.train.dual_optim import PBM
4
+ from humancompatible.train.dual_optim.barrier import quad_log, quad_log_der, quad_recipr, quad_recipr_der
5
+
6
+
7
+ class TestPBMInitialization(unittest.TestCase):
8
+ """Test PBM initialization and error handling."""
9
+
10
+ def test_init_with_m(self):
11
+ """Test initialization with m parameter."""
12
+ pbm = PBM(m=5, penalty_mult=0.1)
13
+ self.assertEqual(len(pbm.duals), 5)
14
+ self.assertEqual(len(pbm.penalties), 5)
15
+
16
+ def test_init_with_scalar_duals(self):
17
+ """Test initialization with scalar init_duals."""
18
+ pbm = PBM(m=3, init_duals=0.5, init_penalties=10.0)
19
+ self.assertTrue(torch.allclose(pbm.duals, torch.tensor([0.5, 0.5, 0.5])))
20
+ self.assertTrue(torch.allclose(pbm.penalties, torch.tensor([10.0, 10.0, 10.0])))
21
+
22
+ def test_init_with_tensor_duals(self):
23
+ """Test initialization with tensor init_duals."""
24
+ init_duals = torch.tensor([0.1, 0.2, 0.3])
25
+ pbm = PBM(m=3, init_duals=init_duals, init_penalties=2.0)
26
+ self.assertTrue(torch.allclose(pbm.duals, init_duals))
27
+
28
+ def test_init_defaults_to_dual_range(self):
29
+ """Test that None init_duals defaults to dual_range lower bound."""
30
+ pbm = PBM(m=3, dual_range=(0.01, 100.0))
31
+ self.assertTrue(torch.allclose(pbm.duals, torch.tensor([0.01, 0.01, 0.01])))
32
+
33
+ def test_init_defaults_to_penalty_range(self):
34
+ """Test that None init_penalties defaults to penalty_range upper bound."""
35
+ pbm = PBM(m=3, penalty_range=(0.1, 50.0))
36
+ self.assertTrue(torch.allclose(pbm.penalties, torch.tensor([50.0, 50.0, 50.0])))
37
+
38
+ def test_init_missing_m_and_duals_raises_error(self):
39
+ """Test that ValueError is raised when both m and init_duals are None."""
40
+ with self.assertRaises(ValueError):
41
+ PBM(m=None, init_duals=None)
42
+
43
+ def test_init_with_device(self):
44
+ """Test initialization on specific device."""
45
+ device = 'cpu'
46
+ pbm = PBM(m=3, device=device)
47
+ self.assertEqual(pbm.duals.device.type, device)
48
+
49
+ def test_init_different_pbf(self):
50
+ """Test initialization with different penalty-barrier functions."""
51
+ pbm_log = PBM(m=3, pbf='quadratic_logarithmic')
52
+ pbm_recipr = PBM(m=3, pbf='quadratic_reciprocal')
53
+ # Just verify they don't raise errors
54
+ self.assertEqual(len(pbm_log.duals), 3)
55
+ self.assertEqual(len(pbm_recipr.duals), 3)
56
+
57
+ def test_init_invalid_pbf_raises_error(self):
58
+ """Test that invalid pbf raises KeyError when used."""
59
+ pbm = PBM(m=3, pbf='invalid_function')
60
+ # PBM creation doesn't validate pbf, but using it will fail
61
+ loss = torch.tensor(1.0)
62
+ constraints = torch.tensor([0.1, 0.1, 0.1])
63
+ with self.assertRaises(KeyError):
64
+ pbm.forward(loss, constraints)
65
+
66
+ def test_init_invalid_penalty_update_raises_error(self):
67
+ """Test that invalid penalty_update raises ValueError."""
68
+ with self.assertRaises(ValueError):
69
+ PBM(m=3, penalty_update='invalid_strategy')
70
+
71
+
72
+ class TestDualAndPenaltyBounds(unittest.TestCase):
73
+ """Test that duals and penalties stay within specified bounds."""
74
+
75
+ def test_duals_clamped_to_range(self):
76
+ """Test that duals are clamped to dual_range."""
77
+ pbm = PBM(m=2, dual_range=(0.01, 10.0), init_duals=5.0)
78
+ # Large constraint violations through forward_update
79
+ loss = torch.tensor(1.0)
80
+ constraints = torch.tensor([1000.0, 1000.0])
81
+ for _ in range(5):
82
+ pbm.forward_update(loss, constraints)
83
+ # Duals should not exceed upper bound
84
+ self.assertTrue(torch.all(pbm.duals <= 10.0))
85
+ # Duals should not fall below lower bound
86
+ self.assertTrue(torch.all(pbm.duals >= 0.01))
87
+
88
+ def test_penalties_clamped_to_range(self):
89
+ """Test that penalties are clamped to penalty_range."""
90
+ pbm = PBM(m=2, penalty_mult=0.05, penalty_range=(0.1, 100.0))
91
+ loss = torch.tensor(1.0)
92
+ # Run multiple updates
93
+ for _ in range(10):
94
+ constraints = torch.tensor([0.5, 0.5])
95
+ pbm.forward_update(loss, constraints)
96
+ # Penalties should not exceed upper bound
97
+ self.assertTrue(torch.all(pbm.penalties <= 100.0))
98
+ # Penalties should not fall below lower bound
99
+ self.assertTrue(torch.all(pbm.penalties >= 0.1))
100
+
101
+ def test_duals_stay_positive_with_small_range(self):
102
+ """Test duals with very small range."""
103
+ pbm = PBM(m=3, dual_range=(1e-6, 1e-3), init_duals=5e-4)
104
+ loss = torch.tensor(1.0)
105
+ constraints = torch.tensor([0.1, 0.2, 0.3])
106
+ pbm.forward_update(loss, constraints)
107
+ self.assertTrue(torch.all(pbm.duals >= 1e-6))
108
+ self.assertTrue(torch.all(pbm.duals <= 1e-3))
109
+
110
+
111
+ class TestPenaltyUpdateStrategies(unittest.TestCase):
112
+ """Test different penalty update strategies."""
113
+
114
+ def test_constant_penalty(self):
115
+ """Test that penalty_update='const' keeps penalties constant during forward_update."""
116
+ init_penalty = 5.0
117
+ pbm = PBM(m=3, penalty_update='const', init_penalties=init_penalty)
118
+ penalties_before = pbm.penalties.clone()
119
+ loss = torch.tensor(1.0)
120
+
121
+ for _ in range(5):
122
+ constraints = torch.tensor([0.1, 0.2, 0.3])
123
+ pbm.forward_update(loss, constraints)
124
+
125
+ self.assertTrue(torch.allclose(pbm.penalties, penalties_before))
126
+
127
+ def test_diminishing_penalty_monotonic(self):
128
+ """Test that penalty_update='dimin' decreases penalties monotonically."""
129
+ penalty_mult = 0.8
130
+ pbm = PBM(m=2, penalty_update='dimin', penalty_mult=penalty_mult, init_penalties=100.0)
131
+
132
+ penalties_history = [pbm.penalties.clone()]
133
+ loss = torch.tensor(1.0)
134
+ constraints = torch.tensor([0.5, 0.5])
135
+
136
+ for _ in range(5):
137
+ pbm.forward_update(loss, constraints)
138
+ penalties_history.append(pbm.penalties.clone())
139
+
140
+ # Verify monotonic decrease (from 1 onwards since 0 is initial)
141
+ for i in range(len(penalties_history) - 1):
142
+ self.assertTrue(torch.all(penalties_history[i + 1] <= penalties_history[i]))
143
+
144
+ def test_adaptive_penalty_responds_to_violations(self):
145
+ """Test that adaptive penalty responds to constraint violations."""
146
+ pbm_adapt = PBM(m=1, penalty_update='dimin_adapt', penalty_mult=0.8, delta=0.9, init_penalties=10.0)
147
+ pbm_const = PBM(m=1, penalty_update='const', init_penalties=10.0)
148
+ loss = torch.tensor(1.0)
149
+
150
+ # Use larger constraint to see penalty difference
151
+ constraint = torch.tensor([1.0])
152
+ pbm_adapt.forward_update(loss, constraint)
153
+ pbm_const.forward_update(loss, constraint)
154
+
155
+ # Adaptive should differ from constant after update
156
+ # (or at least both updates should complete without error)
157
+ self.assertEqual(len(pbm_adapt.penalties), 1)
158
+
159
+ # def test_dimin_dual_penalty_changes(self):
160
+ # """Test that dimin_dual strategy multiplies by duals."""
161
+ # pbm = PBM(m=2, penalty_update='dimin_dual', penalty_mult=0.8, init_duals=2.0, init_penalties=10.0)
162
+
163
+ # initial_penalty = pbm.penalties.clone()
164
+ # loss = torch.tensor(1.0)
165
+ # # Use larger constraints to trigger meaningful updates
166
+ # constraints = torch.tensor([1.0, 1.0])
167
+ # pbm.forward_update(loss, constraints)
168
+
169
+ # # With dimin_dual, penalties should decrease due to low initial duals
170
+ # breakpoint()
171
+ # self.assertTrue(torch.all(pbm.penalties <= initial_penalty))
172
+
173
+
174
+ class TestBarrierFunctions(unittest.TestCase):
175
+ """Test barrier function properties."""
176
+
177
+ def test_quad_log_positive_derivative_at_violation(self):
178
+ """Test that quad_log derivative is positive at constraint violations."""
179
+ t = torch.tensor([-0.2, 0.0, 0.5])
180
+ deriv = quad_log_der(t)
181
+ self.assertTrue(torch.all(deriv > 0.0))
182
+
183
+ def test_quad_recipr_positive_derivative_at_violation(self):
184
+ """Test that quad_recipr derivative is positive at constraint violations."""
185
+ t = torch.tensor([-0.2, 0.0, 0.3])
186
+ deriv = quad_recipr_der(t)
187
+ self.assertTrue(torch.all(deriv > 0.0))
188
+
189
+ def test_quad_log_derivative_matches_finite_diff_smooth_region(self):
190
+ """Test quad_log derivative against finite differences in smooth region."""
191
+ # Use values well within smooth region (t >= -0.5)
192
+ t = torch.tensor([0.5, 1.0, 2.0])
193
+ eps = 1e-3
194
+
195
+ # Finite difference
196
+ deriv_fd = (quad_log(t + eps) - quad_log(t - eps)) / (2 * eps)
197
+ deriv_analytical = quad_log_der(t)
198
+
199
+ self.assertTrue(torch.allclose(deriv_fd, deriv_analytical, atol=1e-3))
200
+
201
+ def test_barrier_functions_increase_with_violation(self):
202
+ """Test that barrier functions penalize larger violations more."""
203
+ t1 = torch.tensor([0.0])
204
+ t2 = torch.tensor([0.5])
205
+
206
+ log_v1 = quad_log(t1)
207
+ log_v2 = quad_log(t2)
208
+ self.assertTrue(log_v2 > log_v1)
209
+
210
+ recipr_v1 = quad_recipr(t1)
211
+ recipr_v2 = quad_recipr(t2)
212
+ self.assertTrue(recipr_v2 > recipr_v1)
213
+
214
+ def test_barrier_functions_nonzero_at_zero(self):
215
+ """Test that barrier function values at zero constraint are reasonable."""
216
+ t = torch.tensor([0.0])
217
+ log_val = quad_log(t)
218
+ recipr_val = quad_recipr(t)
219
+
220
+ # Both should be exactly 0 at t=0
221
+ self.assertTrue(torch.allclose(log_val, torch.tensor([0.0]), atol=1e-6))
222
+ self.assertTrue(torch.allclose(recipr_val, torch.tensor([0.0]), atol=1e-6))
223
+
224
+
225
+ class TestDualVariableUpdates(unittest.TestCase):
226
+ """Test dual variable update mechanisms."""
227
+
228
+ def test_dual_update_with_zero_momentum(self):
229
+ """Test dual update with gamma=0 (complete replacement)."""
230
+ pbm = PBM(m=2, gamma=0.0, init_duals=1.0, init_penalties=10.0)
231
+ loss = torch.tensor(1.0)
232
+ constraints = torch.tensor([0.5, 0.5])
233
+
234
+ # With gamma=0, new duals should be completely replaced
235
+ pbm.forward_update(loss, constraints)
236
+ # Just verify update happened without error
237
+ self.assertEqual(len(pbm.duals), 2)
238
+
239
+ def test_dual_update_with_high_momentum(self):
240
+ """Test dual update with gamma close to 1 (damped update)."""
241
+ pbm_high = PBM(m=2, gamma=0.99, init_duals=1.0)
242
+ pbm_low = PBM(m=2, gamma=0.1, init_duals=1.0)
243
+ loss = torch.tensor(1.0)
244
+
245
+ constraints = torch.tensor([0.5, 0.5])
246
+ duals_high_before = pbm_high.duals.clone()
247
+ duals_low_before = pbm_low.duals.clone()
248
+
249
+ pbm_high.forward_update(loss, constraints)
250
+ pbm_low.forward_update(loss, constraints)
251
+
252
+ # High momentum should change duals less
253
+ high_change = torch.abs(pbm_high.duals - duals_high_before).sum()
254
+ low_change = torch.abs(pbm_low.duals - duals_low_before).sum()
255
+ self.assertTrue(high_change < low_change)
256
+
257
+ def test_dual_momentum_buffer_tracked(self):
258
+ """Test that momentum buffer is tracked across updates."""
259
+ pbm = PBM(m=2, gamma=0.8)
260
+ loss = torch.tensor(1.0)
261
+ constraints = torch.tensor([0.5, 0.5])
262
+
263
+ # Run multiple updates to accumulate momentum
264
+ for _ in range(3):
265
+ pbm.forward_update(loss, constraints)
266
+ # Just verify no errors occur
267
+ self.assertEqual(len(pbm.duals), 2)
268
+
269
+
270
+ class TestMultipleConstraintGroups(unittest.TestCase):
271
+ """Test adding and managing multiple constraint groups."""
272
+
273
+ def test_add_constraint_group(self):
274
+ """Test adding a second constraint group."""
275
+ pbm = PBM(m=2, penalty_mult=0.1)
276
+ self.assertEqual(len(pbm.duals), 2)
277
+
278
+ pbm.add_constraint_group(m=3, penalty_mult=0.15)
279
+ self.assertEqual(len(pbm.duals), 5) # 2 + 3
280
+ self.assertEqual(len(pbm.penalties), 5)
281
+
282
+ def test_constraint_groups_independent_updates(self):
283
+ """Test that constraint groups update independently."""
284
+ pbm = PBM(m=2, penalty_mult=0.8, penalty_update='dimin')
285
+ pbm.add_constraint_group(m=2, penalty_mult=0.5, penalty_update='dimin', delta=0.9, pbf='quadratic_logarithmic')
286
+ loss = torch.tensor(1.0)
287
+ constraints = torch.tensor([0.5, 0.5, 0.1, 0.1])
288
+ pbm.forward_update(loss, constraints)
289
+
290
+ # Just verify both groups were updated
291
+ self.assertEqual(len(pbm.penalties), 4)
292
+
293
+ def test_multiple_groups_different_pbf(self):
294
+ """Test adding groups with different barrier functions."""
295
+ pbm = PBM(m=2, pbf='quadratic_logarithmic', penalty_mult=0.1)
296
+ pbm.add_constraint_group(m=2, pbf='quadratic_reciprocal', penalty_mult=0.1, penalty_update='dimin', delta=0.9)
297
+ loss = torch.tensor(1.0)
298
+ constraints = torch.tensor([0.5, 0.5, 0.5, 0.5])
299
+ pbm.forward_update(loss, constraints)
300
+
301
+ # Just verify both groups update without error
302
+ self.assertEqual(len(pbm.duals), 4)
303
+
304
+ def test_duals_property_concatenates_groups(self):
305
+ """Test that duals property correctly concatenates groups."""
306
+ pbm = PBM(m=3, init_duals=1.0, penalty_mult=0.1)
307
+ pbm.add_constraint_group(m=2, init_duals=2.0, penalty_mult=0.1, penalty_update='dimin', delta=0.9, pbf='quadratic_logarithmic')
308
+
309
+ duals_concat = pbm.duals
310
+ self.assertEqual(len(duals_concat), 5)
311
+ self.assertTrue(torch.allclose(duals_concat[:3], torch.tensor([1.0, 1.0, 1.0])))
312
+ self.assertTrue(torch.allclose(duals_concat[3:], torch.tensor([2.0, 2.0])))
313
+
314
+ def test_penalties_property_concatenates_groups(self):
315
+ """Test that penalties property correctly concatenates groups."""
316
+ pbm = PBM(m=2, init_penalties=5.0, penalty_mult=0.1)
317
+ pbm.add_constraint_group(m=3, init_penalties=10.0, penalty_mult=0.1, penalty_update='dimin', delta=0.9, pbf='quadratic_logarithmic')
318
+
319
+ penalties_concat = pbm.penalties
320
+ self.assertEqual(len(penalties_concat), 5)
321
+ self.assertTrue(torch.allclose(penalties_concat[:2], torch.tensor([5.0, 5.0])))
322
+ self.assertTrue(torch.allclose(penalties_concat[2:], torch.tensor([10.0, 10.0, 10.0])))
323
+
324
+
325
+ class TestLagrangianComputation(unittest.TestCase):
326
+ """Test Lagrangian computation accuracy."""
327
+
328
+ def test_lagrangian_includes_loss(self):
329
+ """Test that Lagrangian includes the loss term."""
330
+ pbm = PBM(m=2)
331
+ loss = torch.tensor(5.0)
332
+ constraints = torch.tensor([0.1, 0.2])
333
+
334
+ lagrangian = pbm.forward(loss, constraints)
335
+ # Lagrangian should be at least the loss
336
+ self.assertTrue(lagrangian >= loss)
337
+
338
+ def test_lagrangian_zero_constraint_equals_loss(self):
339
+ """Test that Lagrangian equals loss when constraints are zero."""
340
+ pbm = PBM(m=2, init_duals=1.0, init_penalties=1.0)
341
+ loss = torch.tensor(3.0)
342
+ constraints = torch.tensor([0.0, 0.0])
343
+
344
+ lagrangian = pbm.forward(loss, constraints)
345
+ self.assertTrue(torch.allclose(lagrangian, loss))
346
+
347
+ def test_lagrangian_increases_with_violation(self):
348
+ """Test that Lagrangian increases with constraint violation."""
349
+ pbm = PBM(m=1, init_duals=1.0, init_penalties=1.0)
350
+ loss = torch.tensor(1.0)
351
+
352
+ lag_small = pbm.forward(loss, torch.tensor([0.1]))
353
+ lag_large = pbm.forward(loss, torch.tensor([0.5]))
354
+
355
+ self.assertTrue(lag_large > lag_small)
356
+
357
+ def test_forward_update_produces_same_lagrangian(self):
358
+ """Test that forward_update produces valid Lagrangian."""
359
+ pbm = PBM(m=2, gamma=0.8, init_duals=0.5, init_penalties=5.0)
360
+
361
+ loss = torch.tensor(2.0)
362
+ constraints = torch.tensor([0.3, 0.4])
363
+
364
+ # forward_update should produce a Lagrangian value
365
+ lag = pbm.forward_update(loss, constraints)
366
+
367
+ # Lagrangian should be a scalar
368
+ self.assertTrue(lag.dim() == 0 or lag.shape == torch.Size([]))
369
+
370
+ def test_manual_lagrangian_computation(self):
371
+ """Test Lagrangian against manual calculation with simple values."""
372
+ pbm = PBM(m=1, init_duals=1.0, init_penalties=1.0, pbf='quadratic_logarithmic')
373
+ loss = torch.tensor(1.0)
374
+ constraints = torch.tensor([0.0])
375
+
376
+ lagrangian = pbm.forward(loss, constraints)
377
+
378
+ # When constraint is 0, barrier function contribution should be 0
379
+ # So lagrangian should equal loss
380
+ self.assertTrue(torch.allclose(lagrangian, loss, atol=1e-5))
381
+
382
+
383
+ class TestSerialization(unittest.TestCase):
384
+ """Test state dict save and load."""
385
+
386
+ def test_state_dict_contains_ranges(self):
387
+ """Test that state_dict includes dual and penalty ranges."""
388
+ pbm = PBM(m=3, dual_range=(0.01, 50.0), penalty_range=(0.5, 200.0))
389
+ state = pbm.state_dict()
390
+
391
+ self.assertEqual(state["state"]["dual_range"], (0.01, 50.0))
392
+ self.assertEqual(state["state"]["penalty_range"], (0.5, 200.0))
393
+
394
+ def test_state_dict_roundtrip_preserves_duals_and_penalties(self):
395
+ """Test that save/load preserves duals and penalties."""
396
+ pbm1 = PBM(m=3, init_duals=1.5, init_penalties=8.0, penalty_update='dimin')
397
+ loss = torch.tensor(1.0)
398
+
399
+ # Modify state
400
+ constraints = torch.tensor([0.1, 0.2, 0.3])
401
+ pbm1.forward_update(loss, constraints)
402
+
403
+ duals_before = pbm1.duals.clone()
404
+ penalties_before = pbm1.penalties.clone()
405
+
406
+ # Save and load
407
+ state = pbm1.state_dict()
408
+ pbm2 = PBM(m=3, penalty_update='dimin')
409
+ pbm2.load_state_dict(state)
410
+
411
+ self.assertTrue(torch.allclose(pbm2.duals, duals_before))
412
+ self.assertTrue(torch.allclose(pbm2.penalties, penalties_before))
413
+
414
+ def test_state_dict_roundtrip_with_multiple_groups(self):
415
+ """Test state dict roundtrip with multiple constraint groups."""
416
+ pbm1 = PBM(m=2, init_duals=1.0, init_penalties=5.0, penalty_mult=0.1, penalty_update='dimin')
417
+ pbm1.add_constraint_group(m=3, init_duals=0.5, init_penalties=10.0, penalty_mult=0.1, penalty_update='dimin', delta=0.9, pbf='quadratic_logarithmic')
418
+ loss = torch.tensor(1.0)
419
+
420
+ # Update some
421
+ constraints = torch.tensor([0.1, 0.1, 0.2, 0.2, 0.2])
422
+ pbm1.forward_update(loss, constraints)
423
+
424
+ state = pbm1.state_dict()
425
+ pbm2 = PBM(m=2, penalty_mult=0.1, penalty_update='dimin')
426
+ pbm2.add_constraint_group(m=3, penalty_mult=0.1, penalty_update='dimin', delta=0.9, pbf='quadratic_logarithmic')
427
+ pbm2.load_state_dict(state)
428
+
429
+ self.assertTrue(torch.allclose(pbm2.duals, pbm1.duals))
430
+ self.assertTrue(torch.allclose(pbm2.penalties, pbm1.penalties))
431
+
432
+
433
+ class TestParameterInteractions(unittest.TestCase):
434
+ """Test interactions between different parameters."""
435
+
436
+ def test_gamma_affects_dual_convergence_rate(self):
437
+ """Test that higher gamma slows dual changes."""
438
+ pbm_fast = PBM(m=1, gamma=0.1, init_duals=1.0, init_penalties=10.0)
439
+ pbm_slow = PBM(m=1, gamma=0.9, init_duals=1.0, init_penalties=10.0)
440
+ loss = torch.tensor(1.0)
441
+ constraints = torch.tensor([1.0])
442
+
443
+ duals_fast_before = pbm_fast.duals.clone()
444
+ duals_slow_before = pbm_slow.duals.clone()
445
+
446
+ pbm_fast.forward_update(loss, constraints)
447
+ pbm_slow.forward_update(loss, constraints)
448
+
449
+ change_fast = torch.abs(pbm_fast.duals - duals_fast_before)
450
+ change_slow = torch.abs(pbm_slow.duals - duals_slow_before)
451
+
452
+ self.assertTrue(change_fast > change_slow)
453
+
454
+ def test_delta_affects_adaptive_penalty_behavior(self):
455
+ """Test that delta parameter affects adaptive penalty update."""
456
+ pbm_low_delta = PBM(m=1, penalty_update='dimin_adapt', delta=0.5, penalty_mult=0.8, init_penalties=10.0)
457
+ pbm_high_delta = PBM(m=1, penalty_update='dimin_adapt', delta=2.0, penalty_mult=0.8, init_penalties=10.0)
458
+ loss = torch.tensor(1.0)
459
+
460
+ # Constraint with significant violation
461
+ constraints = torch.tensor([2.0])
462
+
463
+ pbm_low_delta.forward_update(loss, constraints)
464
+ pbm_high_delta.forward_update(loss, constraints)
465
+
466
+ # Different delta should produce different penalties
467
+ self.assertFalse(torch.allclose(pbm_low_delta.penalties, pbm_high_delta.penalties))
468
+
469
+ def test_penalty_mult_scale_different_strategies(self):
470
+ """Test that penalty_mult works across different strategies."""
471
+ loss = torch.tensor(1.0)
472
+ constraints = torch.tensor([0.5])
473
+
474
+ pbm_dimin = PBM(m=1, penalty_update='dimin', penalty_mult=0.5, init_penalties=100.0)
475
+ pbm_dimin.forward_update(loss, constraints)
476
+ dimin_penalty = pbm_dimin.penalties.clone()
477
+
478
+ pbm_adapt = PBM(m=1, penalty_update='dimin_adapt', penalty_mult=0.5, init_penalties=100.0)
479
+ pbm_adapt.forward_update(loss, constraints)
480
+
481
+ # Both should be affected by penalty_mult, though differently
482
+ self.assertTrue(torch.all(dimin_penalty < 100.0))
483
+ self.assertTrue(torch.all(pbm_adapt.penalties < 100.0))
484
+
485
+
486
+ class TestConstraintSatisfactionProgress(unittest.TestCase):
487
+ """Test constraint satisfaction and convergence behavior."""
488
+
489
+ def test_constraints_drive_duals_up(self):
490
+ """Test that constraints drive dual variables upward."""
491
+ pbm = PBM(m=1, gamma=0.5, init_duals=0.1, init_penalties=10.0)
492
+ duals_before = pbm.duals.clone()
493
+ loss = torch.tensor(1.0)
494
+
495
+ # Positive constraint violation
496
+ constraints = torch.tensor([1.0])
497
+ pbm.forward_update(loss, constraints)
498
+
499
+ duals_after = pbm.duals.clone()
500
+ self.assertTrue(duals_after > duals_before)
501
+
502
+ def test_lagrangian_monotonic_on_consistent_constraints(self):
503
+ """Test that Lagrangian progression with consistent constraints."""
504
+ pbm = PBM(m=1, gamma=0.7, init_duals=0.5, init_penalties=5.0)
505
+ loss = torch.tensor(1.0)
506
+ constraints = torch.tensor([0.5])
507
+
508
+ lagrangians = []
509
+ for _ in range(3):
510
+ lag = pbm.forward_update(loss, constraints)
511
+ lagrangians.append(lag.item())
512
+
513
+ # Just verify sequence is computed without error
514
+ self.assertEqual(len(lagrangians), 3)
515
+
516
+ def test_penalties_decrease_with_dimin(self):
517
+ """Test that penalties monotonically decrease with dimin strategy."""
518
+ pbm = PBM(m=2, penalty_update='dimin', penalty_mult=0.7, init_penalties=100.0)
519
+ loss = torch.tensor(1.0)
520
+ constraints = torch.tensor([0.1, 0.1])
521
+
522
+ penalties_list = [pbm.penalties.clone()]
523
+ for _ in range(5):
524
+ pbm.forward_update(loss, constraints)
525
+ penalties_list.append(pbm.penalties.clone())
526
+
527
+ for i in range(len(penalties_list) - 1):
528
+ self.assertTrue(torch.all(penalties_list[i + 1] <= penalties_list[i]))
529
+
530
+
531
+ if __name__ == '__main__':
532
+ unittest.main()
@@ -1,94 +0,0 @@
1
- import unittest
2
- import torch
3
- from torch.optim import Optimizer
4
- from humancompatible.train.dual_optim.pbm import PBM
5
- from humancompatible.train.dual_optim.barrier import quad_log, quad_log_der, quad_recipr, quad_recipr_der
6
-
7
- class TestPBM(unittest.TestCase):
8
- def setUp(self):
9
- # Initialize PBM instances for reuse in tests
10
- self.pbm_default = PBM(m=3, mu=0.3, lr=0.1, penalty_update='dimin', pbf='quadratic_logarithmic')
11
- self.pbm_reciprocal = PBM(m=3, mu=0.3, lr=0.1, penalty_update='dimin', pbf='quadratic_reciprocal')
12
- self.pbm_const_penalty = PBM(m=3, mu=0.3, lr=0.1, penalty_update='const', pbf='quadratic_logarithmic')
13
- self.pbm_dimin_dual = PBM(m=3, mu=0.3, lr=0.1, penalty_update='dimin_dual', pbf='quadratic_logarithmic', init_duals=2.0)
14
-
15
- # Common test data
16
- self.loss = torch.tensor(5.0)
17
- self.constraints = torch.tensor([1.0, 2.0, 3.0])
18
- self.large_constraints = torch.tensor([10.0, 20.0, 30.0])
19
-
20
- def test_pbm_initialization(self):
21
- # Test initialization with m
22
- self.assertEqual(len(self.pbm_default.duals), 3)
23
- self.assertEqual(len(self.pbm_default.penalties), 3)
24
- self.assertEqual(self.pbm_default.defaults['mu'], 0.3)
25
- self.assertEqual(self.pbm_default.defaults['pbf'], 'quadratic_logarithmic')
26
-
27
- # Test initialization with init_duals and init_penalties
28
- init_duals = torch.tensor([1.0, 2.0, 3.0])
29
- init_penalties = torch.tensor([10.0, 20.0, 30.0])
30
- pbm = PBM(init_duals=init_duals, init_penalties=init_penalties, mu=0.3, lr=0.1, penalty_update='dimin', pbf='quadratic_logarithmic')
31
- self.assertTrue(torch.all(pbm.duals == init_duals))
32
- self.assertTrue(torch.all(pbm.penalties == init_penalties))
33
-
34
- # Test invalid initialization
35
- with self.assertRaises(ValueError):
36
- PBM(m=None, init_duals=None)
37
-
38
- def test_pbm_forward(self):
39
- # Test Lagrangian computation
40
- lagrangian = self.pbm_default.forward(self.loss, self.constraints)
41
- cdivp = self.constraints / self.pbm_default.penalties
42
- pbf_val = quad_log(cdivp)
43
- expected_lagrangian = self.loss + torch.dot(self.pbm_default.duals * self.pbm_default.penalties, pbf_val)
44
- self.assertTrue(torch.allclose(lagrangian, expected_lagrangian))
45
-
46
- def test_pbm_update_duals(self):
47
- # Test dual variable update
48
- cdivp = self.constraints / self.pbm_default.penalties
49
- pbf_der_val = quad_log_der(cdivp)
50
- expected_duals = self.pbm_default.duals * torch.clamp(pbf_der_val, 0.3, 1/0.3)
51
-
52
- self.pbm_default.update(self.constraints)
53
- self.assertTrue(torch.allclose(self.pbm_default.duals, expected_duals))
54
-
55
- def test_pbm_update_penalties_dimin(self):
56
- # Test penalty update with 'dimin' strategy
57
- initial_penalties = self.pbm_default.penalties.clone()
58
- self.pbm_default.update_penalties()
59
- expected_penalties = initial_penalties * 0.1
60
- self.assertTrue(torch.allclose(self.pbm_default.penalties, expected_penalties))
61
-
62
- def test_pbm_update_penalties_dimin_dual(self):
63
- # Test penalty update with 'dimin_dual' strategy
64
- initial_penalties = self.pbm_dimin_dual.penalties.clone()
65
- expected_penalties = initial_penalties * 0.1 * self.pbm_dimin_dual.duals
66
-
67
- self.pbm_dimin_dual.update_penalties()
68
- self.assertTrue(torch.allclose(self.pbm_dimin_dual.penalties, expected_penalties))
69
-
70
- def test_pbm_update_penalties_const(self):
71
- # Test penalty update with 'const' strategy
72
- initial_penalties = self.pbm_const_penalty.penalties.clone()
73
- self.pbm_const_penalty.update_penalties()
74
- self.assertTrue(torch.allclose(self.pbm_const_penalty.penalties, initial_penalties))
75
-
76
- def test_pbm_forward_update(self):
77
- # Test combined forward and update
78
- lagrangian = self.pbm_default.forward_update(self.loss, self.constraints)
79
- cdivp = self.constraints / self.pbm_default.penalties
80
- pbf_val = quad_log(cdivp)
81
- expected_lagrangian = self.loss + torch.dot(self.pbm_default.duals * self.pbm_default.penalties, pbf_val)
82
- self.assertTrue(torch.allclose(lagrangian, expected_lagrangian))
83
-
84
- def test_pbm_add_constraint_group(self):
85
- # Test adding a new constraint group
86
- self.pbm_default.add_constraint_group(m=2, mu=0.5, penalty_update='dimin', pbf='quadratic_reciprocal')
87
- self.assertEqual(len(self.pbm_default.duals), 5)
88
- self.assertEqual(len(self.pbm_default.penalties), 5)
89
- self.assertEqual(self.pbm_default.param_groups[1]['mu'], 0.5)
90
- self.assertEqual(self.pbm_default.param_groups[1]['pbf'], 'quadratic_reciprocal')
91
- self.assertEqual(self.pbm_default.param_groups[1]['lr'], 0.1)
92
-
93
- if __name__ == '__main__':
94
- unittest.main()