Skip to content

Commit a8a0072

Browse files
authored
Merge pull request #2 from joshivanhoe/feat/optionally-combine-func-grad
Provide option to compute objective value and gradient in the same function
2 parents 794808f + 01eae33 commit a8a0072

File tree

7 files changed

+151
-60
lines changed

7 files changed

+151
-60
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
strategy:
1717
fail-fast: false
1818
matrix:
19-
python-version: ["3.10"]
19+
python-version: ["3.9", "3.10", "3.11"]
2020

2121
steps:
2222
- uses: actions/checkout@v3

.github/workflows/release.yml

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# This workflow will upload a Python Package using Twine when a release is created
2+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3+
4+
# This workflow uses actions that are not certified by GitHub.
5+
# They are provided by a third-party and are governed by
6+
# separate terms of service, privacy policy, and support
7+
# documentation.
8+
9+
name: Upload Python Package
10+
11+
on:
12+
release:
13+
types: [published]
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
deploy:
20+
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- uses: actions/checkout@v3
25+
- name: Set up Python
26+
uses: actions/setup-python@v3
27+
with:
28+
python-version: '3.11'
29+
- name: Install dependencies
30+
run: |
31+
python -m pip install --upgrade pip
32+
pip install build
33+
- name: Build package
34+
run: python -m build
35+
- name: Publish package
36+
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
37+
with:
38+
user: __token__
39+
password: ${{ secrets.PYPI_API_TOKEN }}

README.md

+9-18
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ from halfspace import Model
5353
model = Model()
5454

5555
# Define variables
56-
x = model.add_var(lb=0, ub=1) # add a variable
57-
y = model.add_var(var_type="B") # add a binary variable
58-
z = model.add_var_tensor(shape=(5,), lb=0, ub=1) # add a tensor of variables
56+
x = model.add_var(lb=0, ub=1, name="x") # add a variable
57+
y = model.add_var(var_type="B", name="y") # add a binary variable
58+
z = model.add_var_tensor(shape=(5,), lb=0, ub=1, name="z") # add a tensor of variables
5959

6060
# Define objective terms (these are summed to create the objective)
6161
model.add_objective_term(var=x, func=lambda x: (x - 1) ** 2) # add an objective term for one variable
@@ -77,7 +77,9 @@ model.start = [(x, 0), (y, 0)] + [(z[i], 0) for i in range(5)]
7777

7878
# Solve model
7979
status = model.optimize()
80-
print(status, model.objective_value)
80+
print(model.objective_value) # get the best objective value
81+
print(model.var_value(x)) # get the value of a variable directly
82+
print(model.var_value("y")) # get the value of a variable by name
8183
```
8284

8385
## Troubleshooting
@@ -115,20 +117,9 @@ Clone the repository using `git`:
115117
git clone https://github.com/joshivanhoe/halfspace
116118
````
117119

118-
Create a fresh virtual environment using `venv`:
119-
120-
```bash
121-
python3.10 -m venv halfspace
122-
```
123-
124-
Alternatively, this can be done using `conda`:
125-
126-
```bash
127-
conda create -n halfspace python=3.10
128-
```
129-
130-
Note that currently Python 3.10 is recommended.
131-
Activate the environment and navigate to the cloned `halfspace` directory. Install a locally editable version of the package using `pip`:
120+
Create a fresh virtual environment using `venv` or `conda`.
121+
Activate the environment and navigate to the cloned `halfspace` directory.
122+
Install a locally editable version of the package using `pip`:
132123

133124
```bash
134125
pip install -e .

pyproject.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "halfspace-optimizer"
7-
version = "0.0.3"
7+
version = "0.1.0"
88
authors = [
99
{ name="Joshua Ivanhoe", email="[email protected]" },
1010
]
1111
description = "Cutting-plane solver for mixed-integer convex optimization problems"
1212
readme = "README.md"
1313
license = {file = "LICENSE"}
14-
requires-python = ">=3.9"
14+
requires-python = ">=3.9,<3.12"
1515
classifiers = [
1616
"Programming Language :: Python :: 3",
1717
"License :: OSI Approved :: MIT License",

src/halfspace/convex_term.py

+22-12
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import numpy as np
55

66
QueryPoint = dict[mip.Var, float]
7-
Input = Union[float, Iterable[float], np.ndarray]
87
Var = Union[mip.Var, Iterable[mip.Var], mip.LinExprTensor]
8+
Input = Union[float, Iterable[float], np.ndarray]
99
Func = Callable[[Input], float]
10+
FuncWithGrad = Callable[[Input], tuple[float, Union[float, np.ndarray]]]
1011
Grad = Callable[[Input], Union[float, np.ndarray]]
1112

1213

@@ -16,8 +17,8 @@ class ConvexTerm:
1617
def __init__(
1718
self,
1819
var: Var,
19-
func: Func,
20-
grad: Optional[Grad] = None,
20+
func: Union[Func, FuncWithGrad],
21+
grad: Optional[Union[Grad, bool]] = None,
2122
step_size: float = 1e-6,
2223
name: str = "",
2324
):
@@ -27,14 +28,16 @@ def __init__(
2728
var: mip.Var or iterable of mip.Var or mip.LinExprTensor
2829
The variable(s) included in the term. This can be provided in the form of a single variable, an
2930
iterable of multiple variables or a variable tensor.
30-
func: callable mapping float(s) or array to float
31+
func: callable
3132
A function for computing the term's value. This function should except one argument for each
3233
variable in `var`. If `var` is a variable tensor, then the function should accept a single array.
33-
grad: callable mapping float(s) or array to float or array, default=`None`
34+
grad: callable or bool, default=`None`
3435
A function for computing the term's gradient. This function should except one argument for each
3536
variable in `var`. If `var` is a variable tensor, then the function should accept a single array. If
36-
`None`, then the gradient is approximated numerically.
37-
using the central finite difference method.
37+
`None`, then the gradient is approximated numerically using the central finite difference method. If
38+
`grad` is instead a Boolean and is `True`, then `func` is assumed to return a tuple where the first
39+
element is the function value and the second element is the gradient. This is useful when the gradient
40+
is expensive to compute.
3841
step_size: float, default=`1e-6`
3942
The step size used for numerical gradient approximation. If `grad` is provided, then this argument is
4043
ignored.
@@ -49,7 +52,7 @@ def __init__(
4952

5053
def __call__(
5154
self, query_point: QueryPoint, return_grad: bool = False
52-
) -> Union[float, tuple[float, np.ndarray]]:
55+
) -> Union[float, tuple[float, Union[float, np.ndarray]]]:
5356
"""Evaluate the term and (optionally) its gradient.
5457
5558
Args:
@@ -65,7 +68,9 @@ def __call__(
6568
"""
6669
x = self._get_input(query_point=query_point)
6770
value = self._evaluate_func(x=x)
68-
if return_grad:
71+
if self.grad is True and not return_grad:
72+
return value[0]
73+
elif self.grad is not True and return_grad:
6974
return value, self._evaluate_grad(x=x)
7075
return value
7176

@@ -95,8 +100,13 @@ def _get_input(self, query_point: QueryPoint) -> Input:
95100
return np.array([query_point[var] for var in self.var])
96101
return query_point[self.var]
97102

98-
def _evaluate_func(self, x: Input) -> float:
99-
"""Evaluate the function value."""
103+
def _evaluate_func(
104+
self, x: Input
105+
) -> Union[float, tuple[float, Union[float, np.ndarray]]]:
106+
"""Evaluate the function value.
107+
108+
If `grad=True`, then both the value of the function and it's gradient are returned.
109+
"""
100110
if isinstance(self.var, (mip.Var, mip.LinExprTensor)):
101111
return self.func(x)
102112
if isinstance(self.var, Iterable):
@@ -105,7 +115,7 @@ def _evaluate_func(self, x: Input) -> float:
105115

106116
def _evaluate_grad(self, x: Input) -> Union[float, np.ndarray]:
107117
"""Evaluate the gradient."""
108-
if self.grad is None:
118+
if not self.grad:
109119
return self._approximate_grad(x=x)
110120
if isinstance(self.var, (mip.Var, mip.LinExprTensor)):
111121
return self.grad(x)

src/halfspace/model.py

+20-14
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import numpy as np
66
import pandas as pd
77

8-
from .convex_term import ConvexTerm, Input, Var, Func, Grad
8+
from .convex_term import ConvexTerm, Input, Var, Func, FuncWithGrad, Grad
99
from .utils import check_scalar, log_table_header, log_table_row
1010

1111
Start = list[tuple[mip.Var, float]]
@@ -155,8 +155,8 @@ def add_linear_constr(self, constraint: mip.LinExpr, name: str = "") -> mip.Cons
155155
def add_nonlinear_constr(
156156
self,
157157
var: Var,
158-
func: Func,
159-
grad: Optional[Grad] = None,
158+
func: Union[Func, FuncWithGrad],
159+
grad: Optional[Union[Grad, bool]] = None,
160160
name: str = "",
161161
) -> ConvexTerm:
162162
"""Add a nonlinear constraint to the model.
@@ -165,14 +165,16 @@ def add_nonlinear_constr(
165165
var: mip.Var or iterable of mip.Var or mip.LinExprTensor
166166
The variable(s) included in the term. This can be provided in the form of a single variable, an
167167
iterable of multiple variables or a variable tensor.
168-
func: callable mapping float(s) or array to float
168+
func: callable
169169
A function for computing the term's value. This function should except one argument for each
170170
variable in `var`. If `var` is a variable tensor, then the function should accept a single array.
171-
grad: callable mapping float(s) or array to float or array, default=`None`
171+
grad: callable or bool, default=`None`
172172
A function for computing the term's gradient. This function should except one argument for each
173173
variable in `var`. If `var` is a variable tensor, then the function should accept a single array. If
174-
`None`, then the gradient is approximated numerically.
175-
using the central finite difference method.
174+
`None`, then the gradient is approximated numerically using the central finite difference method. If
175+
`grad` is instead a Boolean and is `True`, then `func` is assumed to return a tuple where the first
176+
element is the function value and the second element is the gradient. This is useful when the gradient
177+
is expensive to compute.
176178
name: str, default=''
177179
The name of the constraint.
178180
@@ -192,8 +194,8 @@ def add_nonlinear_constr(
192194
def add_objective_term(
193195
self,
194196
var: Var,
195-
func: Func,
196-
grad: Optional[Grad] = None,
197+
func: Union[Func, FuncWithGrad],
198+
grad: Optional[Union[Grad, bool]] = None,
197199
name: str = "",
198200
) -> ConvexTerm:
199201
"""Add an objective term to the model.
@@ -202,14 +204,16 @@ def add_objective_term(
202204
var: mip.Var or iterable of mip.Var or mip.LinExprTensor
203205
The variable(s) included in the term. This can be provided in the form of a single variable, an
204206
iterable of multiple variables or a variable tensor.
205-
func: callable mapping float(s) or array to float
207+
func: callable
206208
A function for computing the term's value. This function should except one argument for each
207209
variable in `var`. If `var` is a variable tensor, then the function should accept a single array.
208-
grad: callable mapping float(s) or array to float or array, default=`None`
210+
grad: callable or bool, default=`None`
209211
A function for computing the term's gradient. This function should except one argument for each
210212
variable in `var`. If `var` is a variable tensor, then the function should accept a single array. If
211-
`None`, then the gradient is approximated numerically.
212-
using the central finite difference method.
213+
`None`, then the gradient is approximated numerically using the central finite difference method. If
214+
`grad` is instead a Boolean and is `True`, then `func` is assumed to return a tuple where the first
215+
element is the function value and the second element is the gradient. This is useful when the gradient
216+
is expensive to compute.
213217
name: str, default=''
214218
The name of the term.
215219
@@ -365,7 +369,9 @@ def var_by_name(self, name: str) -> mip.Var:
365369
"""Get a variable by name."""
366370
return self._model.var_by_name(name=name)
367371

368-
def var_value(self, x: Union[Input, str]) -> Union[float, np.ndarray]:
372+
def var_value(
373+
self, x: Union[mip.Var, mip.LinExprTensor, str]
374+
) -> Union[float, np.ndarray]:
369375
"""Get the value one or more decision variables corresponding to the best solution.
370376
371377
Args:

0 commit comments

Comments
 (0)