pyoframe 0.2.0__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.
@@ -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.core import Expression, SupportsToExpr
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 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
 
@@ -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 pf.sum()?"
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
- return self._model.poi.get_model_attribute(poi.ModelAttribute.ObjectiveValue)
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 on_add_to_model(self, model, name):
83
- super().on_add_to_model(model, name)
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
- self._model.poi.set_objective(self.to_poi(), sense=self._model.sense.to_poi())
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)