globoptiml 0.1.0__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.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: globoptiml
3
+ Version: 0.1.0
4
+ Summary: Train neural networks via global mathematical optimisation (MINLP) instead of gradient descent.
5
+ Author: Krzysztof
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pyomo
11
+ Provides-Extra: pytorch
12
+ Requires-Dist: torch; extra == "pytorch"
13
+ Provides-Extra: all
14
+ Requires-Dist: torch; extra == "all"
15
+ Provides-Extra: dev
16
+ Requires-Dist: torch; extra == "dev"
17
+ Requires-Dist: scikit-learn; extra == "dev"
18
+ Requires-Dist: pytest; extra == "dev"
19
+
20
+ # OptiML
21
+
22
+ Train neural networks via **global mathematical optimisation** (MINLP) instead of gradient descent.
23
+
24
+ OptiML translates a neural network architecture into a system of constraints and decision variables,
25
+ then uses a MINLP solver (e.g. Couenne, SCIP) to find the weights that *provably* minimise the
26
+ training loss. After solving, the model can be exported to PyTorch for inference.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install -e . # core (numpy + pyomo)
32
+ pip install -e ".[pytorch]" # with PyTorch export support
33
+ pip install -e ".[all]" # everything
34
+ ```
35
+
36
+ You also need a MINLP solver accessible to Pyomo, for example
37
+ [Couenne](https://github.com/coin-or/Couenne) or [SCIP](https://www.scipopt.org/).
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ import optiml
43
+ from optiml.losses import MSELoss
44
+
45
+ # Ultra-small Edge AI classifier — only 9 parameters
46
+ model = optiml.Sequential(
47
+ optiml.Linear(2, 2),
48
+ optiml.ReLU(M=10),
49
+ optiml.Linear(2, 1),
50
+ )
51
+
52
+ model.fit(X_train, y_train, loss=MSELoss(reduction='sum'), solver='couenne')
53
+
54
+ # Export to PyTorch for inference / deployment
55
+ pytorch_model = model.export('pytorch')
56
+ ```
57
+
58
+ ## Available layers
59
+
60
+ | Layer | Description |
61
+ |-------|-------------|
62
+ | `Linear(in, out)` | Fully-connected layer |
63
+ | `Conv1D(in_ch, out_ch, kernel)` | 1-D convolution |
64
+ | `Conv2D(in_ch, out_ch, kernel)` | 2-D convolution |
65
+ | `ReLU(M)` | ReLU via big-M formulation |
66
+ | `AvgPool2D(kernel)` | 2-D average pooling |
67
+ | `Flatten()` | Flatten spatial dimensions |
68
+
69
+ ## Available losses
70
+
71
+ | Loss | Description |
72
+ |------|-------------|
73
+ | `MSELoss(reduction)` | Mean / sum of squared errors |
74
+ | `SSELoss()` | Sum of squared errors |
75
+ | `MAELoss(reduction)` | Mean / sum of absolute errors |
76
+ | `HuberLoss(delta)` | Smooth L1 loss |
77
+
78
+ ## Export
79
+
80
+ After fitting, call `model.export('pytorch')` to get a `torch.nn.Sequential`
81
+ with the optimal weights loaded.
82
+
83
+ ## Example
84
+
85
+ See `examples/binary_classification.py` for a full working example.
86
+ It trains a 9-parameter Iris flower classifier (versicolor vs virginica
87
+ from petal measurements) and compares OptiML with PyTorch + Adam:
88
+
89
+ - **OptiML** finds the mathematically optimal weights in **~7 s**,
90
+ achieving **93.3 % test accuracy** on 90 unseen samples.
91
+ - **PyTorch Adam** has a **30 % failure rate** (stuck at 50 % accuracy)
92
+ and even the best restart (selected by training loss) only reaches 91.1 %.
@@ -0,0 +1,73 @@
1
+ # OptiML
2
+
3
+ Train neural networks via **global mathematical optimisation** (MINLP) instead of gradient descent.
4
+
5
+ OptiML translates a neural network architecture into a system of constraints and decision variables,
6
+ then uses a MINLP solver (e.g. Couenne, SCIP) to find the weights that *provably* minimise the
7
+ training loss. After solving, the model can be exported to PyTorch for inference.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install -e . # core (numpy + pyomo)
13
+ pip install -e ".[pytorch]" # with PyTorch export support
14
+ pip install -e ".[all]" # everything
15
+ ```
16
+
17
+ You also need a MINLP solver accessible to Pyomo, for example
18
+ [Couenne](https://github.com/coin-or/Couenne) or [SCIP](https://www.scipopt.org/).
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ import optiml
24
+ from optiml.losses import MSELoss
25
+
26
+ # Ultra-small Edge AI classifier — only 9 parameters
27
+ model = optiml.Sequential(
28
+ optiml.Linear(2, 2),
29
+ optiml.ReLU(M=10),
30
+ optiml.Linear(2, 1),
31
+ )
32
+
33
+ model.fit(X_train, y_train, loss=MSELoss(reduction='sum'), solver='couenne')
34
+
35
+ # Export to PyTorch for inference / deployment
36
+ pytorch_model = model.export('pytorch')
37
+ ```
38
+
39
+ ## Available layers
40
+
41
+ | Layer | Description |
42
+ |-------|-------------|
43
+ | `Linear(in, out)` | Fully-connected layer |
44
+ | `Conv1D(in_ch, out_ch, kernel)` | 1-D convolution |
45
+ | `Conv2D(in_ch, out_ch, kernel)` | 2-D convolution |
46
+ | `ReLU(M)` | ReLU via big-M formulation |
47
+ | `AvgPool2D(kernel)` | 2-D average pooling |
48
+ | `Flatten()` | Flatten spatial dimensions |
49
+
50
+ ## Available losses
51
+
52
+ | Loss | Description |
53
+ |------|-------------|
54
+ | `MSELoss(reduction)` | Mean / sum of squared errors |
55
+ | `SSELoss()` | Sum of squared errors |
56
+ | `MAELoss(reduction)` | Mean / sum of absolute errors |
57
+ | `HuberLoss(delta)` | Smooth L1 loss |
58
+
59
+ ## Export
60
+
61
+ After fitting, call `model.export('pytorch')` to get a `torch.nn.Sequential`
62
+ with the optimal weights loaded.
63
+
64
+ ## Example
65
+
66
+ See `examples/binary_classification.py` for a full working example.
67
+ It trains a 9-parameter Iris flower classifier (versicolor vs virginica
68
+ from petal measurements) and compares OptiML with PyTorch + Adam:
69
+
70
+ - **OptiML** finds the mathematically optimal weights in **~7 s**,
71
+ achieving **93.3 % test accuracy** on 90 unseen samples.
72
+ - **PyTorch Adam** has a **30 % failure rate** (stuck at 50 % accuracy)
73
+ and even the best restart (selected by training loss) only reaches 91.1 %.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: globoptiml
3
+ Version: 0.1.0
4
+ Summary: Train neural networks via global mathematical optimisation (MINLP) instead of gradient descent.
5
+ Author: Krzysztof
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy
10
+ Requires-Dist: pyomo
11
+ Provides-Extra: pytorch
12
+ Requires-Dist: torch; extra == "pytorch"
13
+ Provides-Extra: all
14
+ Requires-Dist: torch; extra == "all"
15
+ Provides-Extra: dev
16
+ Requires-Dist: torch; extra == "dev"
17
+ Requires-Dist: scikit-learn; extra == "dev"
18
+ Requires-Dist: pytest; extra == "dev"
19
+
20
+ # OptiML
21
+
22
+ Train neural networks via **global mathematical optimisation** (MINLP) instead of gradient descent.
23
+
24
+ OptiML translates a neural network architecture into a system of constraints and decision variables,
25
+ then uses a MINLP solver (e.g. Couenne, SCIP) to find the weights that *provably* minimise the
26
+ training loss. After solving, the model can be exported to PyTorch for inference.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install -e . # core (numpy + pyomo)
32
+ pip install -e ".[pytorch]" # with PyTorch export support
33
+ pip install -e ".[all]" # everything
34
+ ```
35
+
36
+ You also need a MINLP solver accessible to Pyomo, for example
37
+ [Couenne](https://github.com/coin-or/Couenne) or [SCIP](https://www.scipopt.org/).
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ import optiml
43
+ from optiml.losses import MSELoss
44
+
45
+ # Ultra-small Edge AI classifier — only 9 parameters
46
+ model = optiml.Sequential(
47
+ optiml.Linear(2, 2),
48
+ optiml.ReLU(M=10),
49
+ optiml.Linear(2, 1),
50
+ )
51
+
52
+ model.fit(X_train, y_train, loss=MSELoss(reduction='sum'), solver='couenne')
53
+
54
+ # Export to PyTorch for inference / deployment
55
+ pytorch_model = model.export('pytorch')
56
+ ```
57
+
58
+ ## Available layers
59
+
60
+ | Layer | Description |
61
+ |-------|-------------|
62
+ | `Linear(in, out)` | Fully-connected layer |
63
+ | `Conv1D(in_ch, out_ch, kernel)` | 1-D convolution |
64
+ | `Conv2D(in_ch, out_ch, kernel)` | 2-D convolution |
65
+ | `ReLU(M)` | ReLU via big-M formulation |
66
+ | `AvgPool2D(kernel)` | 2-D average pooling |
67
+ | `Flatten()` | Flatten spatial dimensions |
68
+
69
+ ## Available losses
70
+
71
+ | Loss | Description |
72
+ |------|-------------|
73
+ | `MSELoss(reduction)` | Mean / sum of squared errors |
74
+ | `SSELoss()` | Sum of squared errors |
75
+ | `MAELoss(reduction)` | Mean / sum of absolute errors |
76
+ | `HuberLoss(delta)` | Smooth L1 loss |
77
+
78
+ ## Export
79
+
80
+ After fitting, call `model.export('pytorch')` to get a `torch.nn.Sequential`
81
+ with the optimal weights loaded.
82
+
83
+ ## Example
84
+
85
+ See `examples/binary_classification.py` for a full working example.
86
+ It trains a 9-parameter Iris flower classifier (versicolor vs virginica
87
+ from petal measurements) and compares OptiML with PyTorch + Adam:
88
+
89
+ - **OptiML** finds the mathematically optimal weights in **~7 s**,
90
+ achieving **93.3 % test accuracy** on 90 unseen samples.
91
+ - **PyTorch Adam** has a **30 % failure rate** (stuck at 50 % accuracy)
92
+ and even the best restart (selected by training loss) only reaches 91.1 %.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ globoptiml.egg-info/PKG-INFO
4
+ globoptiml.egg-info/SOURCES.txt
5
+ globoptiml.egg-info/dependency_links.txt
6
+ globoptiml.egg-info/requires.txt
7
+ globoptiml.egg-info/top_level.txt
8
+ optiml/__init__.py
9
+ optiml/layers.py
10
+ optiml/losses.py
11
+ optiml/model.py
12
+ optiml/solver.py
@@ -0,0 +1,13 @@
1
+ numpy
2
+ pyomo
3
+
4
+ [all]
5
+ torch
6
+
7
+ [dev]
8
+ torch
9
+ scikit-learn
10
+ pytest
11
+
12
+ [pytorch]
13
+ torch
@@ -0,0 +1 @@
1
+ optiml
@@ -0,0 +1,30 @@
1
+ """OptiML — train neural networks via global mathematical optimisation."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .layers import (
6
+ OptiModule,
7
+ Linear,
8
+ ReLU,
9
+ Conv1D,
10
+ Conv2D,
11
+ AvgPool2D,
12
+ Flatten,
13
+ )
14
+ from .model import Sequential
15
+ from .solver import SolverVariable, SolverModel
16
+ from . import losses
17
+
18
+ __all__ = [
19
+ "OptiModule",
20
+ "Sequential",
21
+ "Linear",
22
+ "ReLU",
23
+ "Conv1D",
24
+ "Conv2D",
25
+ "AvgPool2D",
26
+ "Flatten",
27
+ "SolverVariable",
28
+ "SolverModel",
29
+ "losses",
30
+ ]
@@ -0,0 +1,213 @@
1
+ import numpy as np
2
+ import pyomo.environ as pyo
3
+
4
+ from .solver import SolverVariable
5
+
6
+
7
+ class OptiModule:
8
+ """Base class for all OptiML layers."""
9
+
10
+ def __init__(self):
11
+ self._parameters = {}
12
+ self._weight_bounds = None
13
+
14
+ def get_parameter(self, name, shape, solver_model):
15
+ if name not in self._parameters:
16
+ unique_name = f"layer_{id(self)}_{name}"
17
+ self._parameters[name] = solver_model.create_variable(
18
+ shape, name=unique_name, bounds=self._weight_bounds,
19
+ )
20
+ return self._parameters[name]
21
+
22
+ def forward(self, x, solver_model):
23
+ raise NotImplementedError
24
+
25
+ def __call__(self, x, solver_model):
26
+ return self.forward(x, solver_model)
27
+
28
+ def _extract_weights(self, param_name):
29
+ param_solver_var = self._parameters[param_name]
30
+ arr = np.zeros(param_solver_var.shape)
31
+ for idx, var in np.ndenumerate(param_solver_var.data):
32
+ arr[idx] = pyo.value(var)
33
+ return arr
34
+
35
+ def to_pytorch(self):
36
+ raise NotImplementedError(
37
+ f"PyTorch export is not implemented for {self.__class__.__name__}"
38
+ )
39
+
40
+
41
+ class Linear(OptiModule):
42
+ """Fully-connected layer: y = Wx + b."""
43
+
44
+ def __init__(self, in_features, out_features):
45
+ super().__init__()
46
+ self.in_features = in_features
47
+ self.out_features = out_features
48
+
49
+ def forward(self, x, solver_model):
50
+ W = self.get_parameter('weight', (self.out_features, self.in_features), solver_model)
51
+ b = self.get_parameter('bias', (self.out_features,), solver_model)
52
+ out = solver_model.create_variable(shape=(self.out_features,))
53
+ solver_model.add_constraint(out == W @ x + b)
54
+ return out
55
+
56
+ def to_pytorch(self):
57
+ import torch
58
+ import torch.nn as nn
59
+ layer = nn.Linear(self.in_features, self.out_features)
60
+ layer.weight.data = torch.tensor(self._extract_weights('weight'), dtype=torch.float32)
61
+ layer.bias.data = torch.tensor(self._extract_weights('bias'), dtype=torch.float32)
62
+ return layer
63
+
64
+
65
+ class ReLU(OptiModule):
66
+ """ReLU activation modelled via big-M with binary variables."""
67
+
68
+ def __init__(self, M=1000):
69
+ super().__init__()
70
+ self.M = M
71
+
72
+ def forward(self, x, solver_model):
73
+ shape = x.shape if hasattr(x, 'shape') else (1,)
74
+ y = solver_model.create_variable(shape=shape)
75
+ z = solver_model.create_variable(shape=shape, boolean=True)
76
+
77
+ solver_model.add_constraint(y >= 0)
78
+ solver_model.add_constraint(y >= x)
79
+ solver_model.add_constraint(y <= x + self.M * (1 - z))
80
+ solver_model.add_constraint(y <= self.M * z)
81
+ return y
82
+
83
+ def to_pytorch(self):
84
+ import torch.nn as nn
85
+ return nn.ReLU()
86
+
87
+
88
+ class Conv1D(OptiModule):
89
+ """1-D convolution layer."""
90
+
91
+ def __init__(self, in_channels, out_channels, kernel_size, stride=1):
92
+ super().__init__()
93
+ self.in_channels = in_channels
94
+ self.out_channels = out_channels
95
+ self.kernel_size = kernel_size
96
+ self.stride = stride
97
+
98
+ def forward(self, x, solver_model):
99
+ W = self.get_parameter('weight', (self.out_channels, self.in_channels, self.kernel_size), solver_model)
100
+ b = self.get_parameter('bias', (self.out_channels,), solver_model)
101
+ in_channels, length = x.shape
102
+ out_length = (length - self.kernel_size) // self.stride + 1
103
+ out = solver_model.create_variable(shape=(self.out_channels, out_length))
104
+
105
+ for out_pos in range(out_length):
106
+ in_start = out_pos * self.stride
107
+ in_end = in_start + self.kernel_size
108
+ x_slice = x[:, in_start:in_end]
109
+ for oc in range(self.out_channels):
110
+ conv_sum = np.sum((W[oc, :, :] * x_slice).data) + b[oc].data
111
+ solver_model.add_constraint(out[oc, out_pos] == conv_sum)
112
+ return out
113
+
114
+ def to_pytorch(self):
115
+ import torch
116
+ import torch.nn as nn
117
+ layer = nn.Conv1d(self.in_channels, self.out_channels, self.kernel_size, stride=self.stride)
118
+ layer.weight.data = torch.tensor(self._extract_weights('weight'), dtype=torch.float32)
119
+ layer.bias.data = torch.tensor(self._extract_weights('bias'), dtype=torch.float32)
120
+ return layer
121
+
122
+
123
+ class Conv2D(OptiModule):
124
+ """2-D convolution layer."""
125
+
126
+ def __init__(self, in_channels, out_channels, kernel_size, stride=1):
127
+ super().__init__()
128
+ self.in_channels = in_channels
129
+ self.out_channels = out_channels
130
+ self.kernel_size = kernel_size if isinstance(kernel_size, tuple) else (kernel_size, kernel_size)
131
+ self.stride = stride if isinstance(stride, tuple) else (stride, stride)
132
+
133
+ def forward(self, x, solver_model):
134
+ kh, kw = self.kernel_size
135
+ sh, sw = self.stride
136
+ W = self.get_parameter('weight', (self.out_channels, self.in_channels, kh, kw), solver_model)
137
+ b = self.get_parameter('bias', (self.out_channels,), solver_model)
138
+ in_channels, in_height, in_width = x.shape
139
+ out_height = (in_height - kh) // sh + 1
140
+ out_width = (in_width - kw) // sw + 1
141
+ out = solver_model.create_variable(shape=(self.out_channels, out_height, out_width))
142
+
143
+ for out_h in range(out_height):
144
+ for out_w in range(out_width):
145
+ h_start = out_h * sh
146
+ h_end = h_start + kh
147
+ w_start = out_w * sw
148
+ w_end = w_start + kw
149
+ x_slice = x[:, h_start:h_end, w_start:w_end]
150
+ for oc in range(self.out_channels):
151
+ conv_sum = np.sum((W[oc, :, :, :] * x_slice).data) + b[oc].data
152
+ solver_model.add_constraint(out[oc, out_h, out_w] == conv_sum)
153
+ return out
154
+
155
+ def to_pytorch(self):
156
+ import torch
157
+ import torch.nn as nn
158
+ layer = nn.Conv2d(self.in_channels, self.out_channels, self.kernel_size, stride=self.stride)
159
+ layer.weight.data = torch.tensor(self._extract_weights('weight'), dtype=torch.float32)
160
+ layer.bias.data = torch.tensor(self._extract_weights('bias'), dtype=torch.float32)
161
+ return layer
162
+
163
+
164
+ class AvgPool2D(OptiModule):
165
+ """2-D average pooling layer."""
166
+
167
+ def __init__(self, kernel_size, stride=None):
168
+ super().__init__()
169
+ self.kernel_size = kernel_size if isinstance(kernel_size, tuple) else (kernel_size, kernel_size)
170
+ if stride is None:
171
+ self.stride = self.kernel_size
172
+ else:
173
+ self.stride = stride if isinstance(stride, tuple) else (stride, stride)
174
+
175
+ def forward(self, x, solver_model):
176
+ kh, kw = self.kernel_size
177
+ sh, sw = self.stride
178
+ channels, in_height, in_width = x.shape
179
+ out_height = (in_height - kh) // sh + 1
180
+ out_width = (in_width - kw) // sw + 1
181
+ out = solver_model.create_variable(shape=(channels, out_height, out_width))
182
+ pool_area = kh * kw
183
+
184
+ for c in range(channels):
185
+ for out_h in range(out_height):
186
+ for out_w in range(out_width):
187
+ h_start = out_h * sh
188
+ h_end = h_start + kh
189
+ w_start = out_w * sw
190
+ w_end = w_start + kw
191
+ x_slice = x[c, h_start:h_end, w_start:w_end]
192
+ avg_val = np.sum(x_slice.data) / pool_area
193
+ solver_model.add_constraint(out[c, out_h, out_w] == avg_val)
194
+ return out
195
+
196
+ def to_pytorch(self):
197
+ import torch.nn as nn
198
+ return nn.AvgPool2d(self.kernel_size, stride=self.stride)
199
+
200
+
201
+ class Flatten(OptiModule):
202
+ """Flatten all spatial dimensions into a single vector."""
203
+
204
+ def __init__(self):
205
+ super().__init__()
206
+
207
+ def forward(self, x, solver_model):
208
+ flat_data = x.data.flatten()
209
+ return SolverVariable(flat_data)
210
+
211
+ def to_pytorch(self):
212
+ import torch.nn as nn
213
+ return nn.Flatten(start_dim=1)
@@ -0,0 +1,124 @@
1
+ """Loss functions expressible as Pyomo optimisation objectives.
2
+
3
+ All losses support both scalar outputs (single-target regression /
4
+ binary classification) and vector outputs (multi-target regression /
5
+ multi-class classification with one-hot targets).
6
+ """
7
+
8
+ import numpy as np
9
+
10
+
11
+ class Loss:
12
+ """Base class for all OptiML losses."""
13
+
14
+ def __call__(self, y_pred, y_true, solver_model=None):
15
+ return self.compute(y_pred, y_true, solver_model)
16
+
17
+ def compute(self, y_pred, y_true, solver_model=None):
18
+ raise NotImplementedError
19
+
20
+
21
+ def _squared_error_terms(predictions, targets):
22
+ """Yield (p - t)**2 for every scalar in every sample."""
23
+ for pred, target in zip(predictions, targets):
24
+ p_flat = pred.data.flatten()
25
+ t_flat = np.atleast_1d(target).flatten()
26
+ for p_val, t_val in zip(p_flat, t_flat):
27
+ yield (p_val - t_val) ** 2
28
+
29
+
30
+ class MSELoss(Loss):
31
+ """Mean Squared Error: (1/n) * sum((y_pred - y_true)^2)."""
32
+
33
+ def __init__(self, reduction='mean'):
34
+ self.reduction = reduction
35
+
36
+ def compute(self, predictions, targets, solver_model=None):
37
+ total = sum(_squared_error_terms(predictions, targets))
38
+ if self.reduction == 'mean':
39
+ return total / len(predictions)
40
+ return total
41
+
42
+
43
+ class SSELoss(Loss):
44
+ """Sum of Squared Errors: sum((y_pred - y_true)^2).
45
+
46
+ Equivalent to MSELoss(reduction='sum') but more explicit.
47
+ """
48
+
49
+ def compute(self, predictions, targets, solver_model=None):
50
+ return sum(_squared_error_terms(predictions, targets))
51
+
52
+
53
+ class MAELoss(Loss):
54
+ """Mean Absolute Error modelled with auxiliary variables.
55
+
56
+ Introduces |e| = y_pred - y_true via:
57
+ t >= y_pred - y_true
58
+ t >= -(y_pred - y_true)
59
+ then minimises sum(t).
60
+ Requires solver_model to create auxiliary variables/constraints.
61
+ """
62
+
63
+ def __init__(self, reduction='mean'):
64
+ self.reduction = reduction
65
+
66
+ def compute(self, predictions, targets, solver_model=None):
67
+ if solver_model is None:
68
+ raise ValueError("MAELoss requires a solver_model to create auxiliary variables")
69
+
70
+ total_expr = 0
71
+ for pred, target in zip(predictions, targets):
72
+ p_flat = pred.data.flatten()
73
+ t_flat = np.atleast_1d(target).flatten()
74
+ for p_val, t_val in zip(p_flat, t_flat):
75
+ diff = p_val - t_val
76
+ t = solver_model.create_variable(shape=(1,))
77
+ solver_model.add_constraint(t >= diff)
78
+ solver_model.add_constraint(t >= -diff)
79
+ total_expr = total_expr + t.data.item()
80
+
81
+ if self.reduction == 'mean':
82
+ return total_expr / len(predictions)
83
+ return total_expr
84
+
85
+
86
+ class HuberLoss(Loss):
87
+ """Huber loss (smooth L1) modelled with auxiliary variables.
88
+
89
+ For each scalar output element:
90
+ if |error| <= delta: loss = 0.5 * error^2
91
+ else: loss = delta * (|error| - 0.5 * delta)
92
+
93
+ Requires solver_model to create auxiliary variables/constraints.
94
+ """
95
+
96
+ def __init__(self, delta=1.0, reduction='mean'):
97
+ self.delta = delta
98
+ self.reduction = reduction
99
+
100
+ def compute(self, predictions, targets, solver_model=None):
101
+ if solver_model is None:
102
+ raise ValueError("HuberLoss requires a solver_model to create auxiliary variables")
103
+
104
+ total_expr = 0
105
+ d = self.delta
106
+ for pred, target in zip(predictions, targets):
107
+ p_flat = pred.data.flatten()
108
+ t_flat = np.atleast_1d(target).flatten()
109
+ for p_val, t_val in zip(p_flat, t_flat):
110
+ diff = p_val - t_val
111
+ quad = 0.5 * diff ** 2
112
+ t_abs = solver_model.create_variable(shape=(1,))
113
+ solver_model.add_constraint(t_abs >= diff)
114
+ solver_model.add_constraint(t_abs >= -diff)
115
+ linear = d * t_abs.data.item() - 0.5 * d ** 2
116
+
117
+ h = solver_model.create_variable(shape=(1,))
118
+ solver_model.add_constraint(h >= quad)
119
+ solver_model.add_constraint(h >= linear)
120
+ total_expr = total_expr + h.data.item()
121
+
122
+ if self.reduction == 'mean':
123
+ return total_expr / len(predictions)
124
+ return total_expr
@@ -0,0 +1,194 @@
1
+ """High-level Sequential model that wraps layer definition, fitting, and export."""
2
+
3
+ from collections import OrderedDict
4
+
5
+ import numpy as np
6
+ import pyomo.environ as pyo
7
+
8
+ from .solver import SolverModel, SolverVariable
9
+ from .layers import OptiModule
10
+ from .losses import Loss, MSELoss
11
+
12
+
13
+ class Sequential(OptiModule):
14
+ """An ordered container of OptiML layers with fit/export capabilities.
15
+
16
+ Usage::
17
+
18
+ import optiml
19
+
20
+ model = optiml.Sequential(
21
+ optiml.Linear(2, 2),
22
+ optiml.ReLU(M=10),
23
+ optiml.Linear(2, 1),
24
+ )
25
+
26
+ model.fit(X_train, y_train, solver='couenne')
27
+ pytorch_model = model.export('pytorch')
28
+ """
29
+
30
+ def __init__(self, *layers):
31
+ super().__init__()
32
+ self._layers = OrderedDict()
33
+ for i, layer in enumerate(layers):
34
+ name = f"{layer.__class__.__name__.lower()}_{i}"
35
+ self._layers[name] = layer
36
+ setattr(self, name, layer)
37
+
38
+ self._solver_model = None
39
+ self._fitted = False
40
+
41
+ def forward(self, x, solver_model):
42
+ out = x
43
+ for layer in self._layers.values():
44
+ out = layer(out, solver_model)
45
+ return out
46
+
47
+ def fit(
48
+ self,
49
+ X,
50
+ y,
51
+ loss=None,
52
+ solver='couenne',
53
+ weight_decay=0.0,
54
+ weight_bounds=None,
55
+ time_limit=None,
56
+ node_limit=None,
57
+ tee=True,
58
+ verbose=True,
59
+ ):
60
+ """Build the optimisation model from data and solve it.
61
+
62
+ Parameters
63
+ ----------
64
+ X : array-like, shape (n_samples, ...)
65
+ Training inputs (without batch dimension per sample).
66
+ y : array-like, shape (n_samples,) or (n_samples, n_outputs)
67
+ Training targets.
68
+ loss : Loss, optional
69
+ Loss function to minimise. Defaults to ``MSELoss(reduction='sum')``.
70
+ solver : str
71
+ Name of a Pyomo-compatible MINLP solver (e.g. ``'couenne'``, ``'scip'``).
72
+ weight_decay : float
73
+ L2 regularisation coefficient. Adds ``weight_decay * sum(w²)``
74
+ to the objective. Prevents over-fitting and encourages small
75
+ weights suitable for quantisation on micro-controllers.
76
+ weight_bounds : tuple[float, float] or None
77
+ If given, bound every trainable weight to ``(lo, hi)``.
78
+ Tightens the solver relaxation and prevents degenerate solutions.
79
+ Also useful for fixed-point quantisation on edge devices.
80
+ time_limit : float or None
81
+ Maximum solver wall-clock time in seconds. The solver returns
82
+ the best feasible solution found within the time budget.
83
+ tee : bool
84
+ Whether to print solver output.
85
+ verbose : bool
86
+ Whether to print progress messages.
87
+
88
+ Returns
89
+ -------
90
+ results : SolverResults
91
+ Pyomo solver results object.
92
+ """
93
+ if loss is None:
94
+ loss = MSELoss(reduction='sum')
95
+
96
+ X = np.asarray(X)
97
+ y = np.asarray(y)
98
+
99
+ if weight_bounds is not None:
100
+ for layer in self._layers.values():
101
+ layer._weight_bounds = weight_bounds
102
+
103
+ self._solver_model = SolverModel()
104
+ sm = self._solver_model
105
+
106
+ if verbose:
107
+ print(f"[OptiML] Building constraints for {len(X)} samples...")
108
+
109
+ predictions = []
110
+ for i in range(len(X)):
111
+ x_input = X[i]
112
+ if not isinstance(x_input, SolverVariable):
113
+ x_input = SolverVariable(x_input)
114
+ y_pred = self.forward(x_input, sm)
115
+ predictions.append(y_pred)
116
+
117
+ if verbose:
118
+ n_vars = sm._var_counter
119
+ n_cons = len(sm.model.constraints)
120
+ print(f"[OptiML] Model has {n_vars} variable groups, {n_cons} constraints.")
121
+ print(f"[OptiML] Computing {loss.__class__.__name__}...")
122
+
123
+ objective_expr = loss(predictions, y, sm)
124
+
125
+ if weight_decay > 0:
126
+ reg = 0
127
+ for layer in self._layers.values():
128
+ for param_sv in layer._parameters.values():
129
+ for val in param_sv.data.flatten():
130
+ reg += val ** 2
131
+ objective_expr += weight_decay * reg
132
+ if verbose:
133
+ print(f"[OptiML] Added L2 regularisation (λ={weight_decay})")
134
+
135
+ sm.set_objective(objective_expr, minimize=True)
136
+
137
+ if verbose:
138
+ print(f"[OptiML] Solving with '{solver}'...")
139
+
140
+ results = sm.solve(solver_name=solver, tee=tee, time_limit=time_limit,
141
+ node_limit=node_limit)
142
+ self._fitted = True
143
+
144
+ if verbose:
145
+ try:
146
+ obj_val = pyo.value(sm.model.obj)
147
+ print(f"[OptiML] Solved. Objective value: {obj_val:.8f}")
148
+ except Exception:
149
+ print("[OptiML] Solved. (could not read objective value)")
150
+
151
+ return results
152
+
153
+ @property
154
+ def objective_value(self):
155
+ """Return the objective value after fitting."""
156
+ if not self._fitted:
157
+ raise RuntimeError("Model has not been fitted yet. Call .fit() first.")
158
+ return pyo.value(self._solver_model.model.obj)
159
+
160
+ def export(self, backend='pytorch'):
161
+ """Export the fitted model to a deep-learning framework.
162
+
163
+ Parameters
164
+ ----------
165
+ backend : str
166
+ Target framework. Currently only ``'pytorch'`` is supported.
167
+
168
+ Returns
169
+ -------
170
+ model
171
+ A native model in the target framework with weights loaded
172
+ from the optimisation solution.
173
+ """
174
+ if not self._fitted:
175
+ raise RuntimeError("Model has not been fitted yet. Call .fit() first.")
176
+
177
+ if backend == 'pytorch':
178
+ return self._export_pytorch()
179
+ raise ValueError(f"Unknown backend '{backend}'. Supported: 'pytorch'")
180
+
181
+ def _export_pytorch(self):
182
+ import torch.nn as nn
183
+
184
+ pytorch_layers = []
185
+ for name, layer in self._layers.items():
186
+ pytorch_layers.append((name, layer.to_pytorch()))
187
+ return nn.Sequential(OrderedDict(pytorch_layers))
188
+
189
+ def __repr__(self):
190
+ lines = [f"Sequential("]
191
+ for name, layer in self._layers.items():
192
+ lines.append(f" ({name}): {layer.__class__.__name__}()")
193
+ lines.append(")")
194
+ return "\n".join(lines)
@@ -0,0 +1,121 @@
1
+ import pyomo.environ as pyo
2
+ import numpy as np
3
+ import operator
4
+
5
+
6
+ class SolverVariable:
7
+ """Array-like wrapper over Pyomo decision variables supporting arithmetic."""
8
+
9
+ def __init__(self, data):
10
+ self.data = np.array(data, dtype=object) if not isinstance(data, np.ndarray) else data
11
+ self.shape = self.data.shape
12
+
13
+ def __add__(self, other):
14
+ other_data = other.data if isinstance(other, SolverVariable) else other
15
+ return SolverVariable(self.data + other_data)
16
+
17
+ def __radd__(self, other):
18
+ return self.__add__(other)
19
+
20
+ def __sub__(self, other):
21
+ other_data = other.data if isinstance(other, SolverVariable) else other
22
+ return SolverVariable(self.data - other_data)
23
+
24
+ def __rsub__(self, other):
25
+ other_data = other.data if isinstance(other, SolverVariable) else other
26
+ return SolverVariable(other_data - self.data)
27
+
28
+ def __matmul__(self, other):
29
+ other_data = other.data if isinstance(other, SolverVariable) else other
30
+ return SolverVariable(np.dot(self.data, other_data))
31
+
32
+ def __mul__(self, other):
33
+ other_data = other.data if isinstance(other, SolverVariable) else other
34
+ return SolverVariable(self.data * other_data)
35
+
36
+ def __rmul__(self, other):
37
+ return self.__mul__(other)
38
+
39
+ def __pow__(self, power):
40
+ if isinstance(power, (int, float)):
41
+ flat = self.data.flatten()
42
+ result = np.empty(len(flat), dtype=object)
43
+ for i in range(len(flat)):
44
+ result[i] = flat[i] ** power
45
+ return SolverVariable(result.reshape(self.shape))
46
+ raise NotImplementedError("Only scalar powers are supported")
47
+
48
+ def _apply_relational(self, other, op):
49
+ other_data = other.data if isinstance(other, SolverVariable) else other
50
+ flat_self = self.data.flatten()
51
+ res_array = np.empty(len(flat_self), dtype=object)
52
+
53
+ if isinstance(other_data, np.ndarray):
54
+ flat_other = other_data.flatten()
55
+ for i in range(len(flat_self)):
56
+ res_array[i] = op(flat_self[i], flat_other[i])
57
+ else:
58
+ for i in range(len(flat_self)):
59
+ res_array[i] = op(flat_self[i], other_data)
60
+
61
+ return SolverVariable(res_array.reshape(self.shape))
62
+
63
+ def __eq__(self, other):
64
+ return self._apply_relational(other, operator.eq)
65
+
66
+ def __ge__(self, other):
67
+ return self._apply_relational(other, operator.ge)
68
+
69
+ def __le__(self, other):
70
+ return self._apply_relational(other, operator.le)
71
+
72
+ def __getitem__(self, key):
73
+ return SolverVariable(self.data[key])
74
+
75
+ def sum(self):
76
+ return np.sum(self.data)
77
+
78
+
79
+ class SolverModel:
80
+ """Pyomo ConcreteModel wrapper for building optimisation problems."""
81
+
82
+ def __init__(self):
83
+ self.model = pyo.ConcreteModel()
84
+ self.model.constraints = pyo.ConstraintList()
85
+ self._var_counter = 0
86
+
87
+ def create_variable(self, shape, boolean=False, name=None, bounds=None):
88
+ if name is None:
89
+ name = f"var_{self._var_counter}"
90
+ self._var_counter += 1
91
+
92
+ domain = pyo.Binary if boolean else pyo.Reals
93
+ flat_size = int(np.prod(shape)) if isinstance(shape, tuple) and shape else 1
94
+
95
+ pyo_var = pyo.Var(range(flat_size), domain=domain, bounds=bounds)
96
+ setattr(self.model, name, pyo_var)
97
+
98
+ arr = np.array([pyo_var[i] for i in range(flat_size)], dtype=object).reshape(shape)
99
+ return SolverVariable(arr)
100
+
101
+ def add_constraint(self, condition):
102
+ cond_data = condition.data if isinstance(condition, SolverVariable) else condition
103
+ if isinstance(cond_data, np.ndarray):
104
+ for cond in cond_data.flatten():
105
+ self.model.constraints.add(cond)
106
+ else:
107
+ self.model.constraints.add(cond_data)
108
+
109
+ def set_objective(self, expr, minimize=True):
110
+ sense = pyo.minimize if minimize else pyo.maximize
111
+ self.model.obj = pyo.Objective(expr=expr, sense=sense)
112
+
113
+ def solve(self, solver_name='couenne', tee=True, time_limit=None,
114
+ node_limit=None):
115
+ solver = pyo.SolverFactory(solver_name)
116
+ if time_limit is not None:
117
+ solver.options['bonmin.time_limit'] = time_limit
118
+ if node_limit is not None:
119
+ solver.options['bonmin.node_limit'] = node_limit
120
+ results = solver.solve(self.model, tee=tee)
121
+ return results
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "globoptiml"
7
+ version = "0.1.0"
8
+ description = "Train neural networks via global mathematical optimisation (MINLP) instead of gradient descent."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "Krzysztof"},
14
+ ]
15
+
16
+ dependencies = [
17
+ "numpy",
18
+ "pyomo",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ pytorch = ["torch"]
23
+ all = ["torch"]
24
+ dev = ["torch", "scikit-learn", "pytest"]
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["optiml*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+