Skip to content

Commit ccc08da

Browse files
authored
feat: infer input dimensions (#162)
1 parent 8623c33 commit ccc08da

File tree

6 files changed

+71
-27
lines changed

6 files changed

+71
-27
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ requires = ['poetry-core']
1414
build-backend = 'poetry.core.masonry.api'
1515

1616
[tool.poetry]
17-
version = '0.23.5rc2'
17+
version = '0.24.0rc1'
1818
packages = [{include = 'opvious', from = 'src'}]
1919

2020
[tool.poetry.dependencies]

src/opvious/data/solves.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
UnboundedOutcome,
2020
)
2121
from .outlines import Label, ObjectiveSense, ProblemOutline, SourceBinding
22+
from .tensors import KeyItem
2223

2324

2425
@dataclasses.dataclass(frozen=True)
@@ -177,37 +178,58 @@ class SolveInputs:
177178
problem_outline: ProblemOutline
178179
"""Target model metadata"""
179180

180-
raw_parameters: list[Json] = dataclasses.field(repr=False)
181+
raw_parameters: Sequence[Json] = dataclasses.field(repr=False)
181182
"""All parameters in raw format"""
182183

183-
raw_dimensions: list[Json] | None = dataclasses.field(repr=False)
184+
raw_dimensions: Sequence[Json] | None = dataclasses.field(repr=False)
184185
"""All dimensions in raw format"""
185186

186-
def parameter(self, label: Label, coerce: bool = True) -> pd.DataFrame:
187-
"""Returns the parameter for a given label as a pandas DataFrame
187+
def parameter(
188+
self,
189+
label: Label,
190+
coerce: bool = True,
191+
index: pd.Index | Sequence[KeyItem] | None = None,
192+
) -> pd.DataFrame:
193+
"""Returns the parameter for a given label as a pandas dataframe
188194
189195
The returned dataframe has a `value` column with the parameter's values
190196
(0 values may be omitted).
191197
192198
Args:
193199
label: Parameter label to retrieve
194200
coerce: Round integral parameters
201+
index: Returned dataframe index
195202
"""
196203
for param in self.raw_parameters:
197204
if param["label"] == label:
198205
outline = self.problem_outline.parameters[label]
199-
return _entries_dataframe(
200-
param["entries"],
206+
return _tensor_json_dataframe(
207+
param,
201208
outline.bindings,
209+
index=index,
202210
round_values=coerce and outline.is_integral,
203211
)
204212
raise Exception(f"Unknown parameter: {label}")
205213

206214
def dimension(self, label: Label) -> pd.Index:
207215
"""Returns the dimension for a given label as a pandas Index"""
208-
for dim in self.raw_dimensions or []:
209-
if dim["label"] == label:
210-
return pd.Index(dim["items"])
216+
if self.raw_dimensions is not None:
217+
for dim in self.raw_dimensions:
218+
if dim["label"] == label:
219+
return pd.Index(dim["items"], name=label)
220+
else:
221+
items = set()
222+
has_binding = False
223+
for param in self.raw_parameters:
224+
outline = self.problem_outline.parameters[param["label"]]
225+
for i, binding in enumerate(outline.bindings):
226+
if binding.dimension_label != label:
227+
continue
228+
has_binding = True
229+
for entry in param["entries"]:
230+
items.add(entry["key"][i])
231+
if has_binding:
232+
return pd.Index(items, name=label).sort_values()
211233
raise Exception(f"Unknown dimension: {label}")
212234

213235

@@ -218,13 +240,18 @@ class SolveOutputs:
218240
problem_outline: ProblemOutline
219241
"""Solved model metadata"""
220242

221-
raw_variables: list[Json] = dataclasses.field(repr=False)
243+
raw_variables: Sequence[Json] = dataclasses.field(repr=False)
222244
"""All variables in raw format"""
223245

224-
raw_constraints: list[Json] = dataclasses.field(repr=False)
246+
raw_constraints: Sequence[Json] = dataclasses.field(repr=False)
225247
"""All constraints in raw format"""
226248

227-
def variable(self, label: Label, coerce: bool = True) -> pd.DataFrame:
249+
def variable(
250+
self,
251+
label: Label,
252+
coerce: bool = True,
253+
index: pd.Index | Sequence[KeyItem] | None = None,
254+
) -> pd.DataFrame:
228255
"""Returns variable results for a given label
229256
230257
The returned dataframe always has a `value` column with the variable's
@@ -234,14 +261,16 @@ def variable(self, label: Label, coerce: bool = True) -> pd.DataFrame:
234261
Args:
235262
label: Variable label to retrieve
236263
coerce: Round integral variables
264+
index: Returned dataframe index
237265
"""
238266
for res in self.raw_variables:
239267
if res["label"] == label:
240268
outline = self.problem_outline.variables[label]
241-
return _entries_dataframe(
242-
res["entries"],
269+
return _tensor_json_dataframe(
270+
res,
243271
outline.bindings,
244272
dual_value_name="reduced_cost",
273+
index=index,
245274
round_values=coerce and outline.is_integral,
246275
)
247276
raise Exception(f"Unknown variable {label}")
@@ -255,29 +284,35 @@ def constraint(self, label: Label) -> pd.DataFrame:
255284
"""
256285
for res in self.raw_constraints:
257286
if res["label"] == label:
258-
return _entries_dataframe(
259-
res["entries"],
287+
return _tensor_json_dataframe(
288+
res,
260289
self.problem_outline.constraints[label].bindings,
261290
value_name="slack",
262291
dual_value_name="shadow_price",
263292
)
264293
raise Exception(f"Unknown constraint {label}")
265294

266295

267-
def _entries_dataframe(
268-
entries: Sequence[Json],
296+
def _tensor_json_dataframe(
297+
tensor_json: Json,
269298
bindings: Sequence[SourceBinding],
270299
*,
271300
value_name: str = "value",
272301
dual_value_name: str | None = None,
302+
index: pd.Index | Sequence[KeyItem] | None = None,
273303
round_values: bool = False,
274304
) -> pd.DataFrame:
305+
entries = tensor_json["entries"]
306+
default_values = {
307+
value_name: decode_extended_float(tensor_json.get("defaultValue", 0)),
308+
}
275309
if dual_value_name:
276310
data = (
277311
(decode_extended_float(e["value"]), e.get("dualValue"))
278312
for e in entries
279313
)
280314
columns = [value_name, dual_value_name]
315+
default_values[dual_value_name] = 0
281316
else:
282317
data = (decode_extended_float(e["value"]) for e in entries)
283318
columns = [value_name]
@@ -287,11 +322,11 @@ def _entries_dataframe(
287322
index=_entry_index(entries, bindings),
288323
)
289324
if dual_value_name and df[dual_value_name].isnull().all():
290-
df.drop(dual_value_name, axis=1, inplace=True)
325+
df = df.drop(dual_value_name, axis=1)
326+
df = df.sort_index() if index is None else df.reindex(cast(Any, index))
327+
df = df.fillna(default_values)
291328
if round_values:
292329
df[value_name] = df[value_name].round(0).astype(np.int64)
293-
df.fillna(0, inplace=True)
294-
df.sort_index(inplace=True)
295330
return df
296331

297332

@@ -463,7 +498,7 @@ class SolveStrategy:
463498
sense: ObjectiveSense | None = None
464499
"""Optimization sense"""
465500

466-
epsilon_constraints: list[EpsilonConstraint] = dataclasses.field(
501+
epsilon_constraints: Sequence[EpsilonConstraint] = dataclasses.field(
467502
default_factory=lambda: []
468503
)
469504
"""All epsilon-constraints to apply"""

src/opvious/data/tensors.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from collections.abc import Iterable, Mapping
3+
from collections.abc import Iterable, Mapping, Sequence
44
import dataclasses
55
import math
66
from typing import Any, Self
@@ -40,12 +40,12 @@ def is_value(arg: Any) -> bool:
4040
)
4141

4242

43-
@dataclasses.dataclass
43+
@dataclasses.dataclass(frozen=True)
4444
class Tensor:
4545
"""An n-dimensional matrix"""
4646

47-
entries: list[Any]
48-
"""Raw list of matrix entries"""
47+
entries: Sequence[Any]
48+
"""Raw matrix entries"""
4949

5050
default_value: ExtendedFloat = 0
5151
"""Value to use for missing key"""

src/opvious/modeling/definitions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ def total_product_count(self):
214214
The number of arguments must match the tensor's quantification.
215215
"""
216216

217+
# TODO: Add map method, which appends to _mappers array of transformations.
218+
# Once implemented, remove the negate arguments to transformations.
219+
217220
def __init__(
218221
self,
219222
image: Image,

src/opvious/modeling/fragments.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,8 @@ def activated_variable(
625625
indicator_projection: Projection = -1,
626626
upper_bound: ExpressionLike | None = None,
627627
negate: bool = False,
628+
force_activation: bool = True,
629+
force_deactivation: bool = True,
628630
name: Name | None = None,
629631
) -> Callable[[TensorLike], ActivatedVariable]:
630632
"""Wraps a method into an :class:`ActivatedVariable` fragment
@@ -640,6 +642,8 @@ def wrapper(fn: TensorLike) -> ActivatedVariable:
640642
indicator_projection=indicator_projection,
641643
upper_bound=upper_bound,
642644
negate=negate,
645+
force_activation=force_activation,
646+
force_deactivation=force_deactivation,
643647
name=name,
644648
)
645649

tests/client_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ async def test_queue_diet_solve(self):
8686
"salad": 9,
8787
"caviar": 23,
8888
}
89+
nutrients = input_data.dimension("nutrients")
90+
assert list(nutrients) == ["carbs", "fibers", "vitamins"]
8991

9092
output_data = await client.fetch_solve_outputs(uuid)
9193
quantities = output_data.variable("quantityOfRecipe")

0 commit comments

Comments
 (0)