Skip to content

API reference

Everything below is re-exported from the top level, so from causalphylo import find_adjustment_set and from causalphylo.backdoor import find_adjustment_set are equivalent.

causalphylo.graph_semantics

Which edges may carry a causal claim. See Which edges earn an arrow.

Which edges may carry a causal claim.

A disease graph assembled from public sources mixes two kinds of edge. Some assert a mechanism with a direction: OmniPath's signed regulatory interactions, where a curator has said that A acts on B. Others assert only that two things occur together: a literature co-mention, membership of a shared pathway, an undirected protein interaction whose arrow exists because one protein landed in the first column of an API response.

Do-calculus over the second kind assumes the very thing a causal method is supposed to establish. So this module partitions the edge types and identification runs only on the causal-admissible subgraph. Associational edges keep contributing to evidence scoring; they stop being arrows.

Gene -> disease is admitted only on genetic evidence. A germline variant is randomised at conception, so a genetic association carries a natural-experiment warrant that a co-mention does not -- the Mendelian randomisation argument (Davey Smith & Ebrahim, 2003, Int J Epidemiol 32:1-22). That warrant is what earns the arrow.

AcyclicCore dataclass

A DAG carved out of a possibly-cyclic graph, and what was removed.

excluded names every node that sat in a feedback loop. Those nodes are not silently repaired: d-separation is undefined on them, and identification reports cyclic_component rather than guessing an acyclic orientation.

Source code in src/causalphylo/graph_semantics.py
@dataclass(frozen=True)
class AcyclicCore:
    """A DAG carved out of a possibly-cyclic graph, and what was removed.

    ``excluded`` names every node that sat in a feedback loop. Those
    nodes are not silently repaired: d-separation is undefined on them,
    and identification reports ``cyclic_component`` rather than guessing
    an acyclic orientation.
    """

    dag: nx.DiGraph
    excluded: frozenset[str]

is_causal_admissible

is_causal_admissible(attrs: dict) -> bool

Whether an edge with these attributes may carry a causal claim.

Parameters:

Name Type Description Default
attrs dict

Edge attribute mapping, as stored on the NetworkX graph. Reads edge_type and evidence_class.

required
Source code in src/causalphylo/graph_semantics.py
def is_causal_admissible(attrs: dict) -> bool:
    """Whether an edge with these attributes may carry a causal claim.

    Parameters
    ----------
    attrs
        Edge attribute mapping, as stored on the NetworkX graph. Reads
        ``edge_type`` and ``evidence_class``.
    """
    edge_type = attrs.get("edge_type", "")
    if edge_type in CAUSAL_EDGE_TYPES:
        return True
    if edge_type in _ASSOCIATION_EDGE_TYPES:
        return attrs.get("evidence_class", "") in GENETIC_EVIDENCE_CLASSES
    return False

causal_subgraph

causal_subgraph(G: DiGraph) -> nx.DiGraph

The subgraph of causal-admissible edges, attributes preserved.

Nodes left with no admissible edge are dropped: a node that participates in no causal relation cannot be a treatment, an outcome, or a confounder.

Source code in src/causalphylo/graph_semantics.py
def causal_subgraph(G: nx.DiGraph) -> nx.DiGraph:
    """The subgraph of causal-admissible edges, attributes preserved.

    Nodes left with no admissible edge are dropped: a node that
    participates in no causal relation cannot be a treatment, an
    outcome, or a confounder.
    """
    admissible = [
        (u, v) for u, v, attrs in G.edges(data=True)
        if is_causal_admissible(attrs)
    ]
    return G.edge_subgraph(admissible).copy()

cyclic_components

cyclic_components(G: DiGraph) -> list[frozenset[str]]

Strongly connected components with cycles, including self-loops.

Source code in src/causalphylo/graph_semantics.py
def cyclic_components(G: nx.DiGraph) -> list[frozenset[str]]:
    """Strongly connected components with cycles, including self-loops."""
    cyclic = []
    for component in nx.strongly_connected_components(G):
        # Non-trivial SCC (more than one node)
        if len(component) > 1:
            cyclic.append(frozenset(component))
        # Single node with self-loop
        elif len(component) == 1:
            node = next(iter(component))
            if G.has_edge(node, node):
                cyclic.append(frozenset(component))
    return cyclic

acyclic_core

acyclic_core(G: DiGraph) -> AcyclicCore

Remove every node in a strongly connected component with cycles.

Biological regulatory networks are full of feedback, and nx.is_d_separator requires a DAG of the whole graph -- not merely of the neighbourhood under test. So cyclic nodes come out entirely, and the caller is told which.

Source code in src/causalphylo/graph_semantics.py
def acyclic_core(G: nx.DiGraph) -> AcyclicCore:
    """Remove every node in a strongly connected component with cycles.

    Biological regulatory networks are full of feedback, and
    ``nx.is_d_separator`` requires a DAG of the whole graph -- not merely
    of the neighbourhood under test. So cyclic nodes come out entirely,
    and the caller is told which.
    """
    cyclic_nodes: set[str] = set()
    for component in cyclic_components(G):
        cyclic_nodes |= set(component)

    dag = G.copy()
    dag.remove_nodes_from(cyclic_nodes)
    return AcyclicCore(dag=dag, excluded=frozenset(cyclic_nodes))

Vocabularies

CAUSAL_EDGE_TYPES
Frozenset of edge_type values that assert a directed mechanism.
GENETIC_EVIDENCE_CLASSES
Frozenset of evidence_class values that license a gene-to-disease arrow.

causalphylo.backdoor

Identification on the admissible subgraph. See Identification.

Pearl's backdoor criterion, and an honest verdict when it cannot be met.

Z satisfies the backdoor criterion relative to (X, Y) when no node in Z is a descendant of X, and Z blocks every path between X and Y that starts with an arrow into X (Pearl, Causality, 2nd ed., Def. 3.3.1). Both halves matter: the descendant condition rules out conditioning on a mediator or a collider downstream of the treatment, and blocking only the backdoor paths -- never the causal path itself -- is what a plain d-separation test does not do for free. To test the second half with a generic d-separation routine, the edges leaving the treatment are removed first: that deletes the causal path (and anything X causes) while leaving every backdoor path, the ones entering X through an arrow, exactly as they were.

This module reports why identification failed, not merely that it did. The reasons are a closed enum so that a run's failures aggregate into a distribution rather than a pile of prose.

A note on trivial identification. When no confounder appears in the graph, the empty set satisfies the criterion and the effect is identifiable -- but a knowledge graph is open-world, and a missing edge is missing knowledge rather than evidence of no confounding. So a trivial verdict is reported under its own reason and carries n_near_miss_confounders: how many nodes regulate the treatment and have some association with the outcome that fell short of admissibility. A target with many near misses is fragile. This is the same move as an E-value in observational epidemiology -- not "there is no confounding" but "here is how much it would take to break this".

Identification dataclass

The verdict on one (treatment, outcome) pair.

Source code in src/causalphylo/backdoor.py
@dataclass(frozen=True)
class Identification:
    """The verdict on one (treatment, outcome) pair."""

    identifiable: bool
    adjustment_set: tuple[str, ...]
    reason: IdentificationReason
    search_truncated: bool = False
    n_near_miss_confounders: int = 0

IdentificationReason

Bases: StrEnum

Why identification succeeded or failed. A closed set.

Source code in src/causalphylo/backdoor.py
class IdentificationReason(StrEnum):
    """Why identification succeeded or failed. A closed set."""

    IDENTIFIABLE_BY_ADJUSTMENT = "identifiable_by_adjustment"
    IDENTIFIABLE_TRIVIALLY = "identifiable_trivially"
    NO_CAUSAL_PATH = "no_causal_path"
    CYCLIC_COMPONENT = "cyclic_component"
    NO_VALID_ADJUSTMENT_SET = "no_valid_adjustment_set"
    TREATMENT_ABSENT = "treatment_absent"
    OUTCOME_ABSENT = "outcome_absent"

find_adjustment_set

find_adjustment_set(G: DiGraph, treatment: str, outcome: str) -> Identification

Search for a minimal valid backdoor adjustment set.

G is the full disease graph. Restriction to the causal subgraph and to its acyclic core happens here, so no caller can forget it.

Source code in src/causalphylo/backdoor.py
def find_adjustment_set(
    G: nx.DiGraph,
    treatment: str,
    outcome: str,
) -> Identification:
    """Search for a minimal valid backdoor adjustment set.

    ``G`` is the *full* disease graph. Restriction to the causal subgraph
    and to its acyclic core happens here, so no caller can forget it.
    """
    causal = causal_subgraph(G)
    core = acyclic_core(causal)
    dag = core.dag

    if treatment in core.excluded or outcome in core.excluded:
        return Identification(
            identifiable=False,
            adjustment_set=(),
            reason=IdentificationReason.CYCLIC_COMPONENT,
        )

    # TREATMENT_ABSENT / OUTCOME_ABSENT mean the node does not exist in
    # the graph at all -- a typo, a stale ID. That is a different fact
    # from a node that exists but has no causal-admissible edge: such a
    # node is dropped from ``dag`` by causal_subgraph, and reporting it
    # as "absent" would blame the query when the honest complaint is
    # that there is no causal path in what evidence exists. So presence
    # is checked against the full input graph, not against ``dag``.
    if treatment not in G:
        return Identification(
            identifiable=False,
            adjustment_set=(),
            reason=IdentificationReason.TREATMENT_ABSENT,
        )

    if outcome not in G:
        return Identification(
            identifiable=False,
            adjustment_set=(),
            reason=IdentificationReason.OUTCOME_ABSENT,
        )

    if (
        treatment not in dag
        or outcome not in dag
        or not nx.has_path(dag, treatment, outcome)
    ):
        return Identification(
            identifiable=False,
            adjustment_set=(),
            reason=IdentificationReason.NO_CAUSAL_PATH,
        )

    if satisfies_backdoor(dag, treatment, outcome, frozenset()):
        return Identification(
            identifiable=True,
            adjustment_set=(),
            reason=IdentificationReason.IDENTIFIABLE_TRIVIALLY,
            n_near_miss_confounders=confounding_sensitivity(
                dag, G, treatment, outcome,
            ),
        )

    descendants = nx.descendants(dag, treatment)
    pool = sorted(
        (
            node for node in dag.nodes()
            if node not in descendants
            and node not in (treatment, outcome)
        ),
        key=lambda n: (-dag.nodes[n].get("score", 0.0), n),
    )
    truncated = len(pool) > MAX_CANDIDATE_POOL
    pool = pool[:MAX_CANDIDATE_POOL]

    for size in range(1, MAX_ADJUSTMENT_SET_SIZE + 1):
        for candidate in combinations(pool, size):
            if satisfies_backdoor(dag, treatment, outcome, frozenset(candidate)):
                return Identification(
                    identifiable=True,
                    adjustment_set=tuple(sorted(candidate)),
                    reason=IdentificationReason.IDENTIFIABLE_BY_ADJUSTMENT,
                    search_truncated=truncated,
                )

    return Identification(
        identifiable=False,
        adjustment_set=(),
        reason=IdentificationReason.NO_VALID_ADJUSTMENT_SET,
        search_truncated=truncated or len(pool) > MAX_ADJUSTMENT_SET_SIZE,
    )

satisfies_backdoor

satisfies_backdoor(G: DiGraph, treatment: str, outcome: str, adjustment: frozenset[str]) -> bool

Whether adjustment satisfies the backdoor criterion.

G must be a DAG containing both nodes; callers reach this through find_adjustment_set, which guarantees that.

Source code in src/causalphylo/backdoor.py
def satisfies_backdoor(
    G: nx.DiGraph,
    treatment: str,
    outcome: str,
    adjustment: frozenset[str],
) -> bool:
    """Whether ``adjustment`` satisfies the backdoor criterion.

    ``G`` must be a DAG containing both nodes; callers reach this through
    ``find_adjustment_set``, which guarantees that.
    """
    if treatment in adjustment or outcome in adjustment:
        return False

    if adjustment & nx.descendants(G, treatment):
        return False

    # The backdoor criterion asks whether Z blocks every path between
    # X and Y that starts with an arrow *into* X -- it says nothing
    # about the causal path itself. Testing that with a generic
    # d-separation routine means removing X's *out*-edges first, so the
    # direct causal edge (and any directed path through it) cannot
    # register as "still connected" and mask an open backdoor path.
    # This is a different mutilation from ``mutilated_graph``, which
    # removes edges *into* the treatment for the interventional graph
    # G_X-bar used elsewhere (e.g. the do-operator's truncated
    # factorization); the two must not be confused.
    backdoor_graph = G.copy()
    backdoor_graph.remove_edges_from(list(G.out_edges(treatment)))

    return bool(
        nx.is_d_separator(
            backdoor_graph,
            {treatment},
            {outcome},
            set(adjustment),
        )
    )

mutilated_graph

mutilated_graph(G: DiGraph, treatment: str) -> nx.DiGraph

G with every edge into treatment removed. G is not modified.

Source code in src/causalphylo/backdoor.py
def mutilated_graph(G: nx.DiGraph, treatment: str) -> nx.DiGraph:
    """G with every edge into ``treatment`` removed. G is not modified."""
    mutilated = G.copy()
    mutilated.remove_edges_from(list(G.in_edges(treatment)))
    return mutilated

confounding_sensitivity

confounding_sensitivity(dag: DiGraph, full_graph: DiGraph, treatment: str, outcome: str) -> int

Count near-miss confounders of treatment and outcome.

A near miss is a node that reaches the treatment in the causal DAG and carries some edge to the outcome in the full graph that was not admissible -- a literature association, a shared pathway. Each one is a confounder the graph hints at but does not license adjusting for.

Source code in src/causalphylo/backdoor.py
def confounding_sensitivity(
    dag: nx.DiGraph,
    full_graph: nx.DiGraph,
    treatment: str,
    outcome: str,
) -> int:
    """Count near-miss confounders of ``treatment`` and ``outcome``.

    A near miss is a node that reaches the treatment in the causal DAG
    and carries some edge to the outcome in the full graph that was not
    admissible -- a literature association, a shared pathway. Each one is
    a confounder the graph hints at but does not license adjusting for.
    """
    if treatment not in dag:
        return 0

    ancestors = nx.ancestors(dag, treatment)
    admissible_parents = set(dag.predecessors(outcome)) if outcome in dag else set()

    return sum(
        1
        for node in ancestors
        if node not in admissible_parents
        and full_graph.has_edge(node, outcome)
    )

Search bounds

MAX_ADJUSTMENT_SET_SIZE
Largest adjustment set the search will consider. Default 3.
MAX_CANDIDATE_POOL
Largest candidate pool the search will draw from. Default 20.

Reaching either bound sets Identification.search_truncated.