def admits_voi(cid: CID, decision: str, node: str) -> bool: r"""Return True if cid admits value of information for node. - A CID admits value of information for a node X if: i) X is not a descendant of the decision node, D. ii) X is d-connected to U given Fa_D \ {X}, where U ∈ U ∩ Desc(D) ("Agent Incentives: a Causal Perspective" by Everitt, Carey, Langlois, Ortega, and Legg, 2020) """ if len(cid.agents) > 1: raise ValueError( f"This CID has {len(cid.agents)} agents. This incentive is currently only valid for CIDs with one agent." ) if node not in cid.nodes: raise KeyError(f"{node} is not present in the cid") if decision not in cid.nodes: raise KeyError(f"{decision} is not present in the cid") if not cid.sufficient_recall(): raise ValueError("Voi only implemented graphs with sufficient recall") if node in nx.descendants(cid, decision) or node == decision: return False cid2 = cid.copy_without_cpds() cid2.add_edge(node, decision) req_graph = requisite_graph(cid2) return node in req_graph.get_parents(decision)
def get_sequential_cid() -> CID: """ This CID is a subtle case of sufficient recall, as the decision rule for D1 influences the expected utility of D2, but D2 can still be chosen without knowing D1, since D1 does not influence any utility nodes descending from D2. """ cid = CID( [ ("S1", "D1"), ("D1", "U1"), ("S1", "U1"), ("D1", "S2"), ("S2", "D2"), ("D2", "U2"), ("S2", "U2"), ], decisions=["D1", "D2"], utilities=["U1", "U2"], ) cid.add_cpds( UniformRandomCPD("S1", [0, 1]), DecisionDomain("D1", [0, 1]), FunctionCPD("U1", lambda s1, d1: int(s1 == d1)), # type: ignore FunctionCPD("S2", lambda d1: d1), # type: ignore DecisionDomain("D2", [0, 1]), FunctionCPD("U2", lambda s2, d2: int(s2 == d2)), # type: ignore ) return cid
def get_introduced_bias() -> CID: cid = CID( [ ("A", "X"), # defining the graph's nodes and edges ("Z", "X"), ("Z", "Y"), ("X", "D"), ("X", "Y"), ("D", "U"), ("Y", "U"), ], decisions=["D"], utilities=["U"], ) cpd_a = UniformRandomCPD("A", [0, 1]) cpd_z = UniformRandomCPD("Z", [0, 1]) cpd_x = FunctionCPD("X", lambda a, z: a * z) # type: ignore cpd_d = DecisionDomain("D", [0, 1]) cpd_y = FunctionCPD("Y", lambda x, z: x + z) # type: ignore cpd_u = FunctionCPD("U", lambda d, y: -((d - y) ** 2)) # type: ignore cid.add_cpds(cpd_a, cpd_d, cpd_z, cpd_x, cpd_y, cpd_u) return cid
def get_sequential_cid() -> CID: """ This CID is a subtle case of sufficient recall, as the decision rule for D1 influences the expected utility of D2, but D2 can still be chosen without knowing D1, since D1 does not influence any utility nodes descending from D2. """ cid = CID( [ ("S1", "D1"), ("D1", "U1"), ("S1", "U1"), ("D1", "S2"), ("S2", "D2"), ("D2", "U2"), ("S2", "U2"), ], decisions=["D1", "D2"], utilities=["U1", "U2"], ) cid.add_cpds( S1=discrete_uniform([0, 1]), D1=[0, 1], U1=lambda s1, d1: int(s1 == d1), S2=lambda d1: d1, D2=[0, 1], U2=lambda s2, d2: int(s2 == d2), ) return cid
def get_introduced_bias() -> CID: cid = CID( [ ("A", "X"), # defining the graph's nodes and edges ("Z", "X"), ("Z", "Y"), ("X", "D"), ("X", "Y"), ("D", "U"), ("Y", "U"), ], decisions=["D"], utilities=["U"], ) cid.add_cpds( A=discrete_uniform([0, 1]), Z=discrete_uniform([0, 1]), X=lambda a, z: a * z, D=[0, 1], Y=lambda x, z: x + z, U=lambda d, y: -((d - y)**2), ) return cid
def get_insufficient_recall_cid() -> CID: cid = CID([("A", "U"), ("B", "U")], decisions=["A", "B"], utilities=["U"]) cid.add_cpds( DecisionDomain("A", [0, 1]), DecisionDomain("B", [0, 1]), FunctionCPD("U", lambda a, b: a * b), # type: ignore ) return cid
def get_3node_cid() -> CID: cid = CID([("S", "D"), ("S", "U"), ("D", "U")], decisions=["D"], utilities=["U"]) cpd_s = UniformRandomCPD("S", [-1, 1]) cpd_u = FunctionCPD("U", lambda s, d: s * d) # type: ignore cpd_d = DecisionDomain("D", [-1, 1]) cid.add_cpds(cpd_d, cpd_s, cpd_u) return cid
def get_quantitative_voi_cid() -> CID: cid = CID([("S", "X"), ("X", "D"), ("D", "U"), ("S", "U")], decisions=["D"], utilities=["U"]) cpd_s = UniformRandomCPD("S", [-1, 1]) # X takes the value of S with probability 0.8 cpd_x = StochasticFunctionCPD("X", lambda s: {s: 0.8}, domain=[-1, 1]) cpd_d = DecisionDomain("D", [-1, 0, 1]) cpd_u = FunctionCPD("U", lambda s, d: int(s) * int(d)) # type: ignore cid.add_cpds(cpd_s, cpd_x, cpd_d, cpd_u) return cid
def get_quantitative_voi_cid() -> CID: cid = CID([("S", "X"), ("X", "D"), ("D", "U"), ("S", "U")], decisions=["D"], utilities=["U"]) cid.add_cpds( S=discrete_uniform([-1, 1]), X=lambda s: noisy_copy(s, probability=0.8, domain=[-1, 1]), D=[-1, 0, 1], U=lambda s, d: int(s) * int(d), ) return cid
def get_5node_cid_with_scaled_utility() -> CID: cid = CID( [("S1", "D"), ("S1", "U1"), ("S2", "D"), ("S2", "U2"), ("D", "U1"), ("D", "U2")], decisions=["D"], utilities=["U1", "U2"], ) cpd_s1 = UniformRandomCPD("S1", [0, 1]) cpd_s2 = UniformRandomCPD("S2", [0, 1]) cpd_u1 = FunctionCPD("U1", lambda s1, d: 10 * int(s1 == d)) # type: ignore cpd_u2 = FunctionCPD("U2", lambda s2, d: 2 * int(s2 == d)) # type: ignore cpd_d = DecisionDomain("D", [0, 1]) cid.add_cpds(cpd_d, cpd_s1, cpd_s2, cpd_u1, cpd_u2) return cid
def get_2dec_cid() -> CID: cid = CID( [("S1", "S2"), ("S1", "D1"), ("D1", "S2"), ("S2", "U"), ("S2", "D2"), ("D2", "U")], decisions=["D1", "D2"], utilities=["U"], ) cpd_s1 = UniformRandomCPD("S1", [0, 1]) cpd_d1 = DecisionDomain("D1", [0, 1]) cpd_d2 = DecisionDomain("D2", [0, 1]) cpd_s2 = FunctionCPD("S2", lambda s1, d1: int(s1 == d1)) # type: ignore cpd_u = FunctionCPD("U", lambda s2, d2: int(s2 == d2)) # type: ignore cid.add_cpds(cpd_s1, cpd_d1, cpd_s2, cpd_d2, cpd_u) return cid
def quantitative_voc(cid: CID, node: str) -> float: r""" Returns the quantitative value of control (voc) of a variable corresponding to a node in a parameterised CID. A node X ∈ V \ {D} in a single-decision CID has quantitative voi equal to max_EU_(π, g^x)[M_g^x] - max_EU_(π)[M] ie the maximum utility attainable under any policy π and any soft intervention g^x in M_g^x minus the maximum utility attainable under any policy π in M where: - M is the original CID - M_g^x is the the original CID modified with a new soft intervention g^x (ie a new function g^x: dom(Pa^X) -> dom(X)) on variable X. ("Agent Incentives: a Causal Perspective" by Everitt, Carey, Langlois, Ortega, and Legg, 2020) """ if node not in cid.nodes: raise KeyError(f"{node} is not present in the cid") # optimal policy in the original CID. cid.impute_optimal_policy() ev1: float = cid.expected_utility({}) cid.make_decision(node) # optimal policy in the modified CID where the agent can now decide the CPD for node. cid.impute_optimal_policy() ev2: float = cid.expected_utility({}) return ev2 - ev1
def get_2dec_cid() -> CID: cid = CID( [("S1", "S2"), ("S1", "D1"), ("D1", "S2"), ("S2", "U"), ("S2", "D2"), ("D2", "U")], decisions=["D1", "D2"], utilities=["U"], ) cid.add_cpds( S1=discrete_uniform([0, 1]), D1=[0, 1], D2=[0, 1], S2=lambda s1, d1: int(s1 == d1), U=lambda s2, d2: int(s2 == d2), ) return cid
def get_5node_cid_with_scaled_utility() -> CID: cid = CID( [("S1", "D"), ("S1", "U1"), ("S2", "D"), ("S2", "U2"), ("D", "U1"), ("D", "U2")], decisions=["D"], utilities=["U1", "U2"], ) cid.add_cpds( S1=discrete_uniform([0, 1]), S2=discrete_uniform([0, 1]), U1=lambda s1, d: 10 * int(s1 == d), U2=lambda s2, d: 2 * int(s2 == d), D=[0, 1], ) return cid
def quantitative_voi(cid: CID, decision: str, node: str) -> float: r""" Returns the quantitative value of information (voi) of a variable corresponding to a node in a parameterised CID. A node X ∈ V \ Desc(D) in a single-decision CID has quantitative voi equal to EU_max[M(X->D)] - EU_max[M(X \ ->D)] ie the maximum utility attainable in M(X->D) minus the maximum utility attainable in M(X \ ->D) where - M(X->D) is the CID that contains the directed edge X -> D - M(X \ ->D) is the CID without the directed edge X -> D. ("Agent Incentives: a Causal Perspective" by Everitt, Carey, Langlois, Ortega, and Legg, 2020) """ if node not in cid.nodes: raise KeyError(f"{node} is not present in the cid") if node in {decision}.union(set(nx.descendants(cid, decision))): raise ValueError( f"{node} is a decision node or is a descendent of the decision node. \ VOI only applies to nodes which are not descendents of the decision node." ) new_cid = cid.copy() new_cid.add_edge(node, decision) new_cid.impute_optimal_policy() ev1: float = new_cid.expected_utility({}) new_cid.remove_all_decision_rules() new_cid.remove_edge(node, decision) new_cid.impute_optimal_policy() ev2: float = new_cid.expected_utility({}) return ev1 - ev2
def admits_ri(cid: CID, decision: str, node: str) -> bool: r"""Check if a CID admits a response incentive on a node. - A CID G admits a response incentive on X ∈ V \ {D} if and only if the reduced graph G* min has a directed path X --> D. ("Agent Incentives: a Causal Perspective" by Everitt, Carey, Langlois, Ortega, and Legg, 2020) """ if len(cid.agents) > 1: raise ValueError( f"This CID has {len(cid.agents)} agents. This incentive is currently only valid for CIDs with one agent." ) if node not in cid.nodes: raise KeyError(f"{node} is not present in the cid") if decision not in cid.nodes: raise KeyError(f"{decision} is not present in the cid") if not cid.sufficient_recall(): raise ValueError( "Response inventives are only implemented for graphs with sufficient recall" ) if node == decision: return False req_graph = requisite_graph(cid) try: next(find_all_dir_paths(req_graph, node, decision)) except StopIteration: return False else: return True
def admits_voc(cid: CID, node: str) -> bool: """Check if a CID admits positive value of control for a node. A CID G admits positive value of control for a node X ∈ V if and only if X is not a decision node and there is a directed path X --> U in the reduced graph G∗. """ if len(cid.agents) > 1: raise ValueError( f"This CID has {len(cid.agents)} agents. This incentive is currently only valid for CIDs with one agent." ) if node not in cid.nodes: raise KeyError(f"{node} is not present in the cid") if not cid.sufficient_recall(): raise ValueError("VoC only implemented graphs with sufficient recall") if node in cid.decisions: return False req_graph = requisite_graph(cid) agent_utilities = cid.utilities for util in agent_utilities: if node == util or util in nx.descendants(req_graph, node): return True return False
def get_grade_predictor() -> CID: cid = CID( [("R", "HS"), ("HS", "E"), ("HS", "P"), ("E", "Gr"), ("Gr", "Ac"), ("Ge", "P"), ("P", "Ac")], decisions=["P"], utilities=["Ac"], ) return cid
def get_modified_content_recommender() -> CID: cid = CID( [("O", "I"), ("O", "M"), ("M", "P"), ("P", "I"), ("P", "C"), ("M", "C")], decisions=["P"], utilities=["C"], ) return cid
def admits_indir_voc(cid: CID, decision: str, node: str) -> bool: r"""Check if a single-decision CID admits indirect positive value of control for a node. - A single-decision CID G admits positive value of control for a node X ∈ V \ {D} if and only if there is a directed path X --> U in the reduced graph G∗. - The path X --> U may or may not pass through D. - The agent has a direct value of control incentive on D if the path does not pass through D. - The agent has an indirect value of control incentive on D if the path does pass through D and there is also a backdoor path X--U that begins backwards from X (...<- X) and is active when conditioning on Fa_D \ {X} """ if len(cid.agents) > 1: raise ValueError( f"This CID has {len(cid.agents)} agents. This incentive is currently only valid for CIDs with one agent." ) if node not in cid.nodes: raise KeyError(f"{node} is not present in the cid") if decision not in cid.nodes: raise KeyError(f"{decision} is not present in the cid") if not cid.sufficient_recall(): raise ValueError("VoC only implemented graphs with sufficient recall") agent_utilities = cid.utilities req_graph = requisite_graph(cid) d_family = [decision] + cid.get_parents(decision) con_nodes = {i for i in d_family if i != node} if not admits_voc(cid, node): return False req_graph_node_descendants = set(nx.descendants(req_graph, node)) for util in agent_utilities: if util != node and util not in req_graph_node_descendants: continue if not is_active_backdoor_trail(req_graph, node, util, con_nodes): continue if any(decision in path for path in find_all_dir_paths(req_graph, node, util)): return True return False
def get_car_accident_predictor() -> CID: cid = CID( [ ("B", "N"), ("N", "AP"), ("N", "P"), ("P", "Race"), ("Age", "Adt"), ("Adt", "Race"), ("Race", "Accu"), ("M", "AP"), ("AP", "Accu"), ], decisions=["AP"], utilities=["Accu"], ) return cid
def get_fitness_tracker() -> CID: cid = CID( [ ("TD", "TF"), ("TF", "SC"), ("TF", "C"), ("EF", "EWD"), ("EWD", "C"), ("C", "F"), ("P", "D"), ("P", "SC"), ("P", "F"), ("SC", "C"), ("SC", "EWD"), ], decisions=["C"], utilities=["F"], ) return cid
def get_trim_example_cid() -> CID: cid = CID( [ ("Y1", "D1"), ("Y1", "Y2"), ("Y1", "D2"), ("Y2", "D2"), ("Y2", "U"), ("D1", "Y2"), ("D1", "D2"), ("Z1", "D1"), ("Z1", "D2"), ("Z1", "Z2"), ("Z2", "D2"), ("D2", "U"), ], decisions=["D1", "D2"], utilities=["U"], ) return cid
def get_minimal_cid() -> CID: cid = CID([("A", "B")], decisions=["A"], utilities=["B"]) cid.add_cpds(A=[0, 1], B=lambda a: a) return cid
def get_minimal_cid() -> CID: cid = CID([("A", "B")], decisions=["A"], utilities=["B"]) cpd_a = DecisionDomain("A", [0, 1]) cpd_b = FunctionCPD("B", lambda a: a) # type: ignore cid.add_cpds(cpd_a, cpd_b) return cid
def random_cid( number_of_nodes: int = 8, number_of_decisions: int = 1, number_of_utilities: int = 1, add_cpds: bool = True, sufficient_recall: bool = False, edge_density: float = 0.4, max_in_degree: int = 4, max_resampling_attempts: int = 100, ) -> CID: """ Generate a random CID. Parameters: ----------- number_of nodes: The total number of nodes in the CID. number_of_decisions: The number of decisions in the CID. number_of_utilities: The number of utilities in the CID. add_cpds: True if we should pararemeterise the CID as a model. This adds [0,1] domains to every decision node and RandomCPDs to every utility and chance node in the CID. sufficient_recall: True the agent should have sufficient recall of all of its previous decisions. An Agent has sufficient recall in a CID if the relevance graph is acyclic. edge_density: The density of edges in the CID's DAG as a proportion of the maximum possible number of nodes in the DAG - n*(n-1)/2 max_in_degree: The maximal number of edges incident to a node in the CID's DAG. max_resampling_attempts: The maxmimum number of resampling of random DAGs attempts in order to try to satisfy all constraints. Returns ------- A CID that satisfies the given constraints or a ValueError if it was unable to meet the constraints in the specified number of attempts. """ mb = random_macidbase( number_of_nodes=number_of_nodes, agent_decisions_num=(number_of_decisions,), agent_utilities_num=(number_of_utilities,), add_cpds=False, sufficient_recall=sufficient_recall, edge_density=edge_density, max_in_degree=max_in_degree, max_resampling_attempts=max_resampling_attempts, ) dag = DAG(mb.edges) decision_nodes = mb.decisions utility_nodes = mb.utilities # change the naming style of decision and utility nodes dec_name_change = {old_dec_name: "D" + str(i) for i, old_dec_name in enumerate(decision_nodes)} util_name_change = {old_util_name: "U" + str(i) for i, old_util_name in enumerate(utility_nodes)} node_name_change_map = {**dec_name_change, **util_name_change} dag = nx.relabel_nodes(dag, node_name_change_map) cid = CID(dag.edges, decisions=list(dec_name_change.values()), utilities=list(util_name_change.values())) if add_cpds: add_random_cpds(cid) return cid
def get_insufficient_recall_cid() -> CID: cid = CID([("A", "U"), ("B", "U")], decisions=["A", "B"], utilities=["U"]) cid.add_cpds(A=[0, 1], B=[0, 1], U=lambda a, b: a * b) return cid
def total_effect(cid: CID, a: str, x: str, a0: int = 0, a1: int = 1) -> float: "the total effect on x from intervening on a with a2 rather than a1" total_effect = ( cid.expected_value([x], {}, intervention={a: a1})[0] - cid.expected_value([x], {}, intervention={a: a0})[0] ) return total_effect # type: ignore
def get_3node_cid() -> CID: cid = CID([("S", "D"), ("S", "U"), ("D", "U")], decisions=["D"], utilities=["U"]) cid.add_cpds(S=discrete_uniform([-1, 1]), U=lambda s, d: s * d, D=[-1, 1]) return cid