ReferenceDomain knowledge
Domain knowledge
Domain knowledge: prior constraints on causal structure. Import from ergodic.knowledge.
DomainKnowledge records what a user knows before looking at the data, the
edges that must or must not exist, the temporal order of variables, which
variables are roots or sinks, and which pairs share a hidden confounder. A
skeleton restriction (restrict_to) caps which pairs may be adjacent at all.
It is an immutable value object built up with small methods, and two knowledge
objects combine with & (more knowledge means fewer admissible graphs).
Every builder but confound also takes confidence=. Left out (or 1), the
statement is a hard constraint, stored as before. A value in [0.5, 1) makes it
soft: decode() weighs it against a model's edge probabilities
instead of enforcing it. The confidence applies to each pair the statement
covers: soft tiers over 16 cross-tier pairs act as 16 beliefs of that confidence,
not one belief that the whole tiering holds. Soft statements live in
soft; every constraint projection (conflicts,
violations, permits_edge, edge_mask, apply_to) reads the hard ones
only, and the discovery searches refuse soft statements rather than drop them
(hard() removes them). log_odds(),
log_prior() and energy() read the soft
ones.
On one pair there are three exclusive states (no edge, a -> b, b -> a),
and a soft statement with confidence c multiplies the chance of each state by
c when the state makes it hold and by 1 - c when it makes it fail. Against
"no edge" that is a likelihood ratio on each directed state: c / (1 - c) for a
state that makes the statement hold where "no edge" fails it, (1 - c) / c for
the reverse, and 1 otherwise. Different statements multiply; the same statement
made twice is one statement (soft is a set). A soft order (order, tiers)
is weighed pair by pair, on the direct edge against it, and is not closed
transitively, as a hard one is.
Also exported from ergodic: InconsistentKnowledgeError, KnowledgeConflictWarning, DomainKnowledge.
InconsistentKnowledgeError#
Domain knowledge contradicts itself (for example a required edge cycle).
KnowledgeConflictWarning#
The model strongly disagrees with a knowledge statement.
decode() raises it once per decode when some pair a statement
covers gets a model probability below 0.1 for the statement's part on that
pair. Filter it by category, for example
warnings.simplefilter("ignore", KnowledgeConflictWarning).
KnowledgeStatement#
class KnowledgeStatement(
kind: str,
args: tuple[Any, ...],
confidence: float | None = None,
) -> None
One knowledge statement.
kind names the builder: "require", "forbid",
"forbid_adjacency", "order" and "confound" take two names in
args; "forbid_between" two groups of names; "tiers" the tiers in
order, each a tuple of names; "root" and "sink" one name (a builder call
naming several makes one statement per name); "restrict_to" the skeleton
pairs. The builders sort groups, tiers and pairs. confidence is None for
a hard statement and in [0.5, 1) for a soft one; confound is hard only.
Everything is checked on construction.
| Attribute | Description |
|---|---|
kind |
The builder that made the statement. |
args |
Its arguments, as above. |
confidence |
None (hard) or the soft statement's confidence. |
| Field | Type | Default |
|---|---|---|
kind |
str |
required |
args |
tuple[Any, ...] |
required |
confidence |
float | None |
None |
KnowledgeStatement.is_soft#
property KnowledgeStatement.is_soft: bool
Whether the statement is soft (it has a confidence below 1).
KnowledgeStatement.variables#
property KnowledgeStatement.variables: frozenset[str]
Every variable the statement names.
DomainKnowledge#
class DomainKnowledge(
variables: frozenset[str] = <factory>,
required: frozenset[tuple[str, str]] = <factory>,
forbidden: frozenset[tuple[str, str]] = <factory>,
precedes: frozenset[tuple[str, str]] = <factory>,
roots: frozenset[str] = <factory>,
sinks: frozenset[str] = <factory>,
confounded: frozenset[frozenset[str]] = <factory>,
allowed_adjacencies: frozenset[frozenset[str]] | None = None,
soft: frozenset[KnowledgeStatement] = <factory>,
nonadjacent: frozenset[frozenset[str]] = <factory>,
) -> None
Prior constraints on a causal graph over named variables.
The hard constraints live in the set fields (required, forbidden,
nonadjacent, precedes, roots, sinks, confounded,
allowed_adjacencies), the soft statements in soft.
forbid_adjacency() records its pair in nonadjacent as well as both
directions in forbidden: an adjacency ban rules out a bidirected edge too,
while two directed bans (forbid(a, b) and forbid(b, a)) do not, so
confound(a, b) with them says "confounded, and neither causes the other".
| Field | Type | Default |
|---|---|---|
variables |
frozenset[str] |
factory |
required |
frozenset[tuple[str, str]] |
factory |
forbidden |
frozenset[tuple[str, str]] |
factory |
precedes |
frozenset[tuple[str, str]] |
factory |
roots |
frozenset[str] |
factory |
sinks |
frozenset[str] |
factory |
confounded |
frozenset[frozenset[str]] |
factory |
allowed_adjacencies |
frozenset[frozenset[str]] | None |
None |
soft |
frozenset[KnowledgeStatement] |
factory |
nonadjacent |
frozenset[frozenset[str]] |
factory |
DomainKnowledge.hard#
def DomainKnowledge.hard() -> DomainKnowledge
This knowledge without its soft statements.
The discovery searches read hard constraints only and refuse soft
statements rather than drop them silently; pass knowledge.hard() to
run one with the hard part. The variables stay listed.
DomainKnowledge.with_variables#
def DomainKnowledge.with_variables(*names: str) -> DomainKnowledge
Return knowledge that also lists names in variables.
Every builder records the names it mentions there; this adds names without adding a constraint.
DomainKnowledge.require#
def DomainKnowledge.require(
cause: str,
effect: str,
*,
confidence: float | None = None,
) -> DomainKnowledge
Require a directed edge cause -> effect.
| Parameter | Description |
|---|---|
cause |
The edge's source. |
effect |
The edge's target. |
confidence |
None or 1 for a hard constraint; a value in [0.5, 1) makes it soft (0.5 changes nothing). |
DomainKnowledge.forbid#
def DomainKnowledge.forbid(
cause: str,
effect: str,
*,
confidence: float | None = None,
) -> DomainKnowledge
Forbid the directed edge cause -> effect (the reverse stays allowed).
confidence makes it soft, as for require(). Forbidding both
directions bans every edge but a bidirected one, so it can sit beside
confound().
DomainKnowledge.forbid_adjacency#
def DomainKnowledge.forbid_adjacency(
a: str,
b: str,
*,
confidence: float | None = None,
) -> DomainKnowledge
Forbid any edge between two variables, bidirected included.
confidence makes it soft, as for require(); a soft ban weighs the
two directed edges (decoders see no other edge type).
DomainKnowledge.order#
def DomainKnowledge.order(
before: str,
after: str,
*,
confidence: float | None = None,
) -> DomainKnowledge
Record that before precedes after in time, so after cannot cause it.
confidence makes it soft, as for require(). A soft order weighs
only the direct edge after -> before and is not closed transitively.
DomainKnowledge.with_tiers#
def DomainKnowledge.with_tiers(
tiers: Sequence[Iterable[str]],
*,
confidence: float | None = None,
) -> DomainKnowledge
Add a tiered order: no variable in a later tier causes one in an earlier tier.
confidence makes it soft, as for require(): one statement that
weighs every edge from a later tier into an earlier one, each with that
confidence. It says nothing about pairs within a tier, as the hard form
does not.
DomainKnowledge.root#
def DomainKnowledge.root(
*names: str,
confidence: float | None = None,
) -> DomainKnowledge
Mark variables as roots: no incoming directed edges.
confidence makes it soft, as for require(), with one statement
per name, weighing every edge into it.
DomainKnowledge.sink#
def DomainKnowledge.sink(
*names: str,
confidence: float | None = None,
) -> DomainKnowledge
Mark variables as sinks: no outgoing directed edges.
confidence makes it soft, as for require(), with one statement
per name, weighing every edge out of it.
DomainKnowledge.confound#
def DomainKnowledge.confound(
a: str,
b: str,
*,
confidence: float | None = None,
) -> DomainKnowledge
Record that a and b share a hidden common cause.
A confounder is hard only: confidence must be None or 1. A hidden
common cause is not an edge state, so edge probabilities cannot weigh it.
Add forbid(a, b) and forbid(b, a) to say that neither causes the
other as well.
DomainKnowledge.forbid_between#
def DomainKnowledge.forbid_between(
group_a: Iterable[str],
group_b: Iterable[str],
*,
confidence: float | None = None,
) -> DomainKnowledge
Forbid every directed edge from group_a into group_b.
confidence makes it soft, as for require(): one statement that
weighs every such edge, each with that confidence.
DomainKnowledge.restrict_to#
def DomainKnowledge.restrict_to(
skeleton: MixedGraph | Iterable[tuple[str, str]],
*,
confidence: float | None = None,
) -> DomainKnowledge
Allow adjacencies only on the pairs of skeleton.
skeleton is a MixedGraph (its edges name
the pairs, whatever the marks) or an iterable of (u, v) pairs. A
pair outside the allow-list is banned in every form: directed either
way, bidirected, or undirected. Restricting an already restricted
object keeps the intersection, so a second call only narrows the
support. An empty iterable allows no adjacencies at all.
confidence makes it soft, as for require(): one statement that
weighs both directed edges of every pair outside the skeleton (among the
variables being decoded), each pair with that confidence. Soft
restrictions do not intersect; each is its own statement.
DomainKnowledge.combine#
def DomainKnowledge.combine(other: DomainKnowledge) -> DomainKnowledge
Combine two knowledge objects by taking the union of their constraints.
Skeleton allow-lists intersect: an adjacency survives only when both
sides allow it, and None (unrestricted) defers to the other side.
Soft statements are pooled as a set, so a statement both sides make (same
kind, arguments and confidence) counts once.
DomainKnowledge.statements#
def DomainKnowledge.statements() -> tuple[KnowledgeStatement, ...]
Every statement, read from what is stored: the hard ones, then the soft ones.
Hard constraints are listed one per stored item, so equal knowledge lists
equal statements however it was built: a required pair (require), an
adjacency ban (forbid_adjacency), any other forbidden direction
(forbid; forbid_between and two-way forbids show up this way), a
precedence pair (order; tiers show up as their cross-tier pairs), a
root, a sink, a confounded pair, and one restrict_to for the
allow-list. Soft statements are listed as made, sorted by kind, then
arguments.
DomainKnowledge.conflicts#
def DomainKnowledge.conflicts() -> list[str]
Return a list of internal contradictions among the hard constraints.
Empty when consistent. Soft statements never conflict: they are weighed,
not enforced. A confounded pair under an adjacency ban
(forbid_adjacency()) or outside a skeleton restriction is a
contradiction; a confounded pair with both directed edges forbidden is not
(confounded, and neither causes the other).
DomainKnowledge.validate#
def DomainKnowledge.validate() -> None
Raise InconsistentKnowledgeError if the hard constraints contradict.
Soft statements are never checked: they are weighed, not enforced.
DomainKnowledge.permits_edge#
def DomainKnowledge.permits_edge(cause: str, effect: str) -> bool
Return True if a directed edge cause -> effect is allowed.
Precedence is read transitively: with A before B and B
before C, an edge C -> A is not permitted. Under a skeleton
restriction, a pair outside the allow-list fails in both directions.
Only hard constraints count; soft statements permit everything.
DomainKnowledge.requires_edge#
def DomainKnowledge.requires_edge(cause: str, effect: str) -> bool
Return True if cause -> effect was required with require().
Only the stated pair counts; nothing is inferred from tiers or other constraints.
A soft require does not count.
DomainKnowledge.precedence#
def DomainKnowledge.precedence() -> frozenset[tuple[str, str]]
Every (before, after) pair, with the order read transitively.
The projection discovery reads to orient: (a, b) here means b
cannot be an ancestor of a, which in a latent-variable search
(FCI) pins an arrowhead at b on any surviving adjacency.
DomainKnowledge.violations#
def DomainKnowledge.violations(graph: MixedGraph) -> list[str]
Return the ways graph breaks the hard constraints, empty when consistent.
Soft statements are not checked. A node in graph.latent is read as a
hidden cause, not as one of the variables: a latent node with two or more
observed children joins each pair of them as a <-> b would (the way a
bow, a -> b plus a <-> b, is written), for confounded pairs,
adjacency bans and a skeleton restriction alike, and its edges count
against neither a root nor a sink. Two directed bans on one pair
(forbid(a, b) and forbid(b, a)) rule out every edge there but a
bidirected one; forbid_adjacency() rules out that too.
DomainKnowledge.is_consistent#
def DomainKnowledge.is_consistent(graph: MixedGraph) -> bool
Return True if graph satisfies every hard constraint.
DomainKnowledge.apply_to#
def DomainKnowledge.apply_to(graph: MixedGraph) -> MixedGraph
Enforce the hard constraints on graph: drop forbidden, add required edges.
A pair under an adjacency ban (forbid_adjacency()) loses its edge
whatever the marks; a pair forbidden in both directions keeps a bidirected
edge (a hidden common cause, neither causing the other) and loses any
other; a single-direction ban only drops the matching directed edge. Under a
skeleton restriction, every edge on a pair outside the allow-list is
dropped too. Edges left undirected or with a circle are oriented when
only one direction stays permitted. The result is returned as a new
graph. Soft statements are not applied.
DomainKnowledge.edge_mask#
def DomainKnowledge.edge_mask(
variables: Sequence[str] | None = None,
) -> tuple[npt.NDArray[np.bool_], list[str]]
Return a boolean matrix of permitted directed edges and its variable order.
mask[i, j] is True when an edge from variable i to variable
j is allowed. The diagonal is False, and a skeleton restriction
zeroes both directions of every pair outside its allow-list. Useful as
a search mask for score-based and continuous-optimization discovery.
Only hard constraints shape the mask; log_odds() reads the soft ones.
DomainKnowledge.log_odds#
def DomainKnowledge.log_odds(
variables: Sequence[str],
) -> npt.NDArray[np.float64]
The soft statements' summed log likelihood ratios on each directed edge.
out[i, j] is log of the product, over every soft statement, of the
ratio it puts on the state variables[i] -> variables[j] against "no
edge" on that pair: log(c / (1 - c)) when the edge makes the statement
hold and no edge fails it, log((1 - c) / c) the other way round, and 0
otherwise. For example require(a, b) adds log(c / (1 - c)) at
[a, b] and nothing at [b, a]; root(x) adds log((1 - c) / c)
at every [v, x]. The diagonal is zero and hard constraints are left
out (they are enforced, not weighed). decode() multiplies
the pair states by exp(out) and renormalizes each pair.
| Parameter | Description |
|---|---|
variables |
The variable order of the rows and columns. Every variable a soft statement names must be among them; root, sink and restrict_to range over all of them. |
Returns: A (d, d) float matrix.
| Raises | Description |
|---|---|
ValueError |
A soft statement names a variable outside variables, or a name repeats. |
DomainKnowledge.log_prior#
def DomainKnowledge.log_prior(graph: MixedGraph) -> float
The log prior of graph under this knowledge, up to a constant.
Minus infinity when graph breaks a hard constraint
(violations()). Otherwise the sum, over every soft statement and
every pair it speaks about, of log c when the pair's state in
graph satisfies the statement and log(1 - c) when it does not. That
is the reading log_odds() and the decoders use, so a multi-pair
statement (tiers, a root, a sink, restrict_to, forbid_between)
counts once per pair, and an order is read as "no direct edge against it".
Hard knowledge alone gives 0 for every consistent graph.
| Parameter | Description |
|---|---|
graph |
A graph with directed (and, ignored here, bidirected) edges, such as a DAG or an ADMG. Latent nodes are not variables: root, sink and restrict_to range over the other nodes. |
| Raises | Description |
|---|---|
ValueError |
graph has an undirected or circle-marked edge, or a soft statement names a variable that is not an observed node. |
DomainKnowledge.energy#
def DomainKnowledge.energy(graph: MixedGraph) -> float
Minus log_prior(): plus infinity when a hard constraint is broken.