Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First version of symmip, which adds tentative MIP support #370

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
21f402e
First version of symmip, which adds tentative Gurobi support
tBuLi Sep 18, 2023
02a2359
Added non-trivial example, improved model output.
tBuLi Sep 18, 2023
8854846
Added SCIPOpt backend and made it the default, since this is a non-co…
tBuLi Sep 19, 2023
4530c47
Fixed parameter bounds, can now be arrays
tBuLi Sep 25, 2023
6f0833f
Updated examples, added test for array parameter bounds
tBuLi Sep 25, 2023
af962d1
Added simple mip test that can be compared with a Fit.
tBuLi Sep 25, 2023
920a1b6
Updated key2str function
tBuLi Sep 25, 2023
8881b65
Added MIP bilinear test. Also, we now allow constraints to have param…
tBuLi Sep 25, 2023
9c66409
Added some try/except clauses since MIP backends are an optional extra.
tBuLi Sep 25, 2023
e863bdd
Added symmip workflow and installation instruction.
tBuLi Sep 25, 2023
a409437
Added SCIPOptSuite installation to symmip workflow
tBuLi Sep 25, 2023
8e46208
Added download statistics badges.
tBuLi Sep 25, 2023
19c263d
Added tr/except clauses such that mip is an optional import.
tBuLi Sep 25, 2023
77dd0bd
Fixed mistake in multiscenario example
tBuLi Sep 26, 2023
6fa3e04
Update symfit/core/argument.py
tBuLi Oct 3, 2023
edee7ef
Support explicit indicing by ints in objectives and constraits.
tBuLi Jan 4, 2024
6450a20
Merge branch 'symmip' of github.com:tBuLi/symfit into symmip
tBuLi Jan 4, 2024
ea86ffe
Removed numpy import in sudoku_alt
tBuLi Jan 4, 2024
c060e80
Fixed kwargs preperation for non-indexed parameters.
tBuLi Jan 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test_symmip.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: pytest_and_symmip
env:
version: 8.0.3
on: [push, pull_request]

jobs:
build:

runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
python-version: [3.7, 3.8, 3.9, "3.10", 3.11]
steps:
- uses: actions/checkout@v3
- name: Install dependencies (SCIPOptSuite)
run: |
wget --quiet --no-check-certificate https://github.com/scipopt/scip/releases/download/$(echo "v${{env.version}}" | tr -d '.')/SCIPOptSuite-${{ env.version }}-Linux-ubuntu.deb
sudo apt-get update && sudo apt install -y ./SCIPOptSuite-${{ env.version }}-Linux-ubuntu.deb
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install wheel
pip install pytest
pip install -r requirements.txt
pip install matplotlib
pip install -e .[symmip]
- name: Test MIP
run:
pytest tests/test_mip.py
10 changes: 9 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
:target: https://zenodo.org/badge/latestdoi/24005390
.. image:: https://coveralls.io/repos/github/tBuLi/symfit/badge.svg?branch=master
:target: https://coveralls.io/github/tBuLi/symfit?branch=master

.. image:: https://img.shields.io/pypi/v/symfit?label=pypi%20package
:alt: PyPI
:target:`https://pypi.org/project/symfit/`
.. image:: https://img.shields.io/pypi/dm/symfit
:alt: PyPI - Downloads
:target:`https://pypi.org/project/symfit/`
.. image:: https://img.shields.io/conda/dn/conda-forge/symfit?color=brightgreen&label=downloads&logo=conda-forge
:alt: Conda
:target: https://anaconda.org/conda-forge/symfit

Please cite this DOI if ``symfit`` benefited your publication. Building this has been a lot of work, and as young researchers your citation means a lot to us.
Martin Roelfs & Peter C Kroon, symfit. doi:10.5281/zenodo.1133336
Expand Down
9 changes: 9 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ from your terminal. If you prefer to use `conda`, run ::
instead. Lastly, if you prefer to install manually you can download
the source from https://github.com/tBuLi/symfit.

symmip module
--------------
To use `symfit`'s :class:`~symfit.symmip.mip.MIP` object for mixed integer programming (MIP) and
mixed integer nonlinear programming (MINLP), you need to have a suitable backend installed.
Because this is an optional feature, no such solver is installed by default.
In order to install the non-commercial SCIPOpt package, install `symfit` by running

pip install symfit[symmip]

Contrib module
--------------
To also install the dependencies of 3rd party contrib modules such as
Expand Down
26 changes: 26 additions & 0 deletions examples/mip/bilinear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Inspired by https://www.gurobi.com/documentation/9.5/examples/bilinear_py.html#subsubsection:bilinear.py
#
# This example formulates and solves the following simple bilinear model:
# maximize x
# subject to x + y + z <= 10
# x * y <= 2 (bilinear inequality)
# x * z + y * z = 1 (bilinear equality)
# x, y, z non-negative (x integral in second version)
Comment on lines +3 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding, this is not a MIP, but just an almost-linear problem? In other words, there are no integer constraints. In other other words, you could solve this with the existing minimizers

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second note, I don't see the non-negative boundaries in the code below


from symfit import parameters, Eq
from symfit import MIP

# Create variables
x, y, z = parameters('x, y, z', min=0)

objective = 1.0 * x
constraints = [
x + y + z <= 10,
x*y <= 2,
Eq(x*z + y*z, 1),
]

mip = MIP(- objective, constraints=constraints)
mip_result = mip.execute()

print(mip_result)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing empty line

35 changes: 35 additions & 0 deletions examples/mip/mip1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Inspired by https://www.gurobi.com/documentation/9.5/examples/mip1_py.html
#
# Solve the following MIP:
# maximize
# x + y + 2 z
# subject to
# x + 2 y + 3 z <= 4
# x + y >= 1
# x, y, z binary

from symfit import parameters, MIP
from symfit.symmip.backend import SCIPOptBackend, GurobiBackend

x, y, z = parameters('x, y, z', binary=True, min=0, max=1)

objective = x + y + 2 * z
constraints = [
x + 2 * y + 3 * z <= 4,
x + y >= 1
]

# We know solve this problem with different backends.
for backend in [SCIPOptBackend, GurobiBackend]:
print(f'Run with {backend=}:')
fit = MIP(objective, constraints=constraints, backend=backend)
fit_result = fit.execute()

print(f"Optimal objective value: {fit_result.objective_value}")
print(
f"Solution values: "
f"x={fit_result[x]}, "
f"y={fit_result[y]}, "
f"z={fit_result[z]}"
)
print(fit_result, end='\n\n')
66 changes: 66 additions & 0 deletions examples/mip/multiscenario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Inspired by https://www.gurobi.com/documentation/9.5/examples/multiscenario_py.html.

For now this symfit equivalent only solves a single scenario, as we do not (currently) support Gurobi's
multiscenario feature. However, this is still a nice example to demonstrate some of symfit's features.
"""

from symfit import MIP, IndexedBase, Eq, Idx, Parameter, symbols, Sum, pprint
from symfit.symmip.backend import SCIPOptBackend, GurobiBackend
import numpy as np

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an example this needs a little paragraph describing the problem

# Warehouse demand in thousands of units
data_demand = np.array([15, 18, 14, 20])

# Plant capacity in thousands of units
data_capacity = np.array([20, 22, 17, 19, 18])

# Fixed costs for each plant
data_fixed_costs = np.array([12000, 15000, 17000, 13000, 16000])

# Transportation costs per thousand units
data_trans_costs = np.array(
[[4000, 2000, 3000, 2500, 4500],
[2500, 2600, 3400, 3000, 4000],
[1200, 1800, 2600, 4100, 3000],
[2200, 2600, 3100, 3700, 3200]]
)
Comment on lines +12 to +27
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a min-cost-flow problem :)


# Indices over the plants and warehouses
plant = Idx('plant', range=len(data_capacity))
warehouse = Idx('warehouse', range=len(data_demand))

# Indexed variables. Initial values become coefficients in the objective function.
open = IndexedBase(Parameter('Open', binary=True))
transport = IndexedBase(Parameter('Transport'))
fixed_costs = IndexedBase(Parameter('fixed_costs'))
trans_cost = IndexedBase(Parameter('trans_cost'))
capacity = IndexedBase(Parameter('capacity'))
demand = IndexedBase(Parameter('demand'))

objective = Sum(fixed_costs[plant] * open[plant], plant) + Sum(trans_cost[warehouse, plant] * transport[warehouse, plant], warehouse, plant)
constraints = [
Sum(transport[warehouse, plant], warehouse) <= capacity[plant] * open[plant],
Eq(Sum(transport[warehouse, plant], plant), demand[warehouse])
]

print('Objective:')
pprint(objective, wrap_line=False)
print('\nSubject to:')
for constraint in constraints:
pprint(constraint, wrap_line=False)
print('\n\n')

data = {
fixed_costs[plant]: data_fixed_costs,
trans_cost[warehouse, plant]: data_trans_costs,
capacity[plant]: data_capacity,
demand[warehouse]: data_demand,
}

# We know solve this problem with different backends.
for backend in [SCIPOptBackend, GurobiBackend]:
print(f'Run with {backend=}:')
mip = MIP(objective, constraints=constraints, data=data, backend=backend)
results = mip.execute()
print(results)
68 changes: 68 additions & 0 deletions examples/mip/sudoku.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Inspired by https://www.gurobi.com/documentation/9.5/examples/sudoku_py.html

import math

import numpy as np

from symfit import Parameter, symbols, IndexedBase, Idx, Sum, Eq
from symfit.symmip.backend import SCIPOptBackend, GurobiBackend
from symfit import MIP

with open('sudoku1') as f:
grid = f.read().split()

for line in grid:
print(line)

n = len(grid[0])
s = math.isqrt(n)

# Fix variables associated with cells whose values are pre-specified
# lb = np.array([[0 if char == "." else 1 for j, char in enumerate(line)] for i, line in enumerate(grid)])
lb = np.zeros((n, n, n), dtype=int)
for i in range(n):
for j in range(n):
if grid[i][j] != '.':
v = int(grid[i][j]) - 1
lb[i, j, v] = 1
ub = np.ones_like(lb)
Comment on lines +22 to +28
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like these variable names.
Some explanation as to why the one-hot encoding also wouldn't be misplaced in an example


# Prepare the boolean parameters for our sudoku board.
# Because every position on the board can have only one value,
# we make a binary Indexed symbol x[i,j,v], where i is the column,
# j is the row, and v is the value in the (i, j) position.
x = IndexedBase(Parameter('x', binary=True, min=lb, max=ub))
i, j, v = symbols('i, j, v', cls=Idx, range=n)
x_ijv = x[i, j, v]

# Add the sudoku constraints:
# 1. Each cell must take exactly one value: Sum(x[i,j,v], v) == 1
# 2. Each value is used exactly once per row: Sum(x[i,j,v], i) == 1
# 3. Each value is used exactly once per column: Sum(x[i,j,v], j) == 1
# 4. Each value is used exactly once per 3x3 subgrid.
constraints = [
Eq(Sum(x[i, j, v], v), 1),
Eq(Sum(x[i, j, v], i), 1),
Eq(Sum(x[i, j, v], j), 1),
*[Eq(Sum(x[i, j, v], (i, i_lb, i_lb + s - 1), (j, j_lb, j_lb + s - 1)), 1)
for i_lb in range(0, n, s)
for j_lb in range(0, n, s)]
]

# We know solve this problem with different backends.
for backend in [SCIPOptBackend, GurobiBackend]:
print(f'Run with {backend=}:')
fit = MIP(constraints=constraints, backend=backend)
result = fit.execute()

print('')
print('Solution:')
print('')
solution = result[x]
for i in range(n):
sol = ''
for j in range(n):
for v in range(n):
if solution[i, j, v] > 0.5:
sol += str(v+1)
print(sol)
9 changes: 9 additions & 0 deletions examples/mip/sudoku1
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.284763..
...839.2.
7..512.8.
..179..4.
3........
..9...1..
.5..8....
..692...5
..2645..8
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ universal=1
[extras]
contrib =
matplotlib >= 2.0
symmip =
pyscipopt
# all should be a complete list of all dependencies of all other extras. How to
# automate this?
all =
matplotlib >= 2.0
pyscipopt
6 changes: 6 additions & 0 deletions symfit/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,11 @@
from symfit.core.argument import Variable, Parameter
from symfit.core.support import variables, parameters, D

try:
# MIP is an optional feature. If no solvers are installed this will raise an import error.
from symfit.symmip import MIP
except ImportError:
pass

# Expose the sympy API
from sympy import *
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty line

29 changes: 21 additions & 8 deletions symfit/core/argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import numbers
import warnings

import numpy as np

from sympy.core.symbol import Symbol


Expand Down Expand Up @@ -93,6 +95,9 @@ class Parameter(Argument):
_argument_name = 'par'

def __new__(cls, name=None, value=1.0, min=None, max=None, fixed=False, **kwargs):
if 'binary' in kwargs:
kwargs['integer'] = kwargs['real'] = kwargs['nonnegative'] = True
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
kwargs['integer'] = kwargs['real'] = kwargs['nonnegative'] = True
kwargs['integer'] = True
kwargs['real'] = True
kwargs['nonnegative'] = True


try:
return super(Parameter, cls).__new__(cls, name, **kwargs)
except TypeError as err:
Expand All @@ -115,13 +120,17 @@ def __init__(self, name=None, value=1.0, min=None, max=None, fixed=False, **assu
self.value = value
self.fixed = fixed

if min is not None and max is not None and min > max:
if not self.fixed:
if min is not None and max is not None:
test = min > max
if isinstance(test, np.ndarray):
test = test.any()

if test and not self.fixed:
tBuLi marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError('The value of `min` should be less than or'
' equal to the value of `max`.')
else:
self.min = min
self.max = max

self.min = min
self.max = max

def __eq__(self, other):
"""
Expand All @@ -135,10 +144,14 @@ def __eq__(self, other):
if not equal:
return False
else:
return (self.min == other.min and
self.max == other.max and
min_eq = self.min == other.min
max_eq = self.max == other.max
value_eq = self.value == other.value

return (min_eq.all() if isinstance(min_eq, np.ndarray) else min_eq and
max_eq.all() if isinstance(max_eq, np.ndarray) else max_eq and
self.fixed == other.fixed and
self.value == other.value)
value_eq.all() if isinstance(value_eq, np.ndarray) else value_eq)

__hash__ = Argument.__hash__

Expand Down
3 changes: 1 addition & 2 deletions symfit/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,7 @@ def as_constraint(cls, constraint, model, constraint_type=None, **init_kwargs):
if set(instance.params) <= set(model.params):
instance.params = model.params
else:
raise ModelError('The parameters of ``constraint`` have to be a '
'subset of those of ``model``.')
model.params = sorted(set(model.params) | set(instance.params), key=lambda x: x.name)

return instance

Expand Down
2 changes: 1 addition & 1 deletion symfit/core/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def key2str(target):
:param target: `Mapping` to be made save
:return: `Mapping` of str(symbol): value pairs.
"""
return target.__class__((str(symbol), value) for symbol, value in target.items())
return {str(symbol): value for symbol, value in target.items()}


def D(*args, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions symfit/symmip/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .mip import MIP
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty line

7 changes: 7 additions & 0 deletions symfit/symmip/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
try:
# As a commercial solver, gurobi is optional.
from .gurobi import GurobiBackend
except ImportError:
pass

from .scipopt import SCIPOptBackend
Loading