def is_active_indirect_frontdoor_trail(mb: MACIDBase, start_node: str, end_node: str, observed: List[str] = []) -> bool: """ checks whether an active indirect frontdoor path exists given the 'observed' set of variables. - A frontdoor path between X and Z is a path in which the first edge comes out of the first node (X→···Z). - An indirect path contains at least one collider at some node from start_node to end_node. """ considered_nodes = set(observed).union({start_node}, {end_node}) for node in considered_nodes: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") start_to_end_paths = find_all_undir_paths(mb, start_node, end_node) for path in start_to_end_paths: is_frontdoor_path = path[0] in mb.get_parents(path[1]) not_blocked_by_observed = is_active_path(mb, path, observed) contains_collider = "collider" in get_motifs(mb, path) # default is False since if w = [], any unobserved collider blocks path if is_frontdoor_path and not_blocked_by_observed and contains_collider: return True else: return False
def get_motif(mb: MACIDBase, path: List[str], idx: int) -> str: """ Classify three node structure as a forward (chain), backward (chain), fork, collider, or endpoint at index 'idx' along path. """ for node in path: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") if idx > len(path) - 1: raise Exception( f"The given index {idx} is not valid for the length of this path {len(path)}" ) if len(path) == idx + 1: return 'endpoint' elif mb.has_edge(path[idx - 1], path[idx]) and mb.has_edge( path[idx], path[idx + 1]): return 'forward' elif mb.has_edge(path[idx + 1], path[idx]) and mb.has_edge( path[idx], path[idx - 1]): return 'backward' elif mb.has_edge(path[idx - 1], path[idx]) and mb.has_edge( path[idx + 1], path[idx]): return 'collider' elif mb.has_edge(path[idx], path[idx - 1]) and mb.has_edge( path[idx], path[idx + 1]): return 'fork' else: raise Exception(f"unsure how to classify this path at index {idx}")
def _get_path_edges(mb: MACIDBase, path: List[str]) -> List[Tuple[str, str]]: """ Returns the structure of a path's edges as a list of pairs. In each pair, the first argument states where an edge starts and the second argument states where that same edge finishes. For example, if a (colliding) path is D1 -> X <- D2, this function returns: [('D1', 'X'), ('D2', 'X')] """ structure = [] for i in range(len(path) - 1): if path[i] in mb.get_parents(path[i + 1]): structure.append((path[i], path[i + 1])) elif path[i + 1] in mb.get_parents(path[i]): structure.append((path[i + 1], path[i])) return structure
def _find_all_undirpath_recurse(mb: MACIDBase, path: List[str], end_node: str) -> List[List[str]]: """Find all undirected paths beginning with 'path' as a prefix and ending at the end node.""" if path[-1] == end_node: return [path] path_extensions = [] neighbours = list(mb.get_children(path[-1])) + list( mb.get_parents(path[-1])) neighbours_not_in_path = set(neighbours).difference(set(path)) for child in neighbours_not_in_path: path_extensions.extend( _find_all_undirpath_recurse(mb, path + [child], end_node)) return path_extensions
def requisite_graph(cid: MACIDBase) -> MACIDBase: """Return the requisite graph of the original CID, 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 is_active_path(mb: MACIDBase, path: List[str], observed: List[str] = []) -> bool: """ Check if a specifc path remains active given the 'observed' set of variables. """ considered_nodes = set(path).union(set(observed)) for node in considered_nodes: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") if len(path) < 3: return True for _, b, _ in zip(path[:-2], path[1:-1], path[2:]): structure = get_motif(mb, path, path.index(b)) if structure in {'fork', 'forward', 'backward'} and b in observed: return False if structure == "collider": descendants = nx.descendants(mb, b).union({b}) if not descendants.intersection(set(observed)): return False return True
def get_motifs(mb: MACIDBase, path: List[str]) -> List[str]: """classify the motif of all nodes along a path as a forward (chain), backward (chain), fork, collider or endpoint""" for node in path: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") shapes = [] for i in range(len(path)): if i == 0: if path[i] in mb.get_parents(path[i + 1]): shapes.append('forward') else: shapes.append('backward') else: shapes.append(get_motif(mb, path, i)) return shapes
def requisite(cid: MACIDBase, decision: str, node: str) -> bool: """Return True if cid node is requisite observation for decision (i.e. 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. """ if decision not in cid.nodes: raise Exception(f"{decision} is not present in the cid") if node not in cid.get_parents(decision): raise Exception(f"{node} is not a parent of {decision}") agent_utilities = cid.utility_nodes_agent[cid.whose_node[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 find_all_undir_paths(mb: MACIDBase, start_node: str, end_node: str) -> List[List[str]]: """ Finds all undirected paths from start node to end node that exist in the (MA)CID. """ for node in [start_node, end_node]: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") return _find_all_undirpath_recurse(mb, [start_node], end_node)
def __init__(self, cid: MACIDBase, decisions: List[str] = None): super().__init__() if decisions is None: decisions = cid.all_decision_nodes 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 find_active_path(mb: MACIDBase, start_node: str, end_node: str, observed: List[str] = []) -> List[str]: """Find active path from `start_node' to `end_node' given the `observed' set of nodes.""" considered_nodes = set(observed).union({start_node}, {end_node}) for node in considered_nodes: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") return _find_active_path_recurse(mb, [start_node], end_node, observed)
def _find_all_dirpath_recurse(mb: MACIDBase, path: List[str], end_node: str) -> List[List[str]]: """Find all directed paths beginning with 'path' as a prefix and ending at the end node""" if path[-1] == end_node: return [path] path_extensions = [] children = mb.get_children(path[-1]) for child in children: path_extensions.extend( _find_all_dirpath_recurse(mb, path + [child], end_node)) return path_extensions
def _active_neighbours(mb: MACIDBase, path: List[str], observed: List[str]) -> Set[str]: """Find possibly active extensions of path conditional on the `observed' set of nodes.""" end_of_path = path[-1] last_forward = len(path) > 1 and end_of_path in mb.get_children(path[-2]) possible_colliders: Set[str] = set().union( *[set(mb._get_ancestors_of(e)) for e in observed]) # type: ignore # if going upward or at a possible collider, it's possible to continue to a parent if end_of_path in possible_colliders or not last_forward: active_parents = set(mb.get_parents(end_of_path)) - set(observed) else: active_parents = set() # it's possible to go downward if and only if not an observed node if end_of_path in observed: active_children = set() else: active_children = set(mb.get_children(end_of_path)) active_neighbours = active_parents.union(active_children) new_active_neighbours = active_neighbours - set(path) return new_active_neighbours
def is_active_backdoor_trail(mb: MACIDBase, start_node: str, end_node: str, observed: List[str] = []) -> bool: """ Returns true if there is a backdoor path that's active given the 'observed' set of nodes. - A backdoor path between X and Z is an (undirected) path in which the first edge goes into the first node (X←···Z) """ considered_nodes = set(observed).union({start_node}, {end_node}) for node in considered_nodes: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") start_to_end_paths = find_all_undir_paths(mb, start_node, end_node) for path in start_to_end_paths: if len(path) > 1: # must have path of at least 2 nodes is_backdoor_path = path[1] in mb.get_parents(path[0]) not_blocked_by_observed = is_active_path(mb, path, observed) if is_backdoor_path and not_blocked_by_observed: return True else: return False
def directed_decision_free_path(mb: MACIDBase, start_node: str, end_node: str) -> bool: """ Checks to see if a directed decision free path exists """ for node in [start_node, end_node]: if node not in mb.nodes(): raise Exception(f"The node {node} is not in the (MA)CID") start_to_end_paths = find_all_dir_paths(mb, start_node, end_node) dec_free_path_exists = any( set(mb.all_decision_nodes).isdisjoint(set(path[1:-1])) for path in start_to_end_paths) # ignore path's start_node and end_node if start_to_end_paths and dec_free_path_exists: return True else: return False
def _effective_backdoor_path_not_blocked_by_set_w( mb: MACIDBase, start: str, finish: str, effective_set: List[str], w: List[str] = []) -> List[str]: """ Returns the effective backdoor path not blocked if we condition on nodes in set w. If no such path exists, this returns None. """ start_finish_paths: List[List[str]] = find_all_undir_paths( mb, start, finish) for path in start_finish_paths: is_backdoor_path = path[1] in mb.get_parents(path[0]) not_blocked_by_w = is_active_path(mb, path, w) if is_backdoor_path and _path_is_effective( mb, path, effective_set) and not_blocked_by_w: return path return []
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)]