Skip to content

Commit 7430ad0

Browse files
committed
Tidy lexico code
1 parent bc1454f commit 7430ad0

3 files changed

Lines changed: 186 additions & 76 deletions

File tree

src/muse/decisions.py

Lines changed: 154 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,19 @@ def weighted_sum(objectives: Dataset, parameters: Any, **kwargs) -> DataArray:
3838
"weighted_sum",
3939
]
4040

41-
from collections.abc import Mapping, MutableMapping, Sequence
41+
from collections.abc import Hashable, Mapping, MutableMapping, Sequence
4242
from typing import (
4343
Any,
4444
Callable,
4545
)
4646

47+
import numpy as np
48+
import xarray as xr
4749
from xarray import DataArray, Dataset
4850

4951
from muse.registration import registrator
52+
from muse.timeslices import drop_timeslice
53+
from muse.utilities import tupled_dimension
5054

5155
PARAMS_TYPE = Sequence[tuple[str, bool, float]]
5256
"""Standard decision parameter type.
@@ -170,63 +174,180 @@ def weighted_sum(objectives: Dataset, parameters: Mapping[str, float]) -> DataAr
170174
def lexical_comparison(
171175
objectives: Dataset, parameters: PARAMS_TYPE | Sequence[tuple[str, float]]
172176
) -> DataArray:
173-
"""Lexical comparison over the objectives.
177+
"""Lexicographic comparison using the best available replacements as reference.
174178
175179
Lexical comparison operates by binning the objectives into bins of width
176-
w_i = min_j(p_i o_i^j). Once binned, dimensions other than `asset` and
177-
`technology` are reduced by taking the max, e.g. the largest constraint.
178-
Finally, the objectives are ranked lexographically, in the order given by the
179-
parameters.
180+
181+
w_i = min_j(p_i o_i^j),
182+
183+
where o_i^j are the objective values of the candidate replacements and the
184+
minimum is taken over the replacement dimension. Once binned, dimensions
185+
other than ``asset`` and ``replacement`` are reduced by taking the maximum
186+
(e.g. the largest constraint). Finally, the objectives are ranked
187+
lexicographically in the order given by ``parameters``.
180188
181189
The result is an array of tuples which can subsequently be compared
182190
lexicographically.
183191
"""
184-
from muse.utilities import lexical_comparison
185-
186192
assert len(parameters) > 0
187-
if len(parameters[0]) == 3:
188-
parameters = [(u[0], coeff_sign(u[1], u[2])) for u in parameters]
189-
assert set(objectives.data_vars).issuperset([u[0] for u in parameters])
190-
order = [u[0] for u in parameters]
191193

192-
binsize = objectives.copy()
193-
for obj_name, weight in parameters:
194-
binsize[obj_name] = binsize[obj_name] * weight
194+
# Convert (name, min/max, coefficient) specifications to
195+
# (name, signed coefficient), where the sign encodes whether the
196+
# objective should be minimised or maximised.
197+
if len(parameters[0]) == 3:
198+
parameters = [
199+
(name, coeff_sign(minmax, coeff)) for name, minmax, coeff in parameters
200+
]
201+
202+
# Lexicographic priority of the objectives.
203+
order = tuple(name for name, _ in parameters)
204+
205+
# All requested objectives must be present.
206+
assert set(objectives.data_vars).issuperset(order)
207+
208+
# Define the bin widths as
209+
#
210+
# w_i = min_j(p_i o_i^j),
211+
#
212+
# i.e. the weighted objective values of the candidate replacements,
213+
# taking the minimum over the replacement dimension.
214+
binsize = objectives.copy(deep=True)
215+
216+
# Temporarily flatten the timeslice MultiIndex to avoid xarray's
217+
# deprecated Dataset assignment path when modifying variables.
218+
if "timeslice" in binsize.indexes:
219+
index_names = binsize.indexes["timeslice"].names
220+
binsize = binsize.reset_index("timeslice")
221+
222+
# Apply the objective weights (including minimisation/maximisation
223+
# direction encoded in the sign).
224+
for name, weight in parameters:
225+
binsize[name] *= weight
226+
227+
# Restore the original timeslice MultiIndex structure.
228+
if "timeslice" in objectives.indexes:
229+
binsize = binsize.set_index(timeslice=index_names)
230+
231+
# Use the smallest weighted objective across replacements as the
232+
# bin width for each objective.
195233
binsize = binsize.min("replacement")
196234

197-
return lexical_comparison(objectives, binsize, order=order, bin_last=False).rank(
198-
"replacement"
199-
)
235+
# Construct lexicographically comparable tuples and rank the
236+
# replacement options accordingly.
237+
return _lexical_comparison(
238+
objectives,
239+
binsize,
240+
order=order,
241+
keep_last_continuous=True,
242+
).rank("replacement")
200243

201244

202245
@register_decision(name="retro_lexo")
203246
def retro_lexical_comparison(
204-
objectives: Dataset, parameters: PARAMS_TYPE | Sequence[tuple[str, float]]
247+
objectives: Dataset,
248+
parameters: PARAMS_TYPE | Sequence[tuple[str, float]],
205249
) -> DataArray:
206-
"""Lexical comparison over the objectives.
250+
"""Lexicographic comparison using current assets as reference.
207251
208252
Lexical comparison operates by binning the objectives into bins of width
209-
w_i = p_i o_i, where i are the current assets. Once binned, dimensions other
210-
than `asset` and `replacement` are reduced by taking the max, e.g. the
211-
largest constraint. Finally, the objectives are ranked lexographically, in
212-
the order given by the parameters.
253+
254+
w_i = p_i o_i,
255+
256+
where o_i are the objective values of the current assets. Once binned,
257+
dimensions other than ``asset`` and ``replacement`` are reduced by taking
258+
the maximum (e.g. the largest constraint). Finally, the objectives are
259+
ranked lexicographically in the order given by ``parameters``.
213260
214261
The result is an array of tuples which can subsequently be compared
215262
lexicographically.
216263
"""
217-
from muse.utilities import lexical_comparison
218-
219264
assert len(parameters) > 0
265+
266+
# Convert (name, min/max, coefficient) specifications to
267+
# (name, signed coefficient), where the sign encodes whether the
268+
# objective should be minimised or maximised.
220269
if len(parameters[0]) == 3:
221-
parameters = [(u[0], coeff_sign(u[1], u[2])) for u in parameters]
270+
parameters = [
271+
(name, coeff_sign(minmax, coeff)) for name, minmax, coeff in parameters
272+
]
273+
274+
# Retrofitting compares candidate replacements against the current
275+
# asset, so every asset must also appear amongst the replacements.
222276
assert objectives.asset.isin(objectives.replacement).all()
223-
assert set(objectives.data_vars).issuperset([u[0] for u in parameters])
224277

225-
order = [u[0] for u in parameters]
226-
binsize = Dataset(dict(parameters)) * objectives.sel(replacement=objectives.asset)
227-
return lexical_comparison(objectives, binsize, order=order, bin_last=False).rank(
228-
"replacement"
229-
)
278+
# Lexicographic priority of the objectives.
279+
order = tuple(name for name, _ in parameters)
280+
281+
# All requested objectives must be present.
282+
assert set(objectives.data_vars).issuperset(order)
283+
284+
# Define the bin widths as
285+
#
286+
# w_i = p_i o_i,
287+
#
288+
# where o_i are the objective values of the current assets. The
289+
# objective values are selected by matching each asset to itself
290+
# along the replacement dimension.
291+
binwidths = Dataset(dict(parameters)) * objectives.sel(replacement=objectives.asset)
292+
293+
# Construct lexicographically comparable tuples and rank the
294+
# replacement options accordingly.
295+
return _lexical_comparison(
296+
objectives,
297+
binwidths,
298+
order=order,
299+
keep_last_continuous=True,
300+
).rank("replacement")
301+
302+
303+
def _lexical_comparison(
304+
objectives: xr.Dataset,
305+
binwidths: xr.Dataset,
306+
order: Sequence[Hashable],
307+
*,
308+
keep_last_continuous: bool = False,
309+
) -> xr.DataArray:
310+
"""Lexical comparison over the objectives.
311+
312+
Lexical comparison operates by binning the objectives into bins of width
313+
``binwidths``. Once binned, dimensions other than ``asset`` and
314+
``replacement`` are reduced by taking the maximum (e.g. the largest
315+
constraint). Finally, the objectives are ranked lexicographically in the
316+
order given by ``order``.
317+
318+
Arguments:
319+
objectives: xr.Dataset containing the objectives to rank.
320+
binwidths: Bin widths used to discretise the objectives.
321+
order: Order in which objectives are compared lexicographically.
322+
keep_last_continuous: Whether the final objective should be left as a
323+
continuous value, rather than being discretised.
324+
325+
Result:
326+
An array of tuples which can subsequently be compared
327+
lexicographically.
328+
"""
329+
# Restrict to the objectives participating in the comparison and
330+
# temporarily flatten the timeslice MultiIndex to avoid Dataset
331+
# assignment issues in xarray.
332+
result = drop_timeslice(objectives[list(order)]).copy()
333+
334+
# All objectives are discretised except, optionally, the final
335+
# tie-breaking objective.
336+
discretized = order[:-1] if keep_last_continuous else order
337+
338+
# Convert objectives to integer-valued bins.
339+
for name in discretized:
340+
result[name] = np.floor(result[name] / binwidths[name]).astype(np.int64)
341+
342+
# Preserve the final objective as a continuous quantity for
343+
# tie-breaking, while still normalising by its bin width.
344+
if keep_last_continuous:
345+
name = order[-1]
346+
result[name] = result[name] / binwidths[name]
347+
348+
# Combine the ordered objectives into tuples that can be compared
349+
# lexicographically.
350+
return result.to_array(dim="objective").reduce(tupled_dimension, dim="objective")
230351

231352

232353
def _epsilon_constraints(

src/muse/utilities.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -310,47 +310,6 @@ def tupled_dimension(array: np.ndarray, axis: int):
310310
return result.reshape(*shape[:-1])
311311

312312

313-
def lexical_comparison(
314-
objectives: xr.Dataset,
315-
binsize: xr.Dataset,
316-
order: Sequence[Hashable] | None = None,
317-
bin_last: bool = True,
318-
) -> xr.DataArray:
319-
"""Lexical comparison over the objectives.
320-
321-
Lexical comparison operates by binning the objectives into bins of width
322-
`binsize`. Once binned, dimensions other than `asset` and `technology` are
323-
reduced by taking the max, e.g. the largest constraint. Finally, the
324-
objectives are ranked lexographically, in the order given by the parameters.
325-
326-
Arguments:
327-
objectives: xr.Dataset containing the objectives to rank
328-
binsize: bin size, minimization direction
329-
(+ -> minimize, - -> maximize), and (optionally) order of
330-
lexicographical comparison. The order is the one given
331-
`binsize.data_vars` if the argument `order` is None.
332-
order: Optional array indicating the order in which to rank the tuples.
333-
bin_last: Whether the last metric should be binned, or whether it
334-
should be left as a the type it already is (e.g. no flooring and
335-
no turning to integer.)
336-
337-
Result:
338-
An array of tuples which can subsequently be compared lexicographically.
339-
"""
340-
if order is None:
341-
order = [u for u in binsize.data_vars]
342-
343-
assert set(order) == set(binsize.data_vars)
344-
assert set(order).issuperset(objectives)
345-
346-
result = objectives[order]
347-
for name in order if bin_last else order[:-1]:
348-
result[name] = np.floor(result[name] / binsize[name]).astype(int)
349-
if not bin_last:
350-
result[order[-1]] = result[order[-1]] / binsize[order[-1]]
351-
return result.to_array(dim="variable").reduce(tupled_dimension, dim="variable")
352-
353-
354313
def merge_assets(
355314
capa_a: xr.DataArray,
356315
capa_b: xr.DataArray,

tests/test_decisions.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,38 @@ def normalize(objective):
5555
def test_lexical():
5656
"""Test lexical comparison against hand-constructed tuples."""
5757
shape = (5, 10)
58+
59+
# Three objectives evaluated for each asset/replacement pair.
5860
a = rand(*shape) * 10 - 5
5961
b = rand(*shape) * 10 - 5
6062
c = rand(*shape) * 10 - 5
6163

62-
parameters = [("b", -rand() * 0.1), ("a", rand() * 0.1), ("c", rand())]
64+
# Objectives are compared lexicographically in the order
65+
# b -> a -> c. Negative weights indicate maximisation.
66+
parameters = [
67+
("b", -rand() * 0.1),
68+
("a", rand() * 0.1),
69+
("c", rand()),
70+
]
6371
param_dict = dict(parameters)
6472

73+
# Lexo defines bin widths as
74+
#
75+
# w_i = min_j(p_i o_i^j),
76+
#
77+
# over the replacement dimension. For maximisation objectives,
78+
# the sign convention means this becomes the negative of the
79+
# largest weighted objective.
6580
mina = (a * param_dict["a"]).min(1)
6681
minb = -(b * abs(param_dict["b"])).max(1)
6782
minc = (c * param_dict["c"]).min(1)
6883

84+
# Construct the expected lexicographic tuples by hand:
85+
#
86+
# 1. Bin the first two objectives by flooring after
87+
# normalisation by their bin widths.
88+
# 2. Leave the final objective continuous so that it acts
89+
# as a tie-breaker.
6990
expected = np.zeros(shape=shape, dtype=object)
7091
for i in range(shape[0]):
7192
for j in range(shape[1]):
@@ -82,10 +103,19 @@ def test_lexical():
82103
"c": (("asset", "replacement"), c),
83104
}
84105
)
85-
objectives["asset"] = choice(objectives.replacement, shape[0], replace=False)
106+
107+
# Associate each asset with one of the replacement options.
108+
objectives["asset"] = choice(
109+
objectives.replacement,
110+
shape[0],
111+
replace=False,
112+
)
86113

87114
actual = lexical_comparison(objectives, parameters)
88115
assert actual.shape == expected.shape
116+
117+
# The decision function returns the ranks of the lexicographic
118+
# tuples along the replacement dimension.
89119
for i in range(shape[0]):
90120
assert actual.values[i] == approx(rankdata(expected[i]))
91121

0 commit comments

Comments
 (0)