Skip to main content

probability-flow

Discrete causal graphs and exact inference, in pure Python with no dependencies.

pip install probability-flow

You write an argument down as a graph of propositions. Each proposition says how it depends on its parents. The library answers what any of them is worth, given everything or given only part of it.

Nothing here is approximate. Three engines, all exact, and you do not normally choose between them.

Versions below 1.0 on the index are a different library and share no code with this one. If you installed probability-flow before 1.4.0, see CHANGELOG.md.

Start here

Define a causal graph, observe some variables, and ask for a posterior.

from probability_flow import CausalGraph, CPT, render, to_text

g = CausalGraph("alarm")

burglary = g.add_variable("Burglary")
quake    = g.add_variable("Earthquake")
alarm    = g.add_variable("Alarm")

burglary.causes(alarm)               # an edge means cause -> effect
quake.causes(alarm)

burglary.set_prior(0.001)            # priors: uncaused variables only
quake.set_prior(0.002)

alarm.set_cpd(CPT()                  # P(Alarm | Burglary, Earthquake)
    .when({burglary: True,  quake: True },  0.950)
    .when({burglary: True,  quake: False},  0.940)
    .when({burglary: False, quake: True },  0.290)
    .otherwise(0.001))

burglary.posterior({alarm: True}).p_true    # 0.3736

A binary variable's states are Python's own True and False, and there is no reason to spell them "true"/"false" when the booleans exist.

Several ways to write a CPD, all ending in the same table:

# named rows: order-independent, and each one says what it means
alarm.set_cpd(CPT().when({burglary: True, quake: False}, 0.94).otherwise(0.001))

# dense table: name the causes, then index in that order
alarm.set_cpd(CPT(causes=[burglary, quake], table=[[0.001, 0.290],
                                                   [0.940, 0.950]]))

# positional: keys follow the order the causes were added
alarm.set_cpd({(True, True): 0.95, (True, False): 0.94,
               (False, True): 0.29, (False, False): 0.001})

# a single cause is fully specified by two numbers
rain.causes(wet, p_if=0.95, p_if_not=0.01)

The table form reads as table[burglary][quake]. Naming the causes is what makes it safe: the order is yours, stated explicitly, and the table is permuted into the graph's own parent order on the way in, so which order the edges happened to be added in cannot change the answer. Nesting depth is fixed by the number of causes, so a leaf may itself be a distribution (for a multi-state child) without being mistaken for another level.

The last is only well defined while rain is wet's only cause. With two causes there is no combination rule until you give one, so it raises rather than inventing one. .otherwise() fills every row you did not name, which is the common shape: a few interesting rows and a default.

Structure has a graph-level spelling too, so a.causes(b) and g.add_edge(a, b) are the same call. Numbers and questions do not: set_prior, set_cpd and posterior live on the variable only, because there is one place to look for each of them and a second spelling is a second thing to keep in step.

Looking a variable back up: g["v0"] takes the stable id, and g.by_name(...) takes the display name. The subscript uses the id because that is the key that survives what a consumer does to a document. name is content a decorator rewrites. Renaming is safe and moves the name index with it: v.name = "..." is the supported spelling, ids never move, and a name that is already taken is refused exactly as add refuses it.

to_text(g) prints the structure, every CPD row and the current beliefs, with no dependencies. render(g) draws it with matplotlib, causes above effects and observed variables highlighted. What every mark in a drawing means, and which keyword changes it, is in docs/rendering.md in the source tree.

Three decisions worth knowing

The hypothesis is a question, not a node. Nothing in a graph marks a variable as the hypothesis, the target or the root. Which variable you are asking about is an argument to posterior(). There is no node-type field, so there is nothing to drift out of step with the structure, and roots() can mean exactly "parentless" without competing for the word.

A graph may still record which question it was built to ask, as g.hypothesis, and that survives a round trip. It is a pointer at the document's purpose rather than a property of the node: the variable it names carries no marking, behaves identically, and is serialised exactly as it would be if nothing pointed at it. Inference never reads it.

The causal order has three outcomes, not two. a.is_effect_of(b) and a.is_cause_of(b) ask whether a directed path runs between two variables, and both are False when none does. Two co-effects of a common cause are genuinely unordered. There is no third method for that case, because "unrelated" would read as disconnected when the real state is an indeterminate causal relation. Ask both predicates instead.

< and > are the same question, and they are temporal: a > b means a is downstream of b. Not the arrow. causes already spells that and is the call that creates the edge. An indeterminate pair raises rather than returning False, which is what stops sorted() from quietly manufacturing a total order out of a partial one. graph.topological_order() is the linear extension when a consistent sweep is what you want.

A prior is the zero-parent CPD. There is no separate prior concept. Every variable has exactly one CPD whose conditioning set is exactly its parents, so a caused variable cannot also carry a prior, and the illegal state is unrepresentable rather than validated against. set_prior is a convenience for the zero-parent case and refuses a variable that has causes.

Extend where the extension is the identity, clear where it is not. Adding a cause to a variable that already has a CPD used to raise, and raising left no way back, since there is no clear_cpd, so the only recovery was rebuilding the graph. Now it depends on the CPD, and the test is whether the extension changes what the CPD says:

  • an additive form takes a new term at weight 0.0 bits, which contributes nothing, so it is extended and every answer it gave is unchanged,
  • a table would gain rows nobody wrote and a definition would gain a constituent that changes the proposition, so both are cleared, with a warning naming what was lost and what to write next.

Clearing is safe because the guard is downstream: a variable with no CPD fails check() and every query, loudly. An unread warning becomes an error at the next question, never a quietly wrong answer. Structure before parameters is still the advice. It is now advice rather than a wall.

Inference

Three engines, all exact, and you do not normally choose between them. posterior() uses variable elimination and marginals() uses a junction tree. The default is auto and it is the right answer unless you have a particular reason otherwise.

rain.posterior({wet: True})                      # auto: elimination
graph.marginals()                                # auto: a junction tree
rain.posterior({wet: True}, engine="enumeration")  # the reference, explicitly

Enumeration is the third, and it is never chosen for you. It sums the whole joint, which is exponential and deliberately so: it is short enough to check by eye, which is what makes it the reference the other two are tested against. It is also the slowest and, measurably, the least accurate of the three, because it accumulates the entire joint under one scale where the others rescale per factor. Being obviously correct and being accurate are different properties.

What a question costs is the largest clique, not the number of variables, and those come apart fast: a 60-variable chain is 2⁶⁰ joint states and a largest clique of 4. eliminate.cost(graph, query) answers that before doing any work, and a graph is refused when a clique is too wide rather than taking a very long time. engine_config can carry an engine and a budget per document, and leaving it empty is the normal case.

Nothing is special-cased in any of them. Evidence on a descendant of the variable you are asking about works exactly like evidence on an ancestor. The arithmetic never asks which way an edge points.

Every result carries is_exact. An approximate answer must never be mistaken for ground truth downstream, so exactness travels with the number rather than being a property of the library.

Observation

A variable can be observed directly, or through an instrument you declare on it.

ill.observe(True)                    # direct: the variable itself is known
ill.observe(None)                    # forget it again

ill.add_evidence("assay",            # declare an instrument...
    outcomes=("negative", "inconclusive", "positive"),
    table=[(0.90, 0.08, 0.02),       #   given Ill is False
           (0.05, 0.15, 0.80)])      #   given Ill is True

ill.evidence.observe("negative")     # ...and report what it said
ill.evidence.observe(None)           # or that nobody looked

observe always observes its receiver. ill.observe(...) is about ill, ill.evidence.observe(...) is about the instrument. So the object you call it on is what says what was observed, and nothing has to be inferred from context, which matters, because observing ill and observing an instrument that reports on ill give very different posteriors (1.0 against 0.269 for an LR-7 instrument).

x.evidence is a reserved handle, and a variable carries at most one, so it means the same thing in every model. You never have to keep what add_evidence returned. Declaring a second one raises, and has_evidence checks without raising, and reaching for one that was never declared says so.

The reservation is a convenience, not a claim of exclusivity: any effect of a variable is evidence about it when observed. Two independent witnesses are two ordinary children, observed directly.

second = g.add_variable("Bystander")
ill.causes(second, p_if=0.9, p_if_not=0.3)
second.observe(True)

Their likelihood ratios compose exactly as you would expect. .evidence is just the one with the short name.

Three states, and None is reserved. An observation is an outcome or None for "nobody looked". Outcomes can be anything: booleans, strings, numbers, with exactly one exception: no state may be None, and Variable refuses it. That is what makes "nobody looked" impossible to confuse with an outcome somebody named, rather than merely unlikely to be. One sharp edge remains and is_observed exists for it: None and False are both falsy, so if v.observed: cannot tell "nobody looked" from "observed to be False".

Declaring an instrument changes nothing until it is observed, and that is structural rather than incidental. An instrument is childless, so an unobserved one marginalises to 1 and inference drops it from the joint entirely. Declaring a hundred possibilities costs nothing and perturbs nothing. 14 real variables with 70 declared instruments enumerate 2¹⁴, not 2⁸⁴, and their marginals are still available, recovered from the parent.

An instrument is Pearl's virtual evidence, and its two numbers give a genuine likelihood ratio rather than an odds ratio: it has exactly one parent, so they are the two conditionals. The ambiguity that makes "LR" a loaded word elsewhere needs a second parent to appear. Accordingly likelihood_ratio raises on a multi-outcome instrument, which has no single one.

An explicit observations= argument to posterior overrides what is recorded on the graph for that one query without disturbing it, so what-ifs stay cheap, including {v: None}, meaning "and nobody looked at this one".

The keyword is observations rather than the conventional evidence because that is what the library calls the concept everywhere else: observe(state), observed, is_observed. Observe is the verb, evidence is the noun for the Pearl channel. So x.observe(True) records one, posterior(observations={x: True}) supplies them for a query, and x.evidence.observe(True) observes the virtual-evidence child. There is deliberately no evidence= alias.

Definitional nodes

Some nodes are not caused by their parents. They are them. "Someone took the key" does not happen because of "A took it", "B took it", "C took it". It is another way of saying them.

theft = g.add_definition("someone_took_it")
for s in (alice, bob, carol):
    s.constitutes(theft)

theft.set_cpd(logical.Exactly(1, over=[alice, bob, carol]))

And and Or may be declared before their constituents, since both mean the same thing over any set and there is nothing to invent:

theft.set_cpd(logical.Or())        # an OR of whatever constitutes it
alice.constitutes(theft)           # each one extends it
bob.constitutes(theft)

Only And and Or extend, and the reason is in the arithmetic: a k counts against a particular number of inputs, so Exactly(1) is cleared with a warning rather than silently re-read against a new denominator.

A definitional node's incoming edges are all constitutive, it carries no prior, and a constitutive edge may point nowhere else. It carries no prior for the same reason anything with parents does not: a prior is the zero-parent CPD, and a definitional node has constituents by construction.

Every operator is a predicate on the count of satisfied constituents: logical.And, logical.Or, logical.AtLeast(k), logical.Exactly(k), logical.AtMost(k). That is one observation rather than five: it is what makes the set extensible, the cost polynomial rather than 2^D, and the check for a vacuous k a single line.

There is no xor. logical.Exactly(1) is what you want. xor is logical.Exactly(1) only at n = 2. Generalised past that it is parity, so over three suspects it would call "all three did it" true. It is a special case of an operator that is here, not one that is missing.

Partial reveal: partially_revealed averages over what was not shown, at each unrevealed constituent's implicit prior, and hands back a table over the ones that were. It is total: every operator partially reveals, including a count predicate. Exactly(2) over three constituents and over two are different claims, so rather than re-read k against a smaller denominator, the unshown constituent is marginalised: with two of three shown true and the third at q = 0.5, Exactly(2) comes back at 0.5 rather than 1.0.

Both families keep the hidden term, and they differ in where it is averaged. An unrevealed cause of an additive node is folded into baseline in log-odds, which is exact rather than approximate: the exponent is linear in the parents' states, so E[f] = f(q) by linearity of expectation. An unrevealed constituent has no exponent to interpolate into, so it is averaged in probability space instead. Which arithmetic applies is fixed by the shape of the CPD rather than chosen.

A constituent is a variable plus the states that satisfy it, which covers negation and multi-state membership with one mechanism:

logical.And(over=[alice, (bob, False), (temp, {"hot", "very_hot"})])

True is the default on a binary variable and required on anything else. Order is not an input. Every constituent names its variable, so it is permuted into the graph's parent order for you.

Why a class rather than a table

Because a table lets you write 1e-6 into a cell the definition makes impossible, and that near-zero is a different object from a zero. With C = A AND B at prior p, softening the impossible cells to L gives

P(A | C) = (p² + p(1-p)L) / (p² + (1-p²)L)

which depends on L / p² and nothing else, so 1e-6 is 0.999998 at p = 0.5 and 0.50 at p = 0.001. It is not a safe number or a dangerous one. Here you do not author cells at all, so the zeros are exact by construction rather than by validation.

leak is the declared version: P(node | the definition is NOT satisfied), the noisy-OR sense. It defaults to 0.0 and stays exactly representable, which matters because the strength of a completed deduction caps at log2(prior/leak) bits. A declared leak is fine because whoever wrote it can check it against that ratio, and an inherited 1e-12 cannot be checked by anyone.

Exceptions, meaning the definition holding while the conclusion fails, are deliberately not a parameter. Interpose a node: D' = D AND NOT X. The reason is about what this is for rather than about parsimony. An anonymous exception probability is a claim nobody can contest, where an exception node is a claim a debater can attack.

Interchange

graph.to_json() and CausalGraph.from_json(text). The format is documented in docs/causal-graph-format.md in the repository, with worked documents at examples/speeding-camera.json and examples/twelve-angry-men.json. For the short version of both the formalism and the format, read docs/cheat-sheet.md.

Two properties worth knowing here. The CPD field is a tagged union: table, logical, noisy_and, noisy_or and independent_evidence, one per concrete CPD class, and an unknown tag is rejected rather than guessed at. And "type": "table" means authored as a table, never compiled to one: the writer never calls as_table(), and a CPD class with no tag is refused outright rather than flattened, because flattening preserves every probability exactly while destroying the structure.

Independent evidence, and gating

alarm.set_cpd(IndependentEvidence(
    baseline=0.001,
    weights={burglary: 9.876, quake: 8.179},      # bits
    gates=[Gate(grudge, modulates=burglary)],
))

Additive in log-odds, with one edge's weight selected by a gate. Weights are in bits: how far the child's log-odds move when that parent goes false to true, holding the others fixed. Zero contributes nothing, and negative is evidence against.

logit P(child) = logit(baseline) + SUM_i s_i log LR_i
                                 + s_A log( gate ? LR'_A : LR_A )

Hard gating is weight_when_gated=0.0. The gated edge contributes nothing, which is what removing a reason means. An undercutter does not make a conclusion impossible. When this parameter held a ratio its neutral value was 1.0 and 0 was refused, because log 0 is -inf and a -inf meeting a +inf from another edge is nan. In bits the parameter is already in log space, so the neutral value is zero and the pathological one does not exist.

A gate is a multiplexer, not an attenuator. weight_when_gated replaces the edge's weight rather than scaling it, so a larger magnitude strengthens the edge and the opposite sign reverses it. Gating generalises undercutting rather than only weakening it.

One gate per EDGE, not per node. gates takes a list, so "A supports H but is undercut by U1, B supports H but is undercut by U2" is written directly. It used to need an interposed node and no longer does. Several gates may share a source, and an edge takes at most one, because combining two is a modelling decision rather than an arithmetic one.

Two obstructions that must compound go through an upstream ADDITIVE node, not an OR-node. An edge takes at most one gate, so compounding has to happen before the gate, and the node that does it matters. Say a witness could not see because she was not wearing her glasses and a train was passing: an OR-node saturates on either one alone, so the glasses by themselves undercut as hard as both together, which is not what either obstruction claims. An additive node accumulates them, so each contributes and two contribute more than one. Gate on that node's posterior.

Reach for an OR-node only where either condition genuinely suffices on its own.

Noisy conjunction and disjunction

noisy.And and noisy.Or: a conjunction and a disjunction that are allowed to misfire.

plan_works.set_cpd(noisy.And({permit: 0.95, inspection: 0.9, bond: 0.99}))

alarm.set_cpd(noisy.Or({burglary: 0.9, quake: 0.3}, leak=0.001))

An activation is P(this input does its job | this input is satisfied), and it is attached to a named input, which is what makes it a claim someone can argue with. The keys are the inputs: there is no separate list and no default, so every input carries a number. A key may also be a (variable, when) pair, where when must be hashable: a tuple, not a set.

IndependentEvidence keeps its parent list implicit too: the inputs are the keys of weights plus any gate source, and a gate source joins the CPD's input list automatically at weight 0.0 bits, which is what "contributes no evidence of its own, gating only" means.

You still have to draw the edge yourself. gate_source.causes(child) is required, and omitting it is a ValueError naming the two sets. The automatic part is the CPD's input list, not the graph's edges, and the sentence above used to read as though it were both.

These are not logical with a leak. A logical is deterministic over a predicate on a count, and these are probabilistic over per-input activations. They agree in exactly one corner (every activation 1.0, no leak) and differ everywhere else.

Necessity in noisy.And is hard at every activation, so a revealed unsatisfied conjunct refutes exactly, whatever is hidden. noisy.Or does not mirror that: a satisfied input gives only a lower bound, because unseen inputs could also be firing. Noise costs the disjunction its exactness and costs the conjunction nothing.

noisy.And has no leak. The only leak a conjunction can carry without losing necessity is a downward one, where the inputs hold and the conclusion fails anyway, which is the anonymous exception parameter kept out of logical, for a reason that applies here unchanged.

The reveal game

Some consumers show a reader part of a graph and ask what that part warrants believing. probability_flow.reveal is for them. One function is public:

from probability_flow import reveal

revealed_graph = reveal.induce(g, revealed={claim, witness})

revealed_graph.by_id(claim.id).posterior()
revealed_graph.by_id(claim.id).posterior({revealed_graph.by_id(ev.id): True})
revealed_graph.marginals()

revealed going in is the set of nodes somebody has been shown, as variables or as ids. What comes back is an ordinary CausalGraph, which is the whole point: observations, un-observing with {v: None}, virtual evidence, all three engines and the renderer work on it already, so there is no second API to learn and no second implementation to keep in step.

An unrevealed parent does not become a node. It is folded into the child it belongs to, so a hidden common cause carries no correlation between two revealed children. That is deliberate rather than an approximation. A reader who was never shown the hidden path has no way to know about it, so crediting them with the correlation would measure the modeller's belief instead of theirs.

How the fold works depends on the CPD, and the two cases do not agree. An additive node interpolates in log-odds, which is exact because the exponent is linear in the parents' states. Everything else averages in probability space. Partial reveal under Definitional nodes above has the detail.

metadata is not copied. That is where an unrevealed parent's assumed prior lives, and where a generator records what it knows, so copying it would hand the reader the answer.

Not here yet

A scalable exact engine, and any CPD form that is neither additive, symmetric nor a noisy CPD. An asymmetric multi-parent causal node still writes a full table. Every compact form materialises to the same explicit rows, so each one is tested against the table it claims to abbreviate.

One constraint on whatever fast engine lands next: evidence has to be an axis of it from the start. A batched engine whose only per-row lever is "is this variable present" can express "absent" and "present but unobserved", but not "observed in this state", which is the one virtual evidence needs. Retrofitting that onto a presence mask is the expensive version.

Development

uv sync                  # installs the dev group: pytest, ruff, matplotlib
uv run pytest
uv run ruff check

No flags, and CI runs the same three commands. dev is a dependency group rather than an extra precisely so that a bare uv sync picks it up. When it was also an extra, a plain sync gave you pytest without ruff or matplotlib, so ruff check failed and the two viz tests silently skipped.

Installing the library itself pulls in nothing: dependencies = []. The one user-facing extra is viz, for render(). to_text() needs no dependencies.

Metadata

Release files for probability-flow 1.4.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for probability-flow 1.4.1
File Size Uploaded Provenance
probability_flow-1.4.1.tar.gz 142.0 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for probability-flow 1.4.1
File Interpreter ABI Platform
probability_flow-1.4.1-py3-none-any.whl Python 3 none any Details

Total release size: 295.6 kB

Release files / probability_flow-1.4.1.tar.gz

Download URL probability_flow-1.4.1.tar.gz
Size 142.0 kB
Tags Source
SHA-256 checksum
How to use checksums
10c711153d66cb22cfb40020052d495a2cf4412923ad13388db271639488690f
BLAKE2b-256 checksum
How to use checksums
743147f4c5673a828158c03c457297ec5dd157436f9274c41e47c3d06edd2ef5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release files / probability_flow-1.4.1-py3-none-any.whl

Download URL probability_flow-1.4.1-py3-none-any.whl
Size 153.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f53ea9cfa5bd164bef6a1addda0d4a9a43962a7e59a28193cb2cb34a5e279e38
BLAKE2b-256 checksum
How to use checksums
a6fd588f7fe7219571cf0eb52b376ca97698996782053fc1939d0166ab5a0946
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Security for probability-flow 1.4.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page