def _add_sufficient_recall(mb: MACIDBase, d1: str, d2: str, utility_node: str) -> None: """Add edges to a (MA)CID until an agent at `d2` has sufficient recall of `d1` to optimise utility_node. d1, d2 and utility node all belong to the same agent. `d2' has sufficient recall of `d1' if d2 does not strategically rely on d1. This means that d1 is not s-reachable from d2. edges are added from non-collider nodes along an active path from the mechanism of `d1' to somue utilty node descended from d2 until recall is sufficient. """ if d2 in mb._get_ancestors_of(d1): raise ValueError("{} is an ancestor of {}".format(d2, d1)) mg = MechanismGraph(mb) while mg.is_active_trail(d1 + "mec", utility_node, observed=mg.get_parents(d2) + [d2]): path = find_active_path(mg, d1 + "mec", utility_node, {d2, *mg.get_parents(d2)}) if path is None: raise RuntimeError( "couldn't find path even though there should be an active trail" ) while True: idx = random.randrange(1, len(path) - 1) if get_motif(mg, path, idx) != "collider": if d2 not in mg._get_ancestors_of( path[idx]): # to prevent cycles mb.add_edge(path[idx], d2) mg.add_edge(path[idx], d2) break
def _add_random_cpds(mb: MACIDBase) -> None: """ add cpds to the random (MA)CID. """ for node in mb.nodes: if node in mb.decisions: mb.add_cpds(DecisionDomain(node, [0, 1])) else: mb.add_cpds(RandomCPD(node))
def add_random_cpds(mb: MACIDBase) -> None: """ add cpds to the random (MA)CID. """ node_cpds: Dict[str, Relationship] = {} for node in mb.nodes: if node in mb.decisions: node_cpds[node] = [0, 1] else: node_cpds[node] = RandomCPD() mb.add_cpds(**node_cpds)
def __init__(self, cid: MACIDBase, decisions: Iterable[str] = None): super().__init__() if decisions is None: decisions = cid.decisions self.add_nodes_from(decisions) dec_pair_perms = list(itertools.permutations(decisions, 2)) for dec_pair in dec_pair_perms: if cid.is_s_reachable(dec_pair[0], dec_pair[1]): self.add_edge(dec_pair[0], dec_pair[1])
def _check_max_in_degree(mb: MACIDBase, max_in_degree: int) -> bool: """ check that the degree of each vertex in the DAG is less than the set maximum. """ for node in mb.nodes: if mb.in_degree(node) > max_in_degree: return False else: return True
def requisite_graph(cid: MACIDBase) -> MACIDBase: """The requiste graph of the original CID. The requisite graph is also called a minimal reduction, d reduction, or the trimmed graph. The requisite graph G∗ of a multi-decision CID G is the result of repeatedely removing from G all nonrequisite observation links. ("Representing and Solving Decision Problems with Limited Information", Lauritzen and Nielsen, 2001) """ requisite_graph = cid.copy() decisions = cid.get_valid_order() for decision in reversed(decisions): non_requisite_nodes = set(cid.get_parents(decision)) - set( requisite_list(requisite_graph, decision)) for nr in non_requisite_nodes: requisite_graph.remove_edge(nr, decision) return requisite_graph
def requisite(cid: MACIDBase, decision: str, node: str) -> bool: r"""Check if a CID node is a requisite observation for a decision. A node is a requisite observation if it is possibly material. A node can be material if: i) it is a parent of D. ii) X is d-connected to (U ∩ Desc(D)) given Fa_D \ {X} "A note about redundancy in influence diagrams" Fagiuoli and Zaffalon, 1998. Returns True if the node is requisite. """ if node not in cid.get_parents(decision): raise KeyError(f"{node} is not a parent of {decision}") agent_utilities = cid.agent_utilities[cid.decision_agent[decision]] descended_agent_utilities = set(agent_utilities).intersection( nx.descendants(cid, decision)) family_d = [decision] + cid.get_parents(decision) conditioning_nodes = [i for i in family_d if i != node] return any( cid.is_active_trail(node, u_node, conditioning_nodes) for u_node in descended_agent_utilities)
def add_sufficient_recalls(mb: MACIDBase) -> None: """add edges to a macid until all agents have sufficient recall of all of their previous decisions""" agents = mb.agents for agent in agents: decisions = mb.agent_decisions[agent] for utility_node in mb.agent_utilities[agent]: for i, dec1 in enumerate(decisions): for dec2 in decisions[i + 1 :]: if dec1 in mb._get_ancestors_of(dec2): if utility_node in nx.descendants(mb, dec2): _add_sufficient_recall(mb, dec1, dec2, utility_node) else: if utility_node in nx.descendants(mb, dec1): _add_sufficient_recall(mb, dec2, dec1, utility_node)
def random_macidbase( number_of_nodes: int = 10, agent_decisions_num: Tuple[int, ...] = (1, 2), agent_utilities_num: Tuple[int, ...] = (2, 1), add_cpds: bool = False, sufficient_recall: bool = False, edge_density: float = 0.4, max_in_degree: int = 4, max_resampling_attempts: int = 5000, ) -> MACIDBase: """ Generate a random MACIDBase Returns ------- A MACIDBase that satisfies the given constraints or a ValueError if it was unable to meet the constraints in the specified number of attempts. """ if len(agent_decisions_num) != len(agent_utilities_num): raise ValueError( f"The number of agents specified for agent_decisions_num {len(agent_decisions_num)} does not match \ the number of agents specified for agent_utilities_num {len(agent_utilities_num)}" ) for _ in range(max_resampling_attempts): dag = random_dag(number_of_nodes=number_of_nodes, edge_density=edge_density, max_in_degree=max_in_degree) # assign utility nodes to each agent based on the barren nodes in the random dag barren_nodes = [ node for node in dag.nodes if not list(dag.successors(node)) ] if sum(agent_utilities_num) > len(barren_nodes): # there are not enough barren_nodes: resample a new random DAG. continue np.random.shuffle(barren_nodes) # randomise util_node_candidates = iter(barren_nodes) agent_utilities_old_name = { agent: [next(util_node_candidates) for _ in range(num)] for agent, num in enumerate(agent_utilities_num) } used_nodes = set() # type: Set[str] agent_decisions: Mapping[AgentLabel, Iterable[str]] = {} agent_utilities: Mapping[AgentLabel, Iterable[str]] = {} node_name_change_map: Dict[str, str] = {} for agent in agent_utilities_old_name.keys(): # assign decision nodes to agent num_decs = agent_decisions_num[agent] agent_utils = agent_utilities_old_name[agent] possible_dec_nodes: Set[str] = ( set().union( * [set(dag._get_ancestors_of(node)) for node in agent_utils]) - set(agent_utils) - used_nodes # type: ignore ) if num_decs > len(possible_dec_nodes): break agent_decs = random.sample(possible_dec_nodes, num_decs) used_nodes.update(agent_decs) # rename decision and utility nodes agent_util_name_change = { old_util_name: "U^" + str(agent) + "_" + str(i) for i, old_util_name in enumerate(agent_utils) } agent_dec_name_change = { old_dec_name: "D^" + str(agent) + "_" + str(i) for i, old_dec_name in enumerate(agent_decs) } agent_utilities[agent] = list( agent_util_name_change.values()) # type: ignore agent_decisions[agent] = list( agent_dec_name_change.values()) # type: ignore node_name_change_map.update(**agent_util_name_change, **agent_dec_name_change) else: # rename chance nodes chance_nodes = [ node for node in dag.nodes if node not in node_name_change_map.keys() ] chance_name_change = { old_chance_name: "X_" + str(i) for i, old_chance_name in enumerate(chance_nodes) } node_name_change_map.update(chance_name_change) dag = nx.relabel_nodes(dag, node_name_change_map) mb = MACIDBase(dag.edges, agent_decisions=agent_decisions, agent_utilities=agent_utilities) if sufficient_recall: _add_sufficient_recalls(mb) if not _check_max_in_degree(mb, max_in_degree): # adding edges for sufficient recall requirement violates max_in_degree: resample a new random DAG continue if add_cpds: add_random_cpds(mb) return mb continue else: raise ValueError( f"Could not create a MACID satisfying all constraints in {max_resampling_attempts} sampling attempts" )
def random_macidbase( number_of_nodes: int = 8, number_of_agents: int = 2, max_decisions_for_agent: int = 1, max_utilities_for_agent: int = 1, add_cpds: bool = False, sufficient_recall: bool = False, edge_density: float = 0.4, max_in_degree: int = 4, max_resampling_attempts: int = 1000, ) -> MACIDBase: """ Generate a random MACIDBase Returns ------- A MACIDBase that satisfies the given constraints or a ValueError if it was unable to meet the constraints in the specified number of attempts. """ for _ in range(max_resampling_attempts): dag = random_dag(number_of_nodes=number_of_nodes, edge_density=edge_density, max_in_degree=max_in_degree) barren_nodes = [node for node in dag.nodes if not list(dag.successors(node))] if max_utilities_for_agent * number_of_agents > len(barren_nodes): # there are not enough barren_nodes: resample a new random DAG. continue agent_utilities, util_nodes_name_change = _create_random_utility_nodes( number_of_agents, max_utilities_for_agent, barren_nodes ) dag = nx.relabel_nodes(dag, util_nodes_name_change) agent_decisions: Mapping[AgentLabel, Iterable[str]] = {} all_dec_name_change: Dict[str, str] = {} used_nodes = set() # type: Set[str] for agent in range(number_of_agents): agent_utils = agent_utilities[agent] ancestors = set() # type: Set[str] possible_dec_nodes = ( ancestors.union(*[set(dag._get_ancestors_of(node)) for node in agent_utils]) - set(agent_utils) - used_nodes ) if not possible_dec_nodes: # this agent has no possible decision nodes: resample a new random DAG. break # in the single-agent CID setting, we want the number of decisions to be equal to what we we specified: if number_of_agents == 1: number_of_decisions = max_decisions_for_agent if number_of_decisions > len(possible_dec_nodes): # there are not enough possible decision nodes: resample a new random DAG break sample_dec_nodes = random.sample(possible_dec_nodes, number_of_decisions) # in the multi-agent CID setting, the number of decisions for each agent can vary, but should never be # more than the max_decisions_for_agent we specified. else: sample_dec_nodes = random.sample( possible_dec_nodes, min(len(possible_dec_nodes), max_decisions_for_agent) ) used_nodes.update(sample_dec_nodes) dec_name_change = { old_dec_name: "D^" + str(agent) + "_" + str(i) for i, old_dec_name in enumerate(sample_dec_nodes) } agent_decisions[agent] = list(dec_name_change.values()) # type: ignore all_dec_name_change.update(dec_name_change) else: dag = nx.relabel_nodes(dag, all_dec_name_change) mb = MACIDBase(dag.edges, agent_decisions=agent_decisions, agent_utilities=agent_utilities) if sufficient_recall: add_sufficient_recalls(mb) if not _check_max_in_degree(mb, max_in_degree): # adding edges for sufficient recall requirement violates max_in_degree: resample a new random DAG continue if add_cpds: _add_random_cpds(mb) return mb continue else: raise ValueError( f"Could not create a MACID satisfying all constraints in {max_resampling_attempts} sampling attempts" )
def requisite_list(cid: MACIDBase, decision: str) -> List[str]: """Returns list of requisite nodes for decision""" return [ node for node in cid.get_parents(decision) if requisite(cid, decision, node) ]