Source code for tyche.individuals

"""
This module contains classes used to construct ontological knowledge bases
of individuals, the probabilistic beliefs about them (concepts), and the
probabilistic relationships between them (roles). These belief models
can be used as contexts to evaluate aleatoric description logic (ADL)
sentences. This module also contains many learning strategies that may
be used to update your belief models based upon ADL observations.
"""
from typing import TypeVar, Callable, get_type_hints, Final, Type, cast, Generic, Optional

import numpy as np

from tyche.language import ExclusiveRoleDist, TycheLanguageException, TycheContext, Concept, ADLNode, Expectation, \
    Role, RoleDistributionEntries, ALWAYS, CompatibleWithADLNode, CompatibleWithRole, NEVER, Constant, Given, \
    ReferenceBackedRole, RoleDist

from tyche.probability import uncertain_bayes_rule
from tyche.references import SymbolReference, FieldSymbolReference, GuardedSymbolReference, FunctionSymbolReference, \
    BakedSymbolReference


TycheConceptValue = TypeVar("TycheConceptValue", float, int, bool)
TycheRoleValue = TypeVar("TycheRoleValue", bound=RoleDist)

# Marks instance variables of classes as probabilities that
# may be accessed by Tyche formulas.
TycheConceptField = TypeVar("TycheConceptField", float, int, bool)
TycheRoleField = TypeVar("TycheRoleField", bound=RoleDist)


[docs]class TycheIndividualsException(Exception): """ An exception type that is thrown when errors occur in the construction or use of individuals. """ def __init__(self, message: str): self.message = "TycheIndividualsException: " + message
AccessedValueType = TypeVar("AccessedValueType")
[docs]class TycheAccessorStore(Generic[AccessedValueType]): """ Stores a set of ways to access concepts or roles from a Tyche individual. """ # Stores all the accessors for atoms & roles of Individual subclass types. __accessor_store_maps: Final[dict[type, dict[type, 'TycheAccessorStore']]] = {} # Stores all the non-field accessor references for atoms & roles of Individual subclass types. __accessor_ref_maps: Final[dict[type, dict[type, dict[str, SymbolReference[AccessedValueType]]]]] = {} def __init__(self, type_name: str, accessors: dict[str, SymbolReference[AccessedValueType]]): self.type_name = type_name self.accessors = accessors self.all_symbols = set(accessors.keys())
[docs] def contains(self, symbol: str) -> bool: """ Returns whether this store contains a reference to the given symbol. """ return symbol in self.accessors
[docs] def get_reference(self, symbol: str) -> SymbolReference[AccessedValueType]: """ Returns the reference to access the given symbol. """ try: return self.accessors[symbol] except KeyError: raise TycheLanguageException("Unknown {} {}".format( self.type_name, symbol ))
[docs] def get(self, obj: any, symbol: str) -> AccessedValueType: """ Accesses the given symbol from the given object. """ return self.get_reference(symbol).get(obj)
[docs] def set(self, obj: any, symbol: str, value: AccessedValueType): """ Modifies the given symbol in the given object. """ return self.get_reference(symbol).set(obj, value)
[docs] @staticmethod def get_accessor_stores_map(accessor_type: type) -> dict[type, 'TycheAccessorStore']: """ Returns a map from Individual subclass types to their accessor stores for the given type of accessors. """ accessor_store_maps = TycheAccessorStore.__accessor_store_maps if accessor_type not in accessor_store_maps: accessor_store_maps[accessor_type] = {} return accessor_store_maps[accessor_type]
[docs] @staticmethod def get_accessor_ref_map(accessor_type: type) -> dict[type, dict[str, SymbolReference[AccessedValueType]]]: """ Returns a map from Individual subclass types to their accessor stores for the given type of accessors. """ accessor_ref_maps = TycheAccessorStore.__accessor_ref_maps if accessor_type not in accessor_ref_maps: accessor_ref_maps[accessor_type] = {} return accessor_ref_maps[accessor_type]
[docs] @staticmethod def get_or_populate_for( obj_type: type, accessor_type: type, field_type_hint: type, type_name: str ) -> 'TycheAccessorStore': """ Gets or populates the set of accessors associated with obj_type, and returns them. """ stores_cache_map = TycheAccessorStore.get_accessor_stores_map(accessor_type) if obj_type in stores_cache_map: return stores_cache_map[obj_type] # Get the references populated by annotations. refs_map = TycheAccessorStore.get_accessor_ref_map(accessor_type) method_references: dict[str, SymbolReference[AccessedValueType]] = {} for parent_or_obj_type in reversed(obj_type.mro()): if parent_or_obj_type in refs_map: method_references.update(refs_map[parent_or_obj_type]) # Get the set of variables that can be accessed from obj_type. # These include the fields from parent classes automatically. fields: set[str] = set() for symbol, type_hint in get_type_hints(obj_type).items(): if type_hint == field_type_hint: fields.add(symbol) if symbol in method_references: raise TycheIndividualsException( "The {} {} in type {} cannot be provided as both a variable and a method".format( type_name, symbol, obj_type.__name__ )) # Check that all the symbol names are valid. for symbol_name, name_set in [("field", fields), ("method", method_references.keys())]: symbol_type_name = type_name.capitalize() context = "type {}".format(obj_type.__name__) for name in name_set: Concept.check_symbol(name, symbol_name=symbol_name, symbol_type_name=symbol_type_name, context=context) # Store the accessors in the type object. var_references = {symbol: FieldSymbolReference(symbol) for symbol in fields} all_references = {**method_references, **var_references} accessors = TycheAccessorStore(type_name, all_references) stores_cache_map[obj_type] = accessors return accessors
[docs]class ConceptFunctionSymbolReference(FunctionSymbolReference): """ Represents a reference to a concept, with additional information about the concept attached. """ def __init__( self, symbol: str, fget: Callable[[any], TycheConceptValue], fset: Optional[Callable[[any, TycheConceptValue], None]] = None, learning_strat: Optional['ConceptLearningStrategy'] = None ): super().__init__(symbol, fget, fset) self.learning_strat = learning_strat
[docs]class RoleFunctionSymbolReference(FunctionSymbolReference): """ Represents a reference to a concept, with additional information about the concept attached. """ def __init__( self, symbol: str, fget: Callable[[any], TycheRoleValue], fset: Optional[Callable[[any, TycheRoleValue], None]] = None, learning_strat: Optional['RoleLearningStrategy'] = None ): super().__init__(symbol, fget, fset) self.learning_strat = learning_strat
LearningStrategyType = TypeVar("LearningStrategyType", bound='LearningStrategy') SelfType_IndividualPropertyDecorator = TypeVar( "SelfType_IndividualPropertyDecorator", bound="IndividualPropertyDecorator")
[docs]class IndividualPropertyDecorator(Generic[AccessedValueType, LearningStrategyType]): """ A decorator to mark methods as providing the value of a concept or role, and to provide additional metadata or learning functions. """ def __init__( self: SelfType_IndividualPropertyDecorator, type_name: str, fget: Callable[['Individual'], AccessedValueType], *, symbol: Optional[str] = None): self.type_name = type_name self.custom_symbol: Optional[str] = symbol self.symbol: str = symbol if symbol is not None else "this symbol" self.fget: Callable[['Individual'], AccessedValueType] = fget self.fset: Optional[Callable[['Individual', AccessedValueType], None]] = None self.learning_strat: Optional[LearningStrategyType] = None def __call__(self, instance: Optional['Individual'] = None) -> AccessedValueType: if instance is None: raise TycheIndividualsException(f"Cannot access {self.symbol} without an instance") return self.fget(instance)
[docs] def learning_func( self, learning_strat: Optional[LearningStrategyType] = None ) -> Callable[ [Callable[['Individual', AccessedValueType], None]], Callable[['Individual', AccessedValueType], None] ]: """ This can be used as a decorator to register a method-based setter for this symbol. """ def decorator( fset: Callable[['Individual', AccessedValueType], None] ) -> Callable[['Individual', AccessedValueType], None]: if self.fset is not None or self.learning_strat is not None: raise TycheIndividualsException(f"{self.symbol} already has a learning function set") self.fset = fset self.learning_strat = learning_strat # Try to detect a potentially common error that is otherwise hard to spot. if self.fset is not None and self.fget.__name__ is not None and self.fget.__name__ == self.fset.__name__: raise TycheIndividualsException( f"The name of the {self.fget.__name__} {self.type_name}'s getter and learning " f"functions must not be the same") return fset return decorator
def _create_symbol_reference(self) -> 'FunctionSymbolReference': return FunctionSymbolReference( self.symbol, cast(Callable[[object], AccessedValueType], self.fget), self.fset ) def __set_name__(self, owner: type, name: str): ref_map = TycheAccessorStore.get_accessor_ref_map(type(self)) if owner not in ref_map: ref_map[owner] = {} # Allow the user to override the symbol that is used. self.symbol = name if self.custom_symbol is None else self.custom_symbol # Add this symbol to the set of function symbols. ref_map[owner][self.symbol] = self._create_symbol_reference() # Replace this decorator object with the original get function in the class. setattr(owner, name, self.fget)
[docs]class TycheConceptDecorator(IndividualPropertyDecorator[TycheConceptValue, 'ConceptLearningStrategy']): """ Marks that a method provides the value of a concept for use in Tyche formulas. The name of the function is used as the name of the concept in formulas. """ field_type_hint: Final[type] = TycheConceptField def __init__( self: SelfType_IndividualPropertyDecorator, fn: Callable[[], TycheConceptValue], *, symbol: Optional[str] = None): super().__init__("concept", fn, symbol=symbol) def _create_symbol_reference(self) -> 'FunctionSymbolReference': return ConceptFunctionSymbolReference( self.symbol, self.fget, self.fset, learning_strat=self.learning_strat ) @staticmethod def get(obj_type: Type['Individual']) -> TycheAccessorStore: return TycheAccessorStore.get_or_populate_for( obj_type, TycheConceptDecorator, TycheConceptDecorator.field_type_hint, "concept" )
[docs]class TycheRoleDecorator(IndividualPropertyDecorator[TycheRoleValue, 'RoleLearningStrategy']): """ Marks that a method provides the value of a role for use in Tyche formulas. The name of the function is used as the name of the role in formulas. """ field_type_hint: Final[type] = TycheRoleField def __init__( self: SelfType_IndividualPropertyDecorator, fn: Callable[[], TycheRoleValue], *, symbol: Optional[str] = None): super().__init__("role", fn, symbol=symbol) def _create_symbol_reference(self) -> 'FunctionSymbolReference': return RoleFunctionSymbolReference( self.symbol, self.fget, self.fset, learning_strat=self.learning_strat ) @staticmethod def get(obj_type: Type['Individual']) -> TycheAccessorStore: return TycheAccessorStore.get_or_populate_for( obj_type, TycheRoleDecorator, TycheRoleDecorator.field_type_hint, "role" )
[docs]def concept( *, symbol: Optional[str] = None ) -> Callable[[Callable[['Individual'], TycheConceptValue]], TycheConceptDecorator]: """ Registers a method as supplying the value of a concept for the evaluation of Tyche expressions. """ def annotator(inner_fn: Callable[[], TycheConceptValue]): return TycheConceptDecorator(inner_fn, symbol=symbol) return annotator
[docs]def role( *, symbol: Optional[str] = None ) -> Callable[[Callable[[], TycheRoleValue]], TycheRoleDecorator]: """ Registers a method as supplying the value of a role for the evaluation of Tyche expressions. """ def annotator(inner_fn: Callable[[], TycheRoleValue]): return TycheRoleDecorator(inner_fn, symbol=symbol) return annotator
SelfType_LearningStrategy = TypeVar("SelfType_LearningStrategy", bound="LearningStrategy")
[docs]class LearningStrategy: """ Applies changes to individuals to update them based upon observations. """ def __init__(self: SelfType_LearningStrategy): pass
[docs] def init_for_new_usage(self) -> SelfType_LearningStrategy: """ If a learning strategy requires per-reference per-individual state, then this can clone the learning strategy for each new individual and reference. Otherwise, the state of this learning strategy will be shared for every individual and reference that uses it (which is fine for stateless learning strategies). """ return self
[docs]class ConceptLearningStrategy(LearningStrategy): """ Applies changes to individuals to update them based upon observations of concepts. """
[docs] def apply( self: SelfType_LearningStrategy, individual: TycheContext, concept_ref: ConceptFunctionSymbolReference, observation: ADLNode, likelihood: float, learning_rate: float): """ Modifies the individual to learn the given concept from the given observation of it. """ raise NotImplementedError(f"{type(self).__name__} does not implement apply")
[docs]class RoleLearningStrategy(LearningStrategy): """ Applies changes to individuals to update them based upon observations over roles. """
[docs] def apply( self: SelfType_LearningStrategy, individual: TycheContext, role_ref: RoleFunctionSymbolReference, observation: ADLNode, likelihood: float, learning_rate: float): """ Modifies the individual to learn the given concept from the given observation of it. """ raise NotImplementedError(f"{type(self).__name__} does not implement apply")
[docs]class DirectConceptLearningStrategy(ConceptLearningStrategy): """ The most basic learning strategy that simply updates concepts to the values they were observed as. A learning rate can be used to limit the changes to the concept's value, to stop a single true observation marking the concept always true. """ def __init__(self, learning_rate: float = 1): super().__init__() if learning_rate <= 0: raise TycheIndividualsException( f"The learning rate must be greater than 0, not {learning_rate:.3f}") if learning_rate > 1: raise TycheIndividualsException( f"The learning rate must be less than or equal to 1, not {learning_rate:.3f}") self.learning_rate = learning_rate
[docs] def apply( self: SelfType_LearningStrategy, individual: TycheContext, concept_ref: ConceptFunctionSymbolReference, observation: ADLNode, likelihood: float, learning_rate: float): # Limit the learning by the learning rate applied for the strategy. learning_rate *= self.learning_rate # Limit the learning by the confidence in the new value of the concept. # If likelihood is 50%, then that is useless to tell us the value of the concept. confidence = abs(likelihood - 0.5) * 2 learning_rate *= confidence # Calculate the weighted sum of the current and new probabilities. curr_prob = concept_ref.get(individual) new_value = likelihood * learning_rate + curr_prob * (1 - learning_rate) # Update the individual! concept_ref.set(individual, new_value)
def __str__(self): return f"{type(self).__name__}(learning_rate={self.learning_rate:.2f})" def __repr__(self): return f"{type(self).__name__}(learning_rate={self.learning_rate})"
[docs]class StatisticalConceptLearningStrategy(ConceptLearningStrategy): """ This learning strategy accumulates a running mean of observations about a concept, and uses it to learn the value of the concept. An initial value bias can be used to favour the initial value of the concept until enough observations have been made. If an initial value is not supplied on construction, then the initial value is considered to be the value of the concept when the first observation is made that calls this learning strategy. """ def __init__(self, initial_value_weight: float = 1, *, decay_rate: float = 1, decay_rate_for_decay_rate: float = 1): super().__init__() if initial_value_weight < 0: raise TycheIndividualsException( f"The initial value weight must be greater than or equal to 0, not {initial_value_weight:.3f}") if decay_rate < 0: raise TycheIndividualsException( f"The decay rate must be greater than or equal to 0, not {decay_rate:.3f}") if decay_rate_for_decay_rate < 0: raise TycheIndividualsException( f"The decay rate of the decay rate must be greater than or equal to 0, " f"not {decay_rate_for_decay_rate:.3f}") self.initial_value_weight = initial_value_weight self.decay_rate = decay_rate self.decay_rate_for_decay_rate = decay_rate_for_decay_rate self.initial_value: Optional[float] = None self.running_learning_rate_sum: float = 0.0 self.running_likelihood_sum: float = 0.0
[docs] def init_for_new_usage(self) -> SelfType_LearningStrategy: """ This is required so that the state of each reference that uses an instance of this learning strategy is kept separate. """ return type(self)( self.initial_value_weight, decay_rate=self.decay_rate, decay_rate_for_decay_rate=self.decay_rate_for_decay_rate )
[docs] def apply( self: SelfType_LearningStrategy, individual: TycheContext, concept_ref: ConceptFunctionSymbolReference, observation: ADLNode, likelihood: float, learning_rate: float): # Decay old observations over time. self.running_learning_rate_sum *= self.decay_rate self.running_likelihood_sum *= self.decay_rate # Decay the decay rate over time. self.decay_rate = 1 - (1 - self.decay_rate) * self.decay_rate_for_decay_rate # If we were not given an initial value on construction, read it now. if self.initial_value is None: self.initial_value = concept_ref.get(individual) self.running_learning_rate_sum: float = self.initial_value_weight self.running_likelihood_sum: float = self.initial_value * self.initial_value_weight else: # Update the running totals. self.running_learning_rate_sum += learning_rate self.running_likelihood_sum += likelihood * learning_rate # Calculate the running mean observed value of the concept. if self.running_learning_rate_sum <= 0: return concept_ref.set(individual, self.running_likelihood_sum / self.running_learning_rate_sum)
def __str__(self): return f"{type(self).__name__}" \ f"(initial_value_weight={self.initial_value_weight:.3f}, decay_rate={self.decay_rate:.3f})"
[docs]class BayesRuleLearningStrategy(RoleLearningStrategy): """ TODO """ def __init__(self, learning_rate: float = 1.0): super().__init__() self.learning_rate = learning_rate
[docs] def apply( self: SelfType_LearningStrategy, individual: TycheContext, role_ref: RoleFunctionSymbolReference, observation: ADLNode, likelihood: float, learning_rate: float): if isinstance(observation, Expectation): # Update the role using Bayes' rule. expectation = cast(Expectation, observation) curr_role_value = role_ref.get(individual) new_role_value = curr_role_value.apply_bayes_rule( Given(expectation.eval_node, expectation.given_node), likelihood, learning_rate * self.learning_rate ) role_ref.set(individual, new_role_value)
[docs]class StatisticalRoleLearningStrategy(RoleLearningStrategy): """ TODO """ def __init__(self, initial_value_weight: float = 1, *, decay_rate: float = 1, decay_rate_for_decay_rate: float = 1): super().__init__() if initial_value_weight < 0: raise TycheIndividualsException( f"The initial value weight must be greater than or equal to 0, not {initial_value_weight:.3f}") if decay_rate < 0: raise TycheIndividualsException( f"The decay rate must be greater than or equal to 0, not {decay_rate:.3f}") if decay_rate_for_decay_rate < 0: raise TycheIndividualsException( f"The decay rate of the decay rate must be greater than or equal to 0, " f"not {decay_rate_for_decay_rate:.3f}") self.initial_value_weight = initial_value_weight self.decay_rate = decay_rate self.decay_rate_for_decay_rate = decay_rate_for_decay_rate self.initial_value: Optional[RoleDist] = None self.running_learning_rate_sums: dict[Optional[TycheContext], float] = {} self.running_likelihood_sums: dict[Optional[TycheContext], float] = {}
[docs] def init_for_new_usage(self) -> SelfType_LearningStrategy: """ This is required so that the state of each reference that uses an instance of this learning strategy is kept separate. """ return type(self)( self.initial_value_weight, decay_rate=self.decay_rate, decay_rate_for_decay_rate=self.decay_rate_for_decay_rate )
[docs] def apply( self: SelfType_LearningStrategy, individual: TycheContext, role_ref: RoleFunctionSymbolReference, observation: ADLNode, likelihood: float, learning_rate: float): # We only know how to deal with Expectation nodes here. if not isinstance(observation, Expectation): return expectation = cast(Expectation, observation) # Decay old observations over time. for ctx in self.running_likelihood_sums.keys(): self.running_learning_rate_sums[ctx] *= self.decay_rate self.running_likelihood_sums[ctx] *= self.decay_rate # Decay the decay rate over time. self.decay_rate = 1 - (1 - self.decay_rate) * self.decay_rate_for_decay_rate # If we were not given an initial value on construction, read it now. curr_role_value = role_ref.get(individual) if self.initial_value is None: self.initial_value = curr_role_value for ctx, prob in curr_role_value: self.running_learning_rate_sums[ctx] = self.initial_value_weight self.running_likelihood_sums[ctx] = prob * self.initial_value_weight else: # Apply Bayes' rule to update the current value of the role. bayes_role_value = curr_role_value.apply_bayes_rule( Given(expectation.eval_node, expectation.given_node), likelihood, 1.0 ) # Update the running totals. for ctx, prob in bayes_role_value: if ctx not in self.running_likelihood_sums: self.running_learning_rate_sums[ctx] = 0 self.running_likelihood_sums[ctx] = 0 self.running_learning_rate_sums[ctx] += learning_rate self.running_likelihood_sums[ctx] += prob * learning_rate # Calculate the running mean observed value of the role. new_role_value = ExclusiveRoleDist() for ctx in self.running_likelihood_sums.keys(): ctx_learning_rate_sum = self.running_learning_rate_sums[ctx] ctx_prob_sum = self.running_likelihood_sums[ctx] weight = ctx_prob_sum / ctx_learning_rate_sum if weight > 0: new_role_value.add(ctx, weight) if new_role_value.is_empty(): return role_ref.set(individual, new_role_value)
def __str__(self): return f"{type(self).__name__}" \ f"(initial_value_weight={self.initial_value_weight:.3f}, decay_rate={self.decay_rate:.3f})"
[docs]class Individual(TycheContext): """ A helper class for representing individual entities in an aleatoric knowledge base. These roles can be used as contexts to use to evaluate Tyche expressions within. The concepts and roles about the individual may be stored as instance variables, or as methods that supply their value. * Fields can be marked as concepts by giving them the TycheConcept type hint. * Fields can be marked as roles by giving them the TycheRole type hint. * Methods can be marked as concepts using the @concept decorator. * Methods can be marked as roles using the @role decorator. """ name: Optional[str] concepts: TycheAccessorStore roles: TycheAccessorStore concept_learning_strats: dict[str, ConceptLearningStrategy] role_learning_strats: dict[str, RoleLearningStrategy] def __init__(self, name: Optional[str] = None): super().__init__() self.name = name self.concepts = TycheConceptDecorator.get(type(self)) self.roles = TycheRoleDecorator.get(type(self)) # Initialise all the learning strategies for this individual. self.concept_learning_strats = {} for symbol, ref in self.concepts.accessors.items(): if not isinstance(ref, ConceptFunctionSymbolReference): continue concept_ref = cast(ConceptFunctionSymbolReference, ref) if concept_ref.learning_strat is not None: self.concept_learning_strats[symbol] = concept_ref.learning_strat.init_for_new_usage() self.role_learning_strats = {} for symbol, ref in self.roles.accessors.items(): if not isinstance(ref, RoleFunctionSymbolReference): continue role_ref = cast(RoleFunctionSymbolReference, ref) if role_ref.learning_strat is not None: self.role_learning_strats[symbol] = role_ref.learning_strat.init_for_new_usage()
[docs] def eval(self, node: CompatibleWithADLNode) -> float: # The Given operator does nothing when evaluated on a regular individual, # as the given and the sentence within the node are independent. if isinstance(node, Given): return self.eval(cast(Given, node).node) return ADLNode.cast(node).direct_eval(self)
[docs] def eval_role(self, role: CompatibleWithRole) -> RoleDist: return Role.cast(role).direct_eval(self)
[docs] @staticmethod def describe(obj_type: Type['Individual']) -> str: """ Returns a string describing the concepts and roles of the given Individual type. """ atoms = sorted(list(Individual.get_concept_names(obj_type))) roles = sorted(list(Individual.get_role_names(obj_type))) return f"{obj_type.__name__} {{atoms={atoms}, roles={roles}}}"
[docs] @staticmethod def get_concept_names(obj_type: Type['Individual']) -> set[str]: """ Returns all the concept names of the given Individual type. """ return TycheConceptDecorator.get(obj_type).all_symbols
[docs] @staticmethod def get_role_names(obj_type: Type['Individual']) -> set[str]: """ Returns all the role names of the given Individual type. """ return TycheRoleDecorator.get(obj_type).all_symbols
[docs] @classmethod def coerce_concept_value(cls: type, value: any) -> float: """ Coerces concept values to a float value in the range [0, 1], and raises errors if this is not possible. """ if np.isscalar(value): if value < 0: raise TycheIndividualsException( f"Error in {cls.__name__}: Concept values must be >= to 0, not {value}" ) if value > 1: raise TycheIndividualsException( f"Error in {cls.__name__}: Concept values must be <= to 1, not {value}" ) return float(value) if isinstance(value, bool): return 1 if value else 0 raise TycheIndividualsException( f"Error in {cls.__name__}: Concept values be of type float, int or bool, not {type(value).__name__}" )
[docs] def get_concept(self, symbol: str) -> float: value = self.concepts.get(self, symbol) return self.coerce_concept_value(value)
[docs] def get_concept_reference(self, symbol: str) -> BakedSymbolReference[float]: ref = self.concepts.get_reference(symbol) coerced_ref = GuardedSymbolReference(ref, self.coerce_concept_value, self.coerce_concept_value) return coerced_ref.bake(self)
[docs] @classmethod def coerce_role_value(cls: type, value: any) -> RoleDist: """ Coerces role values to only allow WeightedRoleDistribution. In the future, this should accept other types of role distributions. """ if isinstance(value, RoleDist): return value raise TycheIndividualsException( f"Error in {cls.__name__}: Role values must be of type " f"{type(RoleDist).__name__}, not {type(value).__name__}" )
[docs] def get_role(self, symbol: str) -> RoleDist: value = self.roles.get(self, symbol) return self.coerce_role_value(value)
[docs] def get_role_reference(self, symbol: str) -> SymbolReference[RoleDist]: ref = self.roles.get_reference(symbol) coerced_ref = GuardedSymbolReference(ref, self.coerce_role_value, self.coerce_role_value) return coerced_ref.bake(self)
def _observe_expectation(self, expectation: Expectation, likelihood: float, learning_rate: float): """ Applies role learning to the role, and propagates the observation. """ # Apply any learning strategy that may be present. symbol = expectation.role.symbol prev_role_value = self.get_role(symbol) if symbol in self.role_learning_strats: ref = cast(RoleFunctionSymbolReference, self.roles.get_reference(symbol)) self.role_learning_strats[symbol].apply(self, ref, expectation, likelihood, learning_rate) # Propagate the observation! possible_matching_individuals = prev_role_value.reverse_expectation_learning_params( expectation.eval_node, expectation.given_node, likelihood ) concept_given = Given(expectation.eval_node, expectation.given_node) for ctx, child_likelihood, child_influence in possible_matching_individuals: if child_influence > 0: ctx.observe(concept_given, child_likelihood, learning_rate * child_influence) def _observe_atom(self, node: Concept, likelihood: float, learning_rate: float): """ If a learning strategy is set for the observed concept, this applies it. """ if node.symbol in self.concept_learning_strats: ref = cast(ConceptFunctionSymbolReference, self.concepts.get_reference(node.symbol)) self.concept_learning_strats[node.symbol].apply(self, ref, node, likelihood, learning_rate) def _observe_child_nodes(self, observation: ADLNode, likelihood: float, learning_rate: float): """ Propagates the observation to its child nodes. e.g. observe (A and B) -> observe A and observe B """ # Loop through all the child nodes of the observation. child_nodes = observation.get_child_nodes_in_eval_context() for index, child_node in enumerate(child_nodes): if isinstance(child_node, Constant): continue # Quick skip # We have to calculate this within the loop, as the loop updates the model. observation_prob = self.eval(observation) obs_matches_expected_prob = likelihood * observation_prob + (1 - likelihood) * (1 - observation_prob) if obs_matches_expected_prob <= 0: raise TycheIndividualsException( f"The observation is impossible under this model " f"({observation} with likelihood {likelihood} @ {self.name})" ) obs_given_child = observation.copy_with_new_child_node_from_eval_context(index, ALWAYS) obs_given_not_child = observation.copy_with_new_child_node_from_eval_context(index, NEVER) child_prob = self.eval(child_node) obs_given_child_prob = self.eval(obs_given_child) obs_given_not_child_prob = self.eval(obs_given_not_child) child_likelihood = uncertain_bayes_rule( child_prob, observation_prob, obs_given_child_prob, likelihood) # Corresponds to the learning_rate parameter. child_influence = abs(obs_given_child_prob - obs_given_not_child_prob) if child_influence <= 0: continue # Propagate! self.observe(child_node, child_likelihood, learning_rate * child_influence)
[docs] def observe(self, observation: ADLNode, likelihood: float = 1, learning_rate: float = 1): """ Learns the roles and concepts within this individual based upon the observation, and propagate the effects of the observation node to its child nodes. """ if isinstance(observation, Expectation): self._observe_expectation(cast(Expectation, observation), likelihood, learning_rate) elif isinstance(observation, Concept): self._observe_atom(cast(Concept, observation), likelihood, learning_rate) elif isinstance(observation, Given): self.observe(cast(Given, observation).node, likelihood, learning_rate) else: self._observe_child_nodes(observation, likelihood, learning_rate)
[docs] def to_str(self, *, detail_lvl: int = 1, indent_lvl: int = 0): if detail_lvl <= 0: return self.name if self.name is not None else f"<{type(self).__name__}>" sub_detail_lvl = detail_lvl - 1 # Key-values of concepts. concept_values = [f"{sym}={self.get_concept(sym):.3f}" for sym in self.concepts.all_symbols] concept_values.sort() # We don't want to list out the entirety of the roles. role_values = [] for role_symbol in self.roles.all_symbols: role = self.get_role(role_symbol) role_values.append(f"{role_symbol}={role.to_str(detail_lvl=sub_detail_lvl, indent_lvl=indent_lvl)}") role_values.sort() name = self.name if self.name is not None else "" key_values = ", ".join(concept_values + role_values) return f"{name}({key_values})"
[docs]class IdentityIndividual(TycheContext): """ Implicitly represents a role over the set of possible states that an individual could take. Evaluation of concepts and roles with this context will implicitly perform an expectation over the set of possible individuals in the id role. The None-individual is not supported for identity roles. """ name: Optional[str] learning_strat: Optional[RoleLearningStrategy] id_role_value: ExclusiveRoleDist id_role_ref: FieldSymbolReference = FieldSymbolReference("<id>", field_name="id_role_value") id_role: Role = ReferenceBackedRole(id_role_ref) def __init__( self, id_role_value: Optional[ExclusiveRoleDist] = None, *, name: Optional[str] = None, entries: RoleDistributionEntries = None, learning_strat: Optional[RoleLearningStrategy] = BayesRuleLearningStrategy()): super().__init__() self.name = name self.learning_strat = None if learning_strat is None else learning_strat.init_for_new_usage() if id_role_value is not None and entries is not None: raise TycheIndividualsException( "An id_role_value distribution and a set of entries for the id role cannot both be supplied at once") self.id_role_value = id_role_value if id_role_value is not None else ExclusiveRoleDist(entries) for ctx in self.id_role_value.contexts(): if ctx is None: raise TycheIndividualsException("None individuals are not supported by IndividualIdentity")
[docs] def is_empty(self) -> bool: """ Returns whether no possible individuals have been added. """ return self.id_role_value.is_empty()
def _verify_not_empty(self): if self.is_empty(): raise Exception("This IndividualIdentity has not been given any possible individuals")
[docs] def add(self, individual: TycheContext, weight: float = 1): """ Adds a possible individual with the given weight. The weights represent the relative weight of the individual against the weights of other individuals. If the given individual already exists, this will replace their weight. If no weight is given, the default weight of 1 will be used. """ if individual is None: raise TycheIndividualsException("None individuals are not supported by IndividualIdentity") self.id_role_value.add(individual, weight)
[docs] def remove(self, individual: TycheContext): """ Removes the given individual from this identity individual. """ return self.id_role_value.remove(individual)
[docs] def eval(self, node: 'ADLNode') -> float: return self.id_role_value.calculate_expectation(node, ALWAYS)
[docs] def eval_role(self, role: 'Role') -> RoleDist: return role.direct_eval(self)
[docs] def get_role(self, symbol: str) -> RoleDist: self._verify_not_empty() return Expectation.evaluate_role_under_role(self.id_role_value, Role(symbol))
[docs] def get_concept(self, symbol: str) -> float: raise TycheIndividualsException( f"Cannot evaluate atoms for instances of {type(self).__name__}. eval should be used instead")
[docs] def get_concept_reference(self, symbol: str) -> SymbolReference[float]: raise TycheIndividualsException( f"Cannot evaluate mutable concepts for instances of {type(self).__name__}")
[docs] def get_role_reference(self, symbol: str) -> SymbolReference[RoleDist]: raise TycheIndividualsException( f"Cannot evaluate mutable roles for instances of {type(self).__name__}")
[docs] def observe(self, observation: 'ADLNode', likelihood: float = 1, learning_rate: float = 1): if learning_rate <= 0: return self._verify_not_empty() prev_id_value = self.id_role_value node, given = Given.maybe_unpack(observation) # Apply any learning strategy that is set for this IdentityIndividual. if self.learning_strat is not None: implicit_expectation = Expectation(self.id_role, node, given) self.learning_strat.apply(self, self.id_role_ref, implicit_expectation, likelihood, learning_rate) # Propagate the observation! possible_matching_individuals = prev_id_value.reverse_expectation_learning_params( node, given, likelihood ) for ctx, child_likelihood, child_influence in possible_matching_individuals: if child_influence > 0: ctx.observe(observation, child_likelihood, learning_rate * child_influence)
def __iter__(self): """ Yields tuples of the possible individuals that could represent this individual, and their probability. """ self._verify_not_empty() for ctx, prob in self.id_role_value: yield ctx, prob
[docs] def to_str(self, *, detail_lvl: int = 1, indent_lvl: int = 0): if detail_lvl <= 0: return self.name return f"{self.name}{self.id_role_value.to_str(detail_lvl=detail_lvl, indent_lvl=indent_lvl)}"