Source code for negmas.outcomes.outcome_ops

"""
Functions for handling outcome spaces
"""
from __future__ import annotations


import math
import numbers
from typing import TYPE_CHECKING, Any, Sequence, overload

import numpy as np

from negmas.generics import ienumerate, iget, ikeys
from negmas.helpers.numeric import isint, isreal
from negmas.outcomes.cardinal_issue import CardinalIssue

from .base_issue import Issue


if TYPE_CHECKING:
    from .common import Outcome, OutcomeRange
    from .outcome_space import DistanceFun, OutcomeSpace

__all__ = [
    "dict2outcome",
    "outcome2dict",
    "outcome_in_range",
    "outcome_is_complete",
    "outcome_types_are_ok",
    "outcome_is_valid",
    "generalized_minkowski_distance",
    "min_dist",
]


def _is_single(x):
    """Checks whether a value is a single value which is defined as either a string or not an Iterable."""

    return isinstance(x, str) or isinstance(x, numbers.Number)


@overload
def outcome2dict(outcome: None, issues: Sequence[str | Issue]) -> None:
    ...


@overload
def outcome2dict(outcome: Outcome, issues: Sequence[str | Issue]) -> dict[str, Any]:
    ...


[docs] def outcome2dict( outcome: Outcome | None, issues: Sequence[str | Issue] ) -> dict[str, Any] | None: """ Converts the outcome to a dict no matter what was its type. Args: outcome: The outcome to be converted (as a tuple) issues: The issues/issue names used as dictionary keys in the output Remarks: - If called with a dict that is already converted, it will just return it. - None is converted to None Examples: >>> from negmas import make_issue >>> issues = [make_issue(10, "price"), make_issue(5, "quantity")] >>> outcome2dict((3, 4), issues=issues) {'price': 3, 'quantity': 4} You can also use issue names without creating Issue objects >>> issues = ["price", "quantity"] >>> outcome2dict((3, 4), issues=issues) {'price': 3, 'quantity': 4} Trying to convert an already converted outcome does nothing >>> issues = ["price", "quantity"] >>> outcome2dict(outcome2dict((3, 4), issues=issues), issues=issues) {'price': 3, 'quantity': 4} """ if outcome is None: return None # if len(outcome) != len(issues): # raise ValueError( # f"Cannot convert {len(outcome)} valued outcome to a dict with {len(issues)} issues" # ) if isinstance(outcome, np.ndarray): outcome = tuple(outcome.tolist()) # type: ignore if isinstance(outcome, dict): names = {_.name if isinstance(_, Issue) else _ for _ in issues} for k in outcome.keys(): if k not in names: raise ValueError( f"{k} not in the issue names ({names}). An invalid dict is given!!" ) return outcome return dict(zip([_ if isinstance(_, str) else _.name for _ in issues], outcome))
[docs] def dict2outcome( d: dict[str, Any] | tuple | None, issues: tuple[str | Issue, ...] ) -> Outcome | None: """ Converts the outcome to a tuple no matter what was its type Args: d: the dictionary to be converted issues: A list of issues or issue names (as strings) to order the tuple Remarks: - If called with a tuple outcome, it will issue a warning """ if d is None: return None if not isinstance(d, dict): return tuple(d) if isinstance(d, np.ndarray): return tuple(d.tolist()) return tuple(d[_ if isinstance(_, str) else _.name] for _ in issues)
[docs] def generalized_minkowski_distance( a: Outcome, b: Outcome, outcome_space: OutcomeSpace | None, *, weights: Sequence[float] | None = None, dist_power: float = 2, ) -> float: r""" Calculates the difference between two outcomes given an outcome-space (optionally with issue weights). This is defined as the distance. Args: outcome_space: The outcome space used for comparison (If None an apporximate implementation is provided) a: first outcome b: second outcome weights: Issue weights dist_power: The exponent used when calculating the distance Remarks: - Implements the following distance measure: .. math:: d(a, b) = \left( \sum_{i=1}^{N} w_i {\left| a_i - b_i \right|}^p \right)^{frac{1}{p}} where $a, b$ are the outocmes, $x_i$ is value for issue $i$ of outcoem $x$, $w_i$ is the weight of issue $i$ and $p$ is the `dist_power` passsed. Categorical issue differences is defined as $1$ if the values are not equal and $0$ otherwise. - Becomes the Euclidean distance if all issues are numeric and no weights are given - You can control the power: - Setting it to 1 is the city-block distance - Setting it to 0 is the maximum issue difference """ from negmas.outcomes import CartesianOutcomeSpace if not weights: weights = [1] * len(a) if dist_power <= 0 or dist_power == float("inf"): if not isinstance(outcome_space, CartesianOutcomeSpace): return max( (w * abs(x - y)) if (isint(x) or isreal(x)) and (isint(y) or isreal(y)) else (w * int(x == y)) for w, x, y in zip(weights, a, b) ) d = float("-inf") for issue, w, x, y in zip(outcome_space.issues, weights, a, b): if isinstance(issue, CardinalIssue): c = w * abs(x - y) else: c = w * int(x == y) if c > d: d = c return d if not isinstance(outcome_space, CartesianOutcomeSpace): return math.pow( sum( (w * math.pow(abs(x - y), dist_power)) if (isint(x) or isreal(x)) and (isint(y) or isreal(y)) else (w * int(x == y)) for w, x, y in zip(weights, a, b) ), 1.0 / dist_power, ) d = 0.0 for issue, w, x, y in zip(outcome_space.issues, weights, a, b): if isinstance(issue, CardinalIssue): d += w * math.pow(abs(x - y), dist_power) continue d += w * int(x == y) return math.pow(d, 1.0 / dist_power)
[docs] def min_dist( test_outcome: Outcome, outcomes: Sequence[Outcome], outcome_space: OutcomeSpace | None, distance_fun: DistanceFun = generalized_minkowski_distance, **kwargs, ) -> float: """ Minimum distance between an outcome and a set of outcomes in an outcome-spaceself. Args: test_outcome: The outcome tested outcomes: A sequence of outcomes to compare to outcome_space: The outcomespace used for comparison distance_fun: The distance function kwargs: Paramters to pass to the distance function See Also: `generalized_euclidean_distance` """ if not outcomes: return 1.0 return min(distance_fun(test_outcome, _, outcome_space, **kwargs) for _ in outcomes)
[docs] def outcome_is_valid(outcome: Outcome, issues: tuple[Issue, ...]) -> bool: """ Test validity of an outcome given a set of issues. Examples: >>> from negmas.outcomes import make_issue >>> issues = [make_issue((0.5, 2.0), 'price'), make_issue(['2018.10.'+ str(_) for _ in range(1, 4)], 'date')\ , make_issue(20, 'count')] >>> for _ in issues: print(_) price: (0.5, 2.0) date: ['2018.10.1', '2018.10.2', '2018.10.3'] count: (0, 19) >>> print([outcome_is_valid({'price':3.0}, issues), outcome_is_valid({'date': '2018.10.4'}, issues)\ , outcome_is_valid({'count': 21}, issues)]) [False, False, False] >>> valid_incomplete = {'price': 1.9} >>> print(outcome_is_valid(valid_incomplete, issues)) False >>> print(outcome_is_complete(valid_incomplete, issues)) False >>> valid_incomplete.update({'date': '2018.10.2', 'count': 5}) >>> print(outcome_is_complete(valid_incomplete, issues)) True Args: outcome: outcome tested. issues: issues """ try: o = dict2outcome(outcome, issues) if not o: return False return all(issue.is_valid(v) for v, issue in zip(o, issues, strict=True)) except Exception: return False
# outcome_dict = outcome2dict(outcome, [_.name for _ in issues]) # # for val, issue in zip(outcome, issues): # for key in outcome_dict.keys(): # if str(issue.name) == str(key): # break # else: # continue # # value = iget(outcome_dict, key) # return issue.is_valid(value) # if isinstance(issue, RangeIssue) and ( # isinstance(value, str) or not issue.min_value <= value <= issue.max_value # ): # return False # # if isinstance(issue, CardinalIssue) and ( # isinstance(value, str) or not issue.min_value <= value <= issue.max_value # ): # return False # if isinstance(issue._values, list) and value not in issue._values: # return False
[docs] def outcome_types_are_ok(outcome: Outcome, issues: tuple[Issue, ...]) -> bool: """ Checks that the types of all issue values in the outcome are correct """ if not issues or not outcome: return True for v, i in zip(outcome, issues): if i.value_type is None: continue if not isinstance(v, i.value_type): return False return True
def cast_value_types(outcome: Outcome, issues: tuple[Issue, ...]) -> Outcome: """ Casts the types of values in the outcomes to the value-type of each issue (if given) """ if not issues or not outcome: return outcome new_outcome = list(outcome) for indx, (v, i) in enumerate(zip(outcome, issues)): if i.value_type is None: continue new_outcome[indx] = i.value_type(v) # type: ignore I know that value_type is callable return tuple(new_outcome)
[docs] def outcome_is_complete(outcome: Outcome, issues: tuple[Issue, ...]) -> bool: """ Tests that the outcome is valid and complete. Examples: >>> from negmas.outcomes import make_issue >>> issues = [make_issue((0.5, 2.0), 'price'), make_issue(['2018.10.'+ str(_) for _ in range(1, 4)], 'date')\ , make_issue(20, 'count')] >>> for _ in issues: print(_) price: (0.5, 2.0) date: ['2018.10.1', '2018.10.2', '2018.10.3'] count: (0, 19) >>> print([outcome_is_complete({'price':3.0}, issues), outcome_is_complete({'date': '2018.10.4'}, issues)\ , outcome_is_complete({'count': 21}, issues)]) [False, False, False] >>> valid_incomplete = {'price': 1.9} >>> print(outcome_is_complete(valid_incomplete, issues)) False >>> valid_incomplete.update({'date': '2018.10.2', 'count': 5}) >>> print(outcome_is_complete(valid_incomplete, issues)) True >>> invalid = {'price': 2000, 'date': '2018.10.2', 'count': 5} >>> print(outcome_is_complete(invalid, issues)) False >>> invalid = {'unknown': 2000, 'date': '2018.10.2', 'count': 5} >>> print(outcome_is_complete(invalid, issues)) False Args: outcome: outcome tested which much contain valid values all issues if it is to be considered complete. issues: issues Returns: Union[bool, Tuple[bool, str]]: If return_problem is True then a second return value contains a string with reason of failure """ try: outcome2dict(outcome, issues) except ValueError: return False if len(outcome) != len(issues): return False valid = outcome_is_valid(outcome, issues) if not valid: return False outcome_keys = [str(k) for k in ikeys(outcome)] for issue in issues: if str(issue.name) not in outcome_keys: return False return True
[docs] def outcome_in_range( outcome: Outcome, outcome_range: OutcomeRange, *, strict=False, fail_incomplete=False, ) -> bool: """ Tests that the outcome is contained within the given range of outcomes. An outcome range defines a value or a range of values for each issue. Args: outcome: "Outcome" being tested outcome_range: "Outcome" range being tested against strict: Whether to enforce that all issues in the outcome must be mentioned in the outcome_range fail_incomplete: If True then outcomes that do not sepcify a value for all keys in the outcome_range will be considered not falling within it. If False then these outcomes will be considered falling within the range given that the values for the issues mentioned in the outcome satisfy the range constraints. Examples: >>> outcome_range = { ... "price": (0.0, 2.0), ... "distance": [0.3, 0.4], ... "type": ["a", "b"], ... "area": 3, ... } >>> outcome_range_2 = {"price": [(0.0, 1.0), (1.5, 2.0)], "area": [(3, 4), (7, 9)]} >>> outcome_in_range({"price": 3.0}, outcome_range) False >>> outcome_in_range({"date": "2018.10.4"}, outcome_range) True >>> outcome_in_range({"date": "2018.10.4"}, outcome_range, strict=True) False >>> outcome_in_range({"area": 3}, outcome_range, fail_incomplete=True) False >>> outcome_in_range({"area": 3}, outcome_range) True >>> outcome_in_range({"type": "c"}, outcome_range) False >>> outcome_in_range({"type": "a"}, outcome_range) True >>> outcome_in_range({"date": "2018.10.4"}, outcome_range_2) True >>> outcome_in_range({"area": 3.1}, outcome_range_2) True >>> outcome_in_range({"area": 3}, outcome_range_2) False >>> outcome_in_range({"area": 5}, outcome_range_2) False >>> outcome_in_range({"price": 0.4}, outcome_range_2) True >>> outcome_in_range({"price": 0.4}, outcome_range_2, fail_incomplete=True) False >>> outcome_in_range({"price": 1.2}, outcome_range_2) False >>> outcome_in_range({"price": 0.4, "area": 3.9}, outcome_range_2) True >>> outcome_in_range({"price": 0.4, "area": 10}, outcome_range_2) False >>> outcome_in_range({"price": 1.2, "area": 10}, outcome_range_2) False >>> outcome_in_range({"price": 1.2, "area": 4}, outcome_range_2) False >>> outcome_in_range({"type": "a"}, outcome_range_2) True >>> outcome_in_range({"type": "a"}, outcome_range_2, strict=True) False >>> outcome_range = {"price": 10} >>> outcome_in_range({"price": 10}, outcome_range) True >>> outcome_in_range({"price": 11}, outcome_range) False Returns: bool: Success or failure Remarks: Outcome ranges specify regions in an outcome space. They can have any of the following conditions: - A key/issue not mentioned in the outcome range does not add any constraints meaning that **All** values are acceptable except if strict == True. If strict == True then *NO* value will be accepted for issues not in the outcome_range. - A key/issue with the value None in the outcome range means **All** values on this issue are acceptable. This is the same as having this key/issue removed from the outcome space - A key/issue withe the value [] (empty list) accepts *NO* outcomes - A key/issue with a single value means that it is the only one acceptable - A key/issue with a single 2-items tuple (min, max) means that any value within that range is acceptable. - A key/issue with a list of values means an output is acceptable if it falls within the condition specified by any of the values in the list (list == union). Each such value can be a single value, a 2-items tuple or another list. Notice that lists of lists can always be combined into a single list of values """ if ( fail_incomplete and len(set(ikeys(outcome_range)).difference(ikeys(outcome))) > 0 ): return False for key, value in ienumerate(outcome): if key not in ikeys(outcome_range): if strict: return False continue values = iget(outcome_range, key, None) if values is None: return False if _is_single(values) and value != values: return False if isinstance(values, tuple) and not values[0] < value < values[1]: return False if isinstance(values, list): for constraint in values: if _is_single(constraint): if value == constraint: break elif isinstance(constraint, list): if value in constraint: break elif isinstance(constraint, tuple): if constraint[0] < value < constraint[1]: break else: return False continue return True