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.
- globoptiml-0.1.0/PKG-INFO +92 -0
- globoptiml-0.1.0/README.md +73 -0
- globoptiml-0.1.0/globoptiml.egg-info/PKG-INFO +92 -0
- globoptiml-0.1.0/globoptiml.egg-info/SOURCES.txt +12 -0
- globoptiml-0.1.0/globoptiml.egg-info/dependency_links.txt +1 -0
- globoptiml-0.1.0/globoptiml.egg-info/requires.txt +13 -0
- globoptiml-0.1.0/globoptiml.egg-info/top_level.txt +1 -0
- globoptiml-0.1.0/optiml/__init__.py +30 -0
- globoptiml-0.1.0/optiml/layers.py +213 -0
- globoptiml-0.1.0/optiml/losses.py +124 -0
- globoptiml-0.1.0/optiml/model.py +194 -0
- globoptiml-0.1.0/optiml/solver.py +121 -0
- globoptiml-0.1.0/pyproject.toml +27 -0
- globoptiml-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|