ergodicDocs
Products: switch from ergodic to another product
Get started

GuidesDecode a DAG from edge probabilities

Decode a DAG from edge probabilities

Turn a matrix of edge probabilities into a DAG with a named decoder, and see which edges it dropped and why. You'll compare decoders, draw candidate DAGs with a seeded sampler, constrain the result with domain knowledge, and weigh what you only believe against what the model says.

You'll need

Run the program#

The matrix is the one from Work with edge probabilities: four edges pass 0.5, and three of them form a cycle.

decode_a_graph.pyDownload
import numpy as np

from ergodic import DomainKnowledge, EdgeProbabilities, decode

# 1. Independent edge probabilities; three edges above 0.5 form a cycle.
names = ("rain", "sprinkler", "wet", "slippery")
values = np.array(
    [
        [0.00, 0.45, 0.91, 0.06],
        [0.40, 0.00, 0.83, 0.05],
        [0.04, 0.07, 0.00, 0.88],
        [0.58, 0.02, 0.10, 0.00],
    ]
)
probs = EdgeProbabilities(names, values, kind="independent")

# 2. The default decoder: greedy pair-MAP.
result = decode(probs)
print(result)
print(result.graph)
print(result.diagnostics["rejected_cycles"], result.diagnostics["note"])

# 3. A lower threshold nominates more edges.
print(decode(probs, threshold=0.3).graph)

# 4. Exact MAP and the expected-count decoder.
print(decode(probs, "exact_map").graph)
print(decode(probs, "expected_count").graph)

# 5. Candidate DAGs from a seeded sampler, and their consensus.
sampled = decode(probs, "pair_sampler", samples=200, seed=0)
print(sampled)
print(sampled.graph)

# 6. Knowledge: nothing causes the sprinkler.
knowledge = DomainKnowledge().root("sprinkler")
print(decode(probs, threshold=0.3, knowledge=knowledge).graph)
Recorded output
DecodeResult(decode:greedy_v1: decoded dag, 4 nodes, 3 edges)
MixedGraph(kind=dag, nodes=4, edges=[rain --> wet, slippery <-- wet, sprinkler --> wet])
(1,) not a graph posterior; orientation not certified
MixedGraph(kind=dag, nodes=4, edges=[rain --> sprinkler, rain --> wet, slippery <-- wet, sprinkler --> wet])
MixedGraph(kind=dag, nodes=4, edges=[rain --> wet, slippery <-- wet, sprinkler --> wet])
MixedGraph(kind=dag, nodes=4, edges=[rain --> sprinkler, rain --> wet, slippery <-- wet, sprinkler --> wet])
DecodeResult(decode:pair_sampler_v1: decoded dag, 4 nodes, 3 edges, consensus of 200 candidates)
MixedGraph(kind=dag, nodes=4, edges=[rain --> wet, slippery <-- wet, sprinkler --> wet])
MixedGraph(kind=dag, nodes=4, edges=[rain <-- sprinkler, rain --> wet, slippery <-- wet, sprinkler --> wet])

Choose a decoder#

Every decoder reads the pair-state view (see Work with edge probabilities) and scores an edge by w(i -> j) = log P(i -> j) - log P(no edge).

Decoder What it does Settings
"greedy" (greedy_v1, default) Each pair's better direction when w > 0 (or its value is above threshold), strongest first, skipping cycles. threshold
"exact_map" (exact_map_v1) The DAG with the best total score, found exactly by A* search over orderings. max_nodes
"expected_count" (expected_count_v2) The best K edges compatible with the exact ordering, K the rounded expected edge count of what the hard knowledge leaves open. max_nodes
"soft_f1" (soft_f1_v2) The same, with K from a label-free F1 estimate. max_nodes
"pair_sampler" (pair_sampler_v1) Draws each pair's state, adds edges in random order, drops those that close a cycle. samples, seed
"perturb_and_map" (perturb_and_map_v1) Exact MAP under randomly perturbed pair scores, once per sample. samples, seed, max_nodes

Greedy is the default. In the research it came from it matched exact MAP on 238 of 240 graphs. It isn't best everywhere: on one physical benchmark the expected-count decoder scored better. The decoders that search orderings exactly refuse more than max_nodes variables (20 by default), because that search can grow exponentially.

A short name reads as the latest version, and a versioned id never changes its results. expected_count_v1 and soft_f1_v1 are the 0.2 decoders: their budget K also counts the probability of edges your hard knowledge forbids. Without knowledge they agree with _v2.

Read the result#

decode returns a DecodeResult. graph is a DAG over every variable (an ADMG when your knowledge names hidden confounders), method is "decode:<id>", candidates holds every decoded DAG and probabilities the input. diagnostics records:

Key What it holds
decoded, native Always True and False.
decoder, parameters The versioned id and its settings, such as threshold.
threshold_source "decoder" when you passed a threshold, None otherwise. Through Welt it can be "welt_native".
seed, samples The sampler's seed and draw count (None and 1 otherwise).
rejected_cycles Per candidate, how many nominated edges were skipped for closing a directed cycle.
order_skipped Per candidate, how many were skipped only because they'd break the order knowledge.
objective The total log score of graph's directed edges on the input.
knowledge_objective The same score on the pair states the soft knowledge reweighted.
graph_kind, confounded_pairs "dag", or "admg" with the confound pairs.
knowledge_audit, knowledge_cost, knowledge_conflicts What the model makes of your knowledge; see Decode with what you know.
edge_budget K, for the cardinality decoders.
source_kind, source_provenance Where the probabilities came from.
note "not a graph posterior; orientation not certified".

A decoded graph is your choice, not the model's claim

A model that scores edges one at a time doesn't promise a DAG. The decoder picks one, and a different decoder, threshold or seed can pick another. Sampler candidates are not draws from a posterior over graphs, and nothing certifies the orientations. To see how much of the model's posterior over DAGs a decoded graph carries, read it with Evaluate a candidate graph.

Decoding is deterministic: ties go to the earlier variable, the same seed gives the same candidates, and neither the input nor numpy's global random state changes.

Use domain knowledge#

decode(..., knowledge=...) applies hard constraints:

Every variable the knowledge names must be in the probabilities. The decoded graph is checked against the knowledge before it's returned.

Decode with what you know#

Hard constraints say "this is so". Often you only believe something: you're fairly sure, not certain. Give any constraint a confidence= between 0.5 and 1 and the decoder weighs it against the model instead of enforcing it. Every decode with knowledge also tells you where your knowledge and the model disagree.

decode_with_knowledge.pyDownload
import numpy as np

from ergodic import DomainKnowledge, EdgeProbabilities, decode, identify_effect

names = ("rain", "sprinkler", "wet", "slippery")
values = np.array(
    [
        [0.00, 0.45, 0.91, 0.06],
        [0.40, 0.00, 0.83, 0.05],
        [0.04, 0.07, 0.00, 0.88],
        [0.58, 0.02, 0.10, 0.00],
    ]
)
probs = EdgeProbabilities(names, values, kind="independent")

# 1. A soft statement: you're fairly sure nothing causes the sprinkler.
print(decode(probs, threshold=0.3).graph)
soft = DomainKnowledge().root("sprinkler", confidence=0.7)
print(decode(probs, threshold=0.3, knowledge=soft).graph)

# 2. A statement the model disagrees with: rain doesn't wet the ground.
doubtful = soft.forbid("rain", "wet", confidence=0.8)
result = decode(probs, threshold=0.3, knowledge=doubtful)
print(result.graph)

# 3. The audit: what the model makes of each statement.
for entry in result.diagnostics["knowledge_audit"]:
    print(
        f"{entry['statement']:<20} confidence {entry['confidence']}, "
        f"model {entry['probability']:.2f}, holds {entry['holds']}, "
        f"without knowledge {entry['holds_without_knowledge']}"
    )
print([entry["statement"] for entry in result.diagnostics["knowledge_conflicts"]])
print(round(result.diagnostics["knowledge_cost"], 3))

# 4. A hidden common cause of the sprinkler and slipperiness: an ADMG.
confounded = decode(probs, knowledge=DomainKnowledge().confound("sprinkler", "slippery"))
print(confounded)
print(confounded.graph)
print(identify_effect(confounded.graph, "sprinkler", "slippery"))
Recorded output
MixedGraph(kind=dag, nodes=4, edges=[rain --> sprinkler, rain --> wet, slippery <-- wet, sprinkler --> wet])
MixedGraph(kind=dag, nodes=4, edges=[rain <-- sprinkler, rain --> wet, slippery <-- wet, sprinkler --> wet])
MixedGraph(kind=dag, nodes=4, edges=[rain <-- sprinkler, rain --> wet, slippery <-- wet, sprinkler --> wet])
no rain -> wet       confidence 0.8, model 0.09, holds False, without knowledge False
sprinkler is a root  confidence 0.7, model 0.65, holds True, without knowledge False
['no rain -> wet']
0.205
DecodeResult(decode:greedy_v1: decoded admg, 4 nodes, 4 edges)
MixedGraph(kind=admg, nodes=4, edges=[rain --> wet, slippery <-> sprinkler, slippery <-- wet, sprinkler --> wet])
Identification(frontdoor: P(slippery | do(sprinkler)) = sum_m P(m | sprinkler) sum_x' P(slippery | m, x') P(x'), with m = {wet})

How a soft statement weighs#

Every statement is about pairs of variables, and each pair has three states: no edge, one direction, the other. A statement you hold with confidence c multiplies the chance of each state by c where the state agrees with it and by 1 - c where it doesn't. So against "no edge", require("a", "b", confidence=c) multiplies the odds of a --> b by c / (1 - c), forbid divides them by it, and root("x", confidence=c) divides the odds of every edge into x. The decoders then run unchanged on the reweighted states, and "exact_map" finds the best graph for the combined score exactly. knowledge.log_odds(variables) shows the weights and knowledge.log_prior(graph) scores a graph under them.

Read the audit#

Key What it holds
knowledge_audit One entry per statement: statement, kind, confidence (None for hard), probability, min_pair_probability, conflict_pairs, holds, holds_without_knowledge, note. Soft statements are listed as you made them; hard ones one per stored constraint, so hard tiers show as one order entry per cross-tier pair.
knowledge_conflicts The entries with at least one conflict pair.
knowledge_cost The knowledge-free decode's score minus this one's. Never negative for "exact_map"; the other decoders can come out negative.
knowledge_violations_without_knowledge What knowledge.violations() reports on the knowledge-free graph: the hard knowledge as a whole, with order read through chains, confound pairs left out (that graph is a DAG). None with audit=False.
knowledge_audited False when you passed audit=False.

probability is the chance of the whole statement, with each pair taken on its own, so a statement about many pairs (tiers, a root, restrict_to) can get a low number even when no single pair is surprising. That's why conflicts are judged pair by pair: min_pair_probability is the smallest chance the model gives the statement's part on one pair, and conflict_pairs lists the pairs below 0.1, weakest first. For tiers that's an edge against the order the model puts above 0.9; for root("x"), an edge into x above 0.9. A conflict raises one KnowledgeConflictWarning (a UserWarning), which you can filter by category.

holds and holds_without_knowledge read what each statement means: "a before b" fails when b is an ancestor of a along any directed path, not only through a direct edge. The probabilities read direct edges only (for an order, the chance of no direct edge against it), and the entry's note says so. An order implied only through a chain of statements, such as c before a from order("a", "b") and order("b", "c"), has no entry of its own; knowledge_violations_without_knowledge reports it.

holds_without_knowledge comes from a second decode with the same settings and seed but no knowledge, so the audit costs one more decode. That can cost more than the decode itself: hard constraints often make "exact_map"'s search cheap, and the knowledge-free search has none, and a sampler draws its candidates again. Pass audit=False (or Decoder(..., audit=False)) to skip the audit; the decoded graph doesn't change.

Confounders give an ADMG#

With confound("a", "b") the directed part is decoded as before and a <-> b is added, to graph and to every candidate. If the decoder also put a directed edge on that pair, a graph can't hold both edges on one pair, so the confounder becomes an explicit latent node, L__a__b, with an edge into each end. It's in graph.latent, and diagnostics["latent_confounders"] maps it to its pair. Identification treats both forms the same way.

Decode a Welt model's scores#

AVICI and CDFM return directed graphs that may hold cycles, so ergodic.welt.discover refuses them before anything is paid for. Pass decoder= to opt in: discover(data, "cdfm", decoder="greedy") runs the model once, reads its scores as independent edge probabilities and decodes them. Greedy then uses the model's own threshold (0.5 for AVICI, CDFM's adaptive one). The result keeps the scores in diagnostics["edge_probabilities"], so you can try another decoder locally without a new run. The exact decoders' max_nodes cap (20 by default) is checked against your data's variables before anything is uploaded. See Decode AVICI and CDFM.

Pass knowledge= without decoder= and ergodic decodes with greedy at the model's own threshold, as decoder="greedy" would.

Arrow returns a DAG of its own, so ergodic uses that by default. Its scores are decoded only if you ask, with decoder=, and are then read as independent marginals, like AVICI's and CDFM's. That's a modelling choice: Welt doesn't state that Arrow's scores are marginals.

Next steps#

Documentation

Search ergodic 0.3.0

Search ergodic by task, function or error.