pyoframe 0.2.1__py3-none-any.whl → 1.0.0a0__py3-none-any.whl
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.
- pyoframe/__init__.py +21 -14
- pyoframe/_arithmetic.py +265 -159
- pyoframe/_constants.py +416 -0
- pyoframe/_core.py +2575 -0
- pyoframe/_model.py +578 -0
- pyoframe/_model_element.py +175 -0
- pyoframe/_monkey_patch.py +80 -0
- pyoframe/{objective.py → _objective.py} +49 -14
- pyoframe/{util.py → _utils.py} +106 -126
- pyoframe/_version.py +2 -2
- {pyoframe-0.2.1.dist-info → pyoframe-1.0.0a0.dist-info}/METADATA +32 -25
- pyoframe-1.0.0a0.dist-info/RECORD +15 -0
- pyoframe/constants.py +0 -140
- pyoframe/core.py +0 -1787
- pyoframe/model.py +0 -408
- pyoframe/model_element.py +0 -184
- pyoframe/monkey_patch.py +0 -54
- pyoframe-0.2.1.dist-info/RECORD +0 -15
- {pyoframe-0.2.1.dist-info → pyoframe-1.0.0a0.dist-info}/WHEEL +0 -0
- {pyoframe-0.2.1.dist-info → pyoframe-1.0.0a0.dist-info}/licenses/LICENSE +0 -0
- {pyoframe-0.2.1.dist-info → pyoframe-1.0.0a0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Defines the base classes used in Pyoframe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
from pyoframe._arithmetic import _get_dimensions
|
|
11
|
+
from pyoframe._constants import (
|
|
12
|
+
COEF_KEY,
|
|
13
|
+
KEY_TYPE,
|
|
14
|
+
QUAD_VAR_KEY,
|
|
15
|
+
RESERVED_COL_KEYS,
|
|
16
|
+
VAR_KEY,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from pyoframe import Model
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ModelElement(ABC):
|
|
24
|
+
"""The base class for elements of a Model such as [][pyoframe.Variable] and [][pyoframe.Constraint]."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, data: pl.DataFrame, name="unnamed") -> None:
|
|
27
|
+
# Sanity checks, no duplicate column names
|
|
28
|
+
assert len(data.columns) == len(set(data.columns)), (
|
|
29
|
+
"Duplicate column names found."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
cols = _get_dimensions(data)
|
|
33
|
+
if cols is None:
|
|
34
|
+
cols = []
|
|
35
|
+
cols += [col for col in RESERVED_COL_KEYS if col in data.columns]
|
|
36
|
+
|
|
37
|
+
# Reorder columns to keep things consistent
|
|
38
|
+
data = data.select(cols)
|
|
39
|
+
|
|
40
|
+
# Cast to proper dtype
|
|
41
|
+
if COEF_KEY in data.columns:
|
|
42
|
+
data = data.cast({COEF_KEY: pl.Float64})
|
|
43
|
+
if VAR_KEY in data.columns:
|
|
44
|
+
data = data.cast({VAR_KEY: KEY_TYPE})
|
|
45
|
+
if QUAD_VAR_KEY in data.columns:
|
|
46
|
+
data = data.cast({QUAD_VAR_KEY: KEY_TYPE})
|
|
47
|
+
|
|
48
|
+
self._data = data
|
|
49
|
+
self._model: Model | None = None
|
|
50
|
+
self.name: str = name # gets overwritten if object is added to model
|
|
51
|
+
|
|
52
|
+
def _on_add_to_model(self, model: Model, name: str):
|
|
53
|
+
self.name = name
|
|
54
|
+
self._model = model
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def data(self) -> pl.DataFrame:
|
|
58
|
+
"""Returns the object's underlying Polars DataFrame."""
|
|
59
|
+
return self._data
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def dimensions(self) -> list[str] | None:
|
|
63
|
+
"""The names of the data's dimensions.
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
A variable with no dimensions
|
|
67
|
+
>>> pf.Variable().dimensions
|
|
68
|
+
|
|
69
|
+
A variable with dimensions of "hour" and "city"
|
|
70
|
+
>>> pf.Variable(
|
|
71
|
+
... [
|
|
72
|
+
... {"hour": ["00:00", "06:00", "12:00", "18:00"]},
|
|
73
|
+
... {"city": ["Toronto", "Berlin", "Paris"]},
|
|
74
|
+
... ]
|
|
75
|
+
... ).dimensions
|
|
76
|
+
['hour', 'city']
|
|
77
|
+
"""
|
|
78
|
+
return _get_dimensions(self.data)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def _dimensions_unsafe(self) -> list[str]:
|
|
82
|
+
"""Same as `dimensions` but returns an empty list if there are no dimensions instead of `None`.
|
|
83
|
+
|
|
84
|
+
When unsure, use `dimensions` instead since the type checker forces users to handle the None case (no dimensions).
|
|
85
|
+
"""
|
|
86
|
+
dims = self.dimensions
|
|
87
|
+
if dims is None:
|
|
88
|
+
return []
|
|
89
|
+
return dims
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def shape(self) -> dict[str, int]:
|
|
93
|
+
"""The number of indices in each dimension.
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
A variable with no dimensions
|
|
97
|
+
>>> pf.Variable().shape
|
|
98
|
+
{}
|
|
99
|
+
|
|
100
|
+
A variable with dimensions of "hour" and "city"
|
|
101
|
+
>>> pf.Variable(
|
|
102
|
+
... [
|
|
103
|
+
... {"hour": ["00:00", "06:00", "12:00", "18:00"]},
|
|
104
|
+
... {"city": ["Toronto", "Berlin", "Paris"]},
|
|
105
|
+
... ]
|
|
106
|
+
... ).shape
|
|
107
|
+
{'hour': 4, 'city': 3}
|
|
108
|
+
"""
|
|
109
|
+
dims = self.dimensions
|
|
110
|
+
if dims is None:
|
|
111
|
+
return {}
|
|
112
|
+
return {dim: self.data[dim].n_unique() for dim in dims}
|
|
113
|
+
|
|
114
|
+
def estimated_size(self, unit: pl.SizeUnit = "b") -> int | float:
|
|
115
|
+
"""Returns the estimated size of the object in bytes.
|
|
116
|
+
|
|
117
|
+
Only considers the size of the underlying DataFrame(s) since other components (e.g., the object name) are negligible.
|
|
118
|
+
|
|
119
|
+
Parameters:
|
|
120
|
+
unit:
|
|
121
|
+
See [`polars.DataFrame.estimated_size`](https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.estimated_size.html).
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
>>> m = pf.Model()
|
|
125
|
+
|
|
126
|
+
A dimensionless variable contains just a 32 bit (4 bytes) unsigned integer (the variable ID).
|
|
127
|
+
|
|
128
|
+
>>> m.x = pf.Variable()
|
|
129
|
+
>>> m.x.estimated_size()
|
|
130
|
+
4
|
|
131
|
+
|
|
132
|
+
A dimensioned variable contains, for every row, a 32 bit ID and, in this case, a 64 bit `dim_x` value (1200 bytes total).
|
|
133
|
+
|
|
134
|
+
>>> m.y = pf.Variable(pf.Set(dim_x=range(100)))
|
|
135
|
+
>>> m.y.estimated_size()
|
|
136
|
+
1200
|
|
137
|
+
"""
|
|
138
|
+
return self.data.estimated_size(unit)
|
|
139
|
+
|
|
140
|
+
def _add_shape_to_columns(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
141
|
+
"""Adds the shape of the data to the columns of the DataFrame.
|
|
142
|
+
|
|
143
|
+
This is used for displaying the shape in the string representation of the object.
|
|
144
|
+
"""
|
|
145
|
+
shape = self.shape
|
|
146
|
+
return df.rename(lambda col: f"{col}\n({shape[col]})" if col in shape else col)
|
|
147
|
+
|
|
148
|
+
def __len__(self) -> int:
|
|
149
|
+
dims = self.dimensions
|
|
150
|
+
if dims is None:
|
|
151
|
+
return 1
|
|
152
|
+
return self.data.select(dims).n_unique()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ModelElementWithId(ModelElement):
|
|
156
|
+
"""Extends ModelElement with a method that assigns a unique ID to each row in a DataFrame.
|
|
157
|
+
|
|
158
|
+
IDs start at 1 and go up consecutively. No zero ID is assigned since it is reserved for the constant variable term.
|
|
159
|
+
IDs are only unique for the subclass since different subclasses have different counters.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def _has_ids(self) -> bool:
|
|
164
|
+
return self._get_id_column_name() in self.data.columns
|
|
165
|
+
|
|
166
|
+
def _assert_has_ids(self):
|
|
167
|
+
if not self._has_ids:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Cannot use '{self.__class__.__name__}' before it has been added to a model."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
@abstractmethod
|
|
174
|
+
def _get_id_column_name(cls) -> str:
|
|
175
|
+
"""Returns the name of the column containing the IDs."""
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Defines the functions used to monkey patch polars and pandas."""
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
from pyoframe._constants import COEF_KEY, CONST_TERM, VAR_KEY
|
|
9
|
+
from pyoframe._core import Expression, SupportsMath
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _patch_class(cls):
|
|
13
|
+
def _patch_method(func):
|
|
14
|
+
@wraps(func)
|
|
15
|
+
def wrapper(self, other):
|
|
16
|
+
if isinstance(other, SupportsMath):
|
|
17
|
+
return NotImplemented
|
|
18
|
+
return func(self, other)
|
|
19
|
+
|
|
20
|
+
return wrapper
|
|
21
|
+
|
|
22
|
+
cls.__add__ = _patch_method(cls.__add__)
|
|
23
|
+
cls.__mul__ = _patch_method(cls.__mul__)
|
|
24
|
+
cls.__sub__ = _patch_method(cls.__sub__)
|
|
25
|
+
cls.__le__ = _patch_method(cls.__le__)
|
|
26
|
+
cls.__ge__ = _patch_method(cls.__ge__)
|
|
27
|
+
cls.__contains__ = _patch_method(cls.__contains__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def polars_df_to_expr(self: pl.DataFrame) -> Expression:
|
|
31
|
+
"""Converts a [polars](https://pola.rs/) `DataFrame` to a Pyoframe [Expression][pyoframe.Expression] by using the last column for values and the previous columns as dimensions.
|
|
32
|
+
|
|
33
|
+
See [Special Functions](../learn/concepts/special-functions.md#dataframeto_expr) for more details.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> import polars as pl
|
|
37
|
+
>>> df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6], "z": [7, 8, 9]})
|
|
38
|
+
>>> df.to_expr()
|
|
39
|
+
<Expression height=3 terms=3 type=constant>
|
|
40
|
+
┌─────┬─────┬────────────┐
|
|
41
|
+
│ x ┆ y ┆ expression │
|
|
42
|
+
│ (3) ┆ (3) ┆ │
|
|
43
|
+
╞═════╪═════╪════════════╡
|
|
44
|
+
│ 1 ┆ 4 ┆ 7 │
|
|
45
|
+
│ 2 ┆ 5 ┆ 8 │
|
|
46
|
+
│ 3 ┆ 6 ┆ 9 │
|
|
47
|
+
└─────┴─────┴────────────┘
|
|
48
|
+
"""
|
|
49
|
+
name = self.columns[-1]
|
|
50
|
+
return Expression(
|
|
51
|
+
self.rename({name: COEF_KEY})
|
|
52
|
+
.drop_nulls(COEF_KEY)
|
|
53
|
+
.with_columns(pl.lit(CONST_TERM).alias(VAR_KEY)),
|
|
54
|
+
name=name,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pandas_df_to_expr(self: pd.DataFrame) -> Expression:
|
|
59
|
+
"""Same as [`polars.DataFrame.to_expr`](./polars.DataFrame.to_expr.md), but for [pandas](https://pandas.pydata.org/) DataFrames."""
|
|
60
|
+
return polars_df_to_expr(pl.from_pandas(self))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def patch_dataframe_libraries():
|
|
64
|
+
"""Patches the DataFrame and Series classes of both pandas and polars.
|
|
65
|
+
|
|
66
|
+
1) Patches arithmetic operators (e.g. `__add__`) such that operations between DataFrames/Series and `Expressionable`s
|
|
67
|
+
are not supported (i.e. `return NotImplemented`). This leads Python to try the reverse operation (e.g. `__radd__`)
|
|
68
|
+
which is supported by the `Expressionable` class.
|
|
69
|
+
2) Adds a `to_expr` method to DataFrame/Series that allows them to be converted to an `Expression` object.
|
|
70
|
+
Series become DataFrames and DataFrames become expressions where everything but the last column are treated as dimensions.
|
|
71
|
+
"""
|
|
72
|
+
_patch_class(pd.DataFrame)
|
|
73
|
+
_patch_class(pd.Series)
|
|
74
|
+
_patch_class(pl.DataFrame)
|
|
75
|
+
_patch_class(pl.Series)
|
|
76
|
+
pl.DataFrame.to_expr = polars_df_to_expr
|
|
77
|
+
pd.DataFrame.to_expr = pandas_df_to_expr
|
|
78
|
+
# TODO make a set instead!
|
|
79
|
+
pl.Series.to_expr = lambda self: self.to_frame().to_expr()
|
|
80
|
+
pd.Series.to_expr = lambda self: self.to_frame().reset_index().to_expr()
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
"""Defines the `Objective` class for optimization models."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import pyoptinterface as poi
|
|
4
6
|
|
|
5
|
-
from pyoframe.
|
|
7
|
+
from pyoframe._constants import ObjSense
|
|
8
|
+
from pyoframe._core import Expression, SupportsToExpr
|
|
6
9
|
|
|
7
10
|
|
|
11
|
+
# TODO don't subclass Expression to avoid a bunch of unnecessary functions being available.
|
|
8
12
|
class Objective(Expression):
|
|
9
|
-
"""
|
|
13
|
+
"""The objective for an optimization model.
|
|
14
|
+
|
|
10
15
|
Examples:
|
|
11
16
|
An `Objective` is automatically created when an `Expression` is assigned to `.minimize` or `.maximize`
|
|
12
17
|
|
|
@@ -15,8 +20,8 @@ class Objective(Expression):
|
|
|
15
20
|
>>> m.con = m.A + m.B <= 10
|
|
16
21
|
>>> m.maximize = 2 * m.B + 4
|
|
17
22
|
>>> m.maximize
|
|
18
|
-
<Objective
|
|
19
|
-
|
|
23
|
+
<Objective terms=2 type=linear>
|
|
24
|
+
2 B +4
|
|
20
25
|
|
|
21
26
|
The objective value can be retrieved with from the solver once the model is solved using `.value`.
|
|
22
27
|
|
|
@@ -38,11 +43,13 @@ class Objective(Expression):
|
|
|
38
43
|
Objectives cannot be created from dimensioned expressions since an objective must be a single expression.
|
|
39
44
|
|
|
40
45
|
>>> m = pf.Model()
|
|
41
|
-
>>> m.dimensioned_variable = pf.Variable(
|
|
46
|
+
>>> m.dimensioned_variable = pf.Variable(
|
|
47
|
+
... {"city": ["Toronto", "Berlin", "Paris"]}
|
|
48
|
+
... )
|
|
42
49
|
>>> m.maximize = m.dimensioned_variable
|
|
43
50
|
Traceback (most recent call last):
|
|
44
51
|
...
|
|
45
|
-
ValueError: Objective cannot be created from a dimensioned expression. Did you forget to use
|
|
52
|
+
ValueError: Objective cannot be created from a dimensioned expression. Did you forget to use .sum()?
|
|
46
53
|
|
|
47
54
|
Objectives cannot be overwritten.
|
|
48
55
|
|
|
@@ -63,30 +70,58 @@ class Objective(Expression):
|
|
|
63
70
|
expr = Expression.constant(expr)
|
|
64
71
|
else:
|
|
65
72
|
expr = expr.to_expr()
|
|
66
|
-
super().__init__(expr.data)
|
|
73
|
+
super().__init__(expr.data, name="objective")
|
|
67
74
|
self._model = expr._model
|
|
68
75
|
if self.dimensions is not None:
|
|
69
76
|
raise ValueError(
|
|
70
|
-
"Objective cannot be created from a dimensioned expression. Did you forget to use
|
|
77
|
+
"Objective cannot be created from a dimensioned expression. Did you forget to use .sum()?"
|
|
71
78
|
)
|
|
72
79
|
|
|
73
80
|
@property
|
|
74
81
|
def value(self) -> float:
|
|
75
|
-
"""
|
|
76
|
-
The value of the objective function (only available after solving the model).
|
|
82
|
+
"""The value of the objective function (only available after solving the model).
|
|
77
83
|
|
|
78
84
|
This value is obtained by directly querying the solver.
|
|
79
85
|
"""
|
|
80
|
-
|
|
86
|
+
assert self._model is not None, (
|
|
87
|
+
"Objective must be part of a model before it is queried."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
self._model.attr.TerminationStatus
|
|
92
|
+
== poi.TerminationStatusCode.OPTIMIZE_NOT_CALLED
|
|
93
|
+
):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"Cannot retrieve the objective value before calling model.optimize()."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
obj_value: float = self._model.attr.ObjectiveValue
|
|
99
|
+
if (
|
|
100
|
+
not self._model.solver.supports_objective_sense
|
|
101
|
+
and self._model.sense == ObjSense.MAX
|
|
102
|
+
):
|
|
103
|
+
obj_value *= -1
|
|
104
|
+
return obj_value
|
|
81
105
|
|
|
82
|
-
def
|
|
83
|
-
super().
|
|
106
|
+
def _on_add_to_model(self, model, name):
|
|
107
|
+
super()._on_add_to_model(model, name)
|
|
84
108
|
assert self._model is not None
|
|
85
109
|
if self._model.sense is None:
|
|
86
110
|
raise ValueError(
|
|
87
111
|
"Can't set an objective without specifying the sense. Did you use .objective instead of .minimize or .maximize ?"
|
|
88
112
|
)
|
|
89
|
-
|
|
113
|
+
|
|
114
|
+
kwargs = {}
|
|
115
|
+
if (
|
|
116
|
+
not self._model.solver.supports_objective_sense
|
|
117
|
+
and self._model.sense == ObjSense.MAX
|
|
118
|
+
):
|
|
119
|
+
poi_expr = (-self)._to_poi()
|
|
120
|
+
kwargs["sense"] = poi.ObjectiveSense.Minimize
|
|
121
|
+
else:
|
|
122
|
+
poi_expr = self._to_poi()
|
|
123
|
+
kwargs["sense"] = self._model.sense._to_poi()
|
|
124
|
+
self._model.poi.set_objective(poi_expr, **kwargs)
|
|
90
125
|
|
|
91
126
|
def __iadd__(self, other):
|
|
92
127
|
return Objective(self + other, _constructive=True)
|