pyoframe 0.2.0__py3-none-any.whl → 1.0.0__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.
@@ -0,0 +1,189 @@
1
+ """Defines the base classes used in Pyoframe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC
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
+ QUAD_VAR_KEY,
14
+ RESERVED_COL_KEYS,
15
+ VAR_KEY,
16
+ Config,
17
+ )
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from pyoframe import Model
21
+
22
+
23
+ class BaseBlock(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: Config.id_dtype})
45
+ if QUAD_VAR_KEY in data.columns:
46
+ data = data.cast({QUAD_VAR_KEY: Config.id_dtype})
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 dimensionless(self) -> bool:
82
+ """Whether the object has no dimensions.
83
+
84
+ Examples:
85
+ A variable with no dimensions
86
+ >>> pf.Variable().dimensionless
87
+ True
88
+
89
+ A variable with dimensions of "hour" and "city"
90
+ >>> pf.Variable(
91
+ ... [
92
+ ... {"hour": ["00:00", "06:00", "12:00", "18:00"]},
93
+ ... {"city": ["Toronto", "Berlin", "Paris"]},
94
+ ... ]
95
+ ... ).dimensionless
96
+ False
97
+ """
98
+ return self.dimensions is None
99
+
100
+ @property
101
+ def _dimensions_unsafe(self) -> list[str]:
102
+ """Same as `dimensions` but returns an empty list if there are no dimensions instead of `None`.
103
+
104
+ When unsure, use `dimensions` instead since the type checker forces users to handle the None case (no dimensions).
105
+ """
106
+ dims = self.dimensions
107
+ if dims is None:
108
+ return []
109
+ return dims
110
+
111
+ @property
112
+ def shape(self) -> dict[str, int]:
113
+ """The number of distinct labels in each dimension.
114
+
115
+ Examples:
116
+ A variable with no dimensions
117
+ >>> pf.Variable().shape
118
+ {}
119
+
120
+ A variable with dimensions of "hour" and "city"
121
+ >>> pf.Variable(
122
+ ... [
123
+ ... {"hour": ["00:00", "06:00", "12:00", "18:00"]},
124
+ ... {"city": ["Toronto", "Berlin", "Paris"]},
125
+ ... ]
126
+ ... ).shape
127
+ {'hour': 4, 'city': 3}
128
+ """
129
+ dims = self.dimensions
130
+ if dims is None:
131
+ return {}
132
+ return {dim: self.data[dim].n_unique() for dim in dims}
133
+
134
+ def estimated_size(self, unit: pl.SizeUnit = "b") -> int | float:
135
+ """Returns the estimated size of the object in bytes.
136
+
137
+ Only considers the size of the underlying DataFrame(s) since other components (e.g., the object name) are negligible.
138
+
139
+ Parameters:
140
+ unit:
141
+ See [`polars.DataFrame.estimated_size`](https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.estimated_size.html).
142
+
143
+ Examples:
144
+ >>> m = pf.Model()
145
+
146
+ A dimensionless variable contains just a 32 bit (4 bytes) unsigned integer (the variable ID).
147
+
148
+ >>> m.x = pf.Variable()
149
+ >>> m.x.estimated_size()
150
+ 4
151
+
152
+ A dimensioned variable contains, for every row, a 32 bit ID and, in this case, a 64 bit `dim_x` value (1200 bytes total).
153
+
154
+ >>> m.y = pf.Variable(pf.Set(dim_x=range(100)))
155
+ >>> m.y.estimated_size()
156
+ 1200
157
+ """
158
+ return self.data.estimated_size(unit)
159
+
160
+ def _add_shape_to_columns(self, df: pl.DataFrame) -> pl.DataFrame:
161
+ """Adds the shape of the data to the columns of the DataFrame.
162
+
163
+ This is used for displaying the shape in the string representation of the object.
164
+ """
165
+ shape = self.shape
166
+ return df.rename(lambda col: f"{col}\n({shape[col]})" if col in shape else col)
167
+
168
+ def __len__(self) -> int:
169
+ dims = self.dimensions
170
+ if dims is None:
171
+ return 1
172
+ return self.data.select(dims).n_unique()
173
+
174
+ @property
175
+ def _has_ids(self) -> bool:
176
+ id_col = self._get_id_column_name()
177
+ assert id_col is not None, "Cannot check for IDs if no ID column is defined."
178
+ return id_col in self.data.columns
179
+
180
+ def _assert_has_ids(self):
181
+ if not self._has_ids:
182
+ raise ValueError(
183
+ f"Cannot use '{self.__class__.__name__}' before it has been added to a model."
184
+ )
185
+
186
+ @classmethod
187
+ def _get_id_column_name(cls) -> str | None:
188
+ """Subclasses should override to indicate that `data` contains an ID column."""
189
+ return None
@@ -0,0 +1,82 @@
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 BaseOperableBlock, Expression
10
+
11
+
12
+ def _patch_class(cls):
13
+ def _patch_method(func):
14
+ @wraps(func)
15
+ def wrapper(self, other):
16
+ if isinstance(other, BaseOperableBlock):
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.__lt__ = _patch_method(cls.__lt__)
28
+ cls.__gt__ = _patch_method(cls.__gt__)
29
+ cls.__contains__ = _patch_method(cls.__contains__)
30
+
31
+
32
+ def polars_df_to_expr(self: pl.DataFrame) -> Expression:
33
+ """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.
34
+
35
+ See [Special Functions](../../learn/concepts/special-functions.md#dataframeto_expr) for more details.
36
+
37
+ Examples:
38
+ >>> import polars as pl
39
+ >>> df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6], "z": [7, 8, 9]})
40
+ >>> df.to_expr()
41
+ <Expression height=3 terms=3 type=constant>
42
+ ┌─────┬─────┬────────────┐
43
+ │ x ┆ y ┆ expression │
44
+ │ (3) ┆ (3) ┆ │
45
+ ╞═════╪═════╪════════════╡
46
+ │ 1 ┆ 4 ┆ 7 │
47
+ │ 2 ┆ 5 ┆ 8 │
48
+ │ 3 ┆ 6 ┆ 9 │
49
+ └─────┴─────┴────────────┘
50
+ """
51
+ name = self.columns[-1]
52
+ return Expression(
53
+ self.rename({name: COEF_KEY})
54
+ .drop_nulls(COEF_KEY)
55
+ .with_columns(pl.lit(CONST_TERM).alias(VAR_KEY)),
56
+ name=name,
57
+ )
58
+
59
+
60
+ def pandas_df_to_expr(self: pd.DataFrame) -> Expression:
61
+ """Same as [`polars.DataFrame.to_expr`](./polars.DataFrame.to_expr.md), but for [pandas](https://pandas.pydata.org/) DataFrames."""
62
+ return polars_df_to_expr(pl.from_pandas(self))
63
+
64
+
65
+ def pandas_series_to_expr(self: pd.Series) -> Expression:
66
+ """Converts a [pandas](https://pandas.pydata.org/) `Series` to a Pyoframe [Expression][pyoframe.Expression], using the index for labels.
67
+
68
+ See [Special Functions](../../learn/concepts/special-functions.md#dataframeto_expr) for more details.
69
+
70
+ Note that no equivalent method exists for Polars Series, as Polars does not support indexes.
71
+ """
72
+ return pandas_df_to_expr(self.to_frame().reset_index())
73
+
74
+
75
+ def patch_dataframe_libraries():
76
+ _patch_class(pd.DataFrame)
77
+ _patch_class(pd.Series)
78
+ _patch_class(pl.DataFrame)
79
+ _patch_class(pl.Series)
80
+ pl.DataFrame.to_expr = polars_df_to_expr
81
+ pd.DataFrame.to_expr = pandas_df_to_expr
82
+ pd.Series.to_expr = pandas_series_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.core import Expression, SupportsToExpr
7
+ from pyoframe._constants import ObjSense
8
+ from pyoframe._core import Expression, Operable
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 size=1 dimensions={} terms=2>
19
- objective: 2 B +4
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({"city": ["Toronto", "Berlin", "Paris"]})
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 pf.sum()?
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
 
@@ -55,38 +62,64 @@ class Objective(Expression):
55
62
  ValueError: An objective already exists. Use += or -= to modify it.
56
63
  """
57
64
 
58
- def __init__(
59
- self, expr: SupportsToExpr | int | float, _constructive: bool = False
60
- ) -> None:
65
+ def __init__(self, expr: Operable, _constructive: bool = False) -> None:
61
66
  self._constructive = _constructive
62
67
  if isinstance(expr, (int, float)):
63
68
  expr = Expression.constant(expr)
64
69
  else:
65
70
  expr = expr.to_expr()
66
- super().__init__(expr.data)
71
+ super().__init__(expr.data, name="objective")
67
72
  self._model = expr._model
68
73
  if self.dimensions is not None:
69
74
  raise ValueError(
70
- "Objective cannot be created from a dimensioned expression. Did you forget to use pf.sum()?"
75
+ "Objective cannot be created from a dimensioned expression. Did you forget to use .sum()?"
71
76
  )
72
77
 
73
78
  @property
74
79
  def value(self) -> float:
75
- """
76
- The value of the objective function (only available after solving the model).
80
+ """The value of the objective function (only available after solving the model).
77
81
 
78
82
  This value is obtained by directly querying the solver.
79
83
  """
80
- return self._model.poi.get_model_attribute(poi.ModelAttribute.ObjectiveValue)
84
+ assert self._model is not None, (
85
+ "Objective must be part of a model before it is queried."
86
+ )
87
+
88
+ if (
89
+ self._model.attr.TerminationStatus
90
+ == poi.TerminationStatusCode.OPTIMIZE_NOT_CALLED
91
+ ):
92
+ raise ValueError(
93
+ "Cannot retrieve the objective value before calling model.optimize()."
94
+ )
95
+
96
+ obj_value: float = self._model.attr.ObjectiveValue
97
+ if (
98
+ not self._model.solver.supports_objective_sense
99
+ and self._model.sense == ObjSense.MAX
100
+ ):
101
+ obj_value *= -1
102
+ return obj_value
81
103
 
82
- def on_add_to_model(self, model, name):
83
- super().on_add_to_model(model, name)
104
+ def _on_add_to_model(self, model, name):
105
+ super()._on_add_to_model(model, name)
84
106
  assert self._model is not None
85
107
  if self._model.sense is None:
86
108
  raise ValueError(
87
109
  "Can't set an objective without specifying the sense. Did you use .objective instead of .minimize or .maximize ?"
88
110
  )
89
- self._model.poi.set_objective(self.to_poi(), sense=self._model.sense.to_poi())
111
+
112
+ kwargs = {}
113
+ if (
114
+ not self._model.solver.supports_objective_sense
115
+ and self._model.sense == ObjSense.MAX
116
+ ):
117
+ poi_expr = (-self)._to_poi()
118
+ kwargs["sense"] = poi.ObjectiveSense.Minimize
119
+ else:
120
+ poi_expr = self._to_poi()
121
+ kwargs["sense"] = self._model.sense._to_poi()
122
+ self._model.poi.set_objective(poi_expr, **kwargs)
90
123
 
91
124
  def __iadd__(self, other):
92
125
  return Objective(self + other, _constructive=True)