from __future__ import annotations
import numbers
import random
from abc import abstractmethod
import numpy as np
from negmas.helpers.numeric import get_one_float
from negmas.helpers.prob import Distribution, Real, ScipyDistribution
from negmas.outcomes import Outcome
from .base_ufun import BaseUtilityFunction, _ExtremelyDynamic
__all__ = ["ProbUtilityFunction"]
[docs]
class ProbUtilityFunction(_ExtremelyDynamic, BaseUtilityFunction):
"""A probablistic utility function. One that returns a probability distribution when called"""
[docs]
@abstractmethod
def eval(self, offer: Outcome) -> Distribution:
...
[docs]
def to_prob(self) -> ProbUtilityFunction:
return self
[docs]
@classmethod
def generate_bilateral(
cls,
outcomes: int | list[Outcome],
conflict_level: float = 0.5,
conflict_delta=0.005,
scale: float | tuple[float, float] = 0.5,
) -> tuple[ProbUtilityFunction, ProbUtilityFunction]:
"""Generates a couple of utility functions
Args:
n_outcomes (int): number of outcomes to use
conflict_level: How conflicting are the two ufuns to generate.
1.0 means maximum conflict.
conflict_delta: How variable is the conflict at different outcomes.
Examples:
>>> from negmas.preferences import UtilityFunction
>>> from negmas.preferences import conflict_level
>>> u1, u2 = UtilityFunction.generate_bilateral(
... outcomes=10, conflict_level=0.0, conflict_delta=0.0
... )
>>> print(conflict_level(u1, u2, outcomes=10))
0.0
>>> u1, u2 = UtilityFunction.generate_bilateral(
... outcomes=10, conflict_level=1.0, conflict_delta=0.0
... )
>>> print(conflict_level(u1, u2, outcomes=10))
1.0
>>> u1, u2 = UtilityFunction.generate_bilateral(
... outcomes=10, conflict_level=0.5, conflict_delta=0.0
... )
>>> 0.0 < conflict_level(u1, u2, outcomes=10) < 1.0
True
"""
from negmas.preferences.prob.mapping import ProbMappingUtilityFunction
if isinstance(outcomes, int):
outcomes = [(_,) for _ in range(outcomes)]
n_outcomes = len(outcomes)
u1 = np.random.random(n_outcomes)
rand = np.random.random(n_outcomes)
if conflict_level > 0.5:
conflicting = 1.0 - u1 + conflict_delta * np.random.random(n_outcomes)
u2 = conflicting * conflict_level + rand * (1 - conflict_level)
elif conflict_level < 0.5:
same = u1 + conflict_delta * np.random.random(n_outcomes)
u2 = same * (1 - conflict_level) + rand * conflict_level
else:
u2 = rand
# todo implement win_win correctly. Order the ufun then make outcomes with good outcome even better and vice
# versa
# u2 += u2 * win_win
# u2 += np.random.random(n_outcomes) * conflict_delta
u1 -= u1.min()
u2 -= u2.min()
u1 = u1 / u1.max()
u2 = u2 / u2.max()
if random.random() > 0.5:
u1, u2 = u2, u1
return (
ProbMappingUtilityFunction(
dict(
zip(
outcomes,
(
ScipyDistribution(
type="unifomr", loc=_, scale=get_one_float(scale)
)
for _ in u1
),
)
)
),
ProbMappingUtilityFunction(
dict(
zip(
outcomes,
(
ScipyDistribution(
type="unifomr", loc=_, scale=get_one_float(scale)
)
for _ in u2
),
)
)
),
)
[docs]
@classmethod
def generate_random_bilateral(
cls, outcomes: int | list[Outcome], scale: float = 0.5
) -> tuple[ProbUtilityFunction, ProbUtilityFunction]:
"""Generates a couple of utility functions
Args:
n_outcomes (int): number of outcomes to use
conflict_level: How conflicting are the two ufuns to generate. 1.0 means maximum conflict.
conflict_delta: How variable is the conflict at different outcomes.
zero_summness: How zero-sum like are the two ufuns.
"""
from negmas.preferences.prob.mapping import ProbMappingUtilityFunction
if isinstance(outcomes, int):
outcomes = [(_,) for _ in range(outcomes)]
n_outcomes = len(outcomes)
u1 = np.random.random(n_outcomes)
u2 = np.random.random(n_outcomes)
u1 -= u1.min()
u2 -= u2.min()
u1 /= u1.max()
u2 /= u2.max()
return (
ProbMappingUtilityFunction(
dict(
zip(
outcomes,
(
ScipyDistribution(
type="unifomr", loc=_, scale=get_one_float(scale)
)
for _ in u1
),
)
)
),
ProbMappingUtilityFunction(
dict(
zip(
outcomes,
(
ScipyDistribution(
type="unifomr", loc=_, scale=get_one_float(scale)
)
for _ in u2
),
)
)
),
)
[docs]
@classmethod
def generate_random(
cls,
n: int,
outcomes: int | list[Outcome],
normalized: bool = True,
scale: float | tuple[float, float] = 0.5,
) -> list[ProbUtilityFunction]:
"""Generates N mapping utility functions
Args:
n: number of utility functions to generate
outcomes: number of outcomes to use
normalized: if true, the resulting ufuns will be normlized between zero and one.
"""
from negmas.preferences.prob.mapping import ProbMappingUtilityFunction
if isinstance(outcomes, int):
outcomes = [(_,) for _ in range(outcomes)]
else:
outcomes = list(outcomes)
n_outcomes = len(outcomes)
ufuns = []
for _ in range(n):
u1 = np.random.random(n_outcomes)
if normalized:
u1 -= u1.min()
u1 /= u1.max()
ufuns.append(
ProbMappingUtilityFunction(
dict(
zip(
outcomes,
(
ScipyDistribution(
type="unifomr", loc=_, scale=get_one_float(scale)
)
for _ in u1
),
)
)
)
)
return ufuns
[docs]
def __call__(self, offer: Outcome | None) -> Distribution:
"""Calculate the utility_function value for a given outcome.
Args:
offer: The offer to be evaluated.
Remarks:
- It calls the abstract method `eval` after opationally adjusting the
outcome type.
- It is preferred to override eval instead of directly overriding this method
- You cannot return None from overriden eval() functions but raise an exception (ValueError) if it was
not possible to calculate the Value.
- Return a float from your `eval` implementation.
- Return the reserved value if the offer was None
Returns:
The utility of the given outcome
"""
if offer is None:
return ScipyDistribution("uniform", loc=self.reserved_value, scale=0.0)
v = self.eval(offer)
if isinstance(v, float):
return Real(v)
return v
class ProbAdapter(ProbUtilityFunction):
"""
Adapts any utility function to act as a probabilistic utility function (i.e. returning a `Distribution` )
"""
def __init__(self, ufun: BaseUtilityFunction):
self._ufun = ufun
def eval(self, offer: Outcome) -> Distribution:
v = self._ufun.eval(offer)
if isinstance(v, numbers.Real):
return Real(v)
return v # type: ignore
def to_stationary(self):
return ProbAdapter(self._ufun.to_stationary())