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.0bits, 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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| probability_flow-1.4.1.tar.gz | 142.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.