probability-flow 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {probability_flow-0.2.0 → probability_flow-0.3.0}/PKG-INFO +8 -1
- {probability_flow-0.2.0 → probability_flow-0.3.0}/README.md +7 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/__init__.py +4 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/argument.py +53 -13
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/compile.py +12 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/handle.py +35 -10
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/visualization.py +25 -4
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/__init__.py +4 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/bp/engine.py +1 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/__init__.py +4 -1
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/base.py +8 -0
- probability_flow-0.3.0/probability_flow/core/cpd/correlated_evidence.py +241 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/independent_evidence.py +13 -9
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/noisy_and.py +2 -2
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/noisy_or.py +2 -2
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/exact.py +1 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/network.py +41 -3
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/node.py +21 -0
- probability_flow-0.3.0/probability_flow/visualization/animate.py +210 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/visualization/image.py +113 -25
- probability_flow-0.3.0/probability_flow/visualization/layout.py +301 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/pyproject.toml +6 -1
- probability_flow-0.2.0/probability_flow/visualization/layout.py +0 -166
- {probability_flow-0.2.0 → probability_flow-0.3.0}/.gitignore +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/LICENSE +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/__init__.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/calibrate.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/generate.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/_logmath.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/bp/__init__.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/bp/message.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/tabular.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/__init__.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/_util.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/difficulty.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/dseparation.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/loopiness.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/manipulability.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/structure.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/py.typed +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/visualization/__init__.py +0 -0
- {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/visualization/style.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: probability-flow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A from-scratch, modular discrete Bayesian-network library.
|
|
5
5
|
Project-URL: Homepage, https://github.com/scalable-oversight-benchmarks/probability-flow
|
|
6
6
|
Project-URL: Repository, https://github.com/scalable-oversight-benchmarks/probability-flow
|
|
@@ -118,6 +118,13 @@ combiner) from the *object* that implements it (the CPD).
|
|
|
118
118
|
sources of evidence: `logit P(node=1) = logit(prior) + sum of log(lr)` over the
|
|
119
119
|
active inputs. Adding weights of evidence is Bayes' rule for independent
|
|
120
120
|
likelihood ratios. Set per edge with `add_input(x, lr=...)`.
|
|
121
|
+
- **CorrelatedEvidenceCPD**. Independent evidence plus pairwise couplings, for
|
|
122
|
+
*redundant* inputs the additive rule would otherwise double-count (two reports of
|
|
123
|
+
one fact, two clues from a shared cause): it adds a pairwise term
|
|
124
|
+
`+ sum J_ij s_i s_j` (a negative `J` makes two inputs sub-additive when both fire,
|
|
125
|
+
a positive one synergistic). The coupling lives inside the single CPD factor, so
|
|
126
|
+
it adds no edge to the graph and no loop to the solver, and stays a valid
|
|
127
|
+
distribution for any real `J`.
|
|
121
128
|
- **NoisyOrCPD**. "Any one cause can fire the effect":
|
|
122
129
|
`P(node=0) = (1 - leak) * product of (1 - activation)` over present causes.
|
|
123
130
|
Declared with `node.noisy_or(leak=...)` and `add_input(cause, activation=...)`.
|
|
@@ -84,6 +84,13 @@ combiner) from the *object* that implements it (the CPD).
|
|
|
84
84
|
sources of evidence: `logit P(node=1) = logit(prior) + sum of log(lr)` over the
|
|
85
85
|
active inputs. Adding weights of evidence is Bayes' rule for independent
|
|
86
86
|
likelihood ratios. Set per edge with `add_input(x, lr=...)`.
|
|
87
|
+
- **CorrelatedEvidenceCPD**. Independent evidence plus pairwise couplings, for
|
|
88
|
+
*redundant* inputs the additive rule would otherwise double-count (two reports of
|
|
89
|
+
one fact, two clues from a shared cause): it adds a pairwise term
|
|
90
|
+
`+ sum J_ij s_i s_j` (a negative `J` makes two inputs sub-additive when both fire,
|
|
91
|
+
a positive one synergistic). The coupling lives inside the single CPD factor, so
|
|
92
|
+
it adds no edge to the graph and no loop to the solver, and stays a valid
|
|
93
|
+
distribution for any real `J`.
|
|
87
94
|
- **NoisyOrCPD**. "Any one cause can fire the effect":
|
|
88
95
|
`P(node=0) = (1 - leak) * product of (1 - activation)` over present causes.
|
|
89
96
|
Declared with `node.noisy_or(leak=...)` and `add_input(cause, activation=...)`.
|
|
@@ -12,12 +12,14 @@ from .core import (
|
|
|
12
12
|
CPD,
|
|
13
13
|
BayesianNetwork,
|
|
14
14
|
CompiledCPD,
|
|
15
|
+
CorrelatedEvidenceCPD,
|
|
15
16
|
ExactSolver,
|
|
16
17
|
IndependentEvidenceCPD,
|
|
17
18
|
LoopySolver,
|
|
18
19
|
Node,
|
|
19
20
|
NoisyAndCPD,
|
|
20
21
|
NoisyOrCPD,
|
|
22
|
+
PendingWeightError,
|
|
21
23
|
TabularCPD,
|
|
22
24
|
)
|
|
23
25
|
|
|
@@ -36,6 +38,8 @@ __all__ = [
|
|
|
36
38
|
"CPD",
|
|
37
39
|
"TabularCPD",
|
|
38
40
|
"IndependentEvidenceCPD",
|
|
41
|
+
"CorrelatedEvidenceCPD",
|
|
39
42
|
"NoisyOrCPD",
|
|
40
43
|
"NoisyAndCPD",
|
|
44
|
+
"PendingWeightError",
|
|
41
45
|
]
|
|
@@ -41,9 +41,12 @@ class _Claim(Node):
|
|
|
41
41
|
|
|
42
42
|
def __init__(self, name: str, prior: float = 0.5):
|
|
43
43
|
super().__init__(name, prior=prior)
|
|
44
|
-
self._edges: list[tuple["Node", float, str]] = [] # (src, lr, kind)
|
|
44
|
+
self._edges: list[tuple["Node", "float | None", str]] = [] # (src, lr, kind)
|
|
45
45
|
self._strict: list["Node"] = [] # strict sources
|
|
46
46
|
self._undercuts: list[tuple["Node", "Node"]] = [] # (attacked source, undercutter)
|
|
47
|
+
# (source_a, source_b, J): residual correlation between two defeasible
|
|
48
|
+
# sources of this conclusion, lowered to a CorrelatedEvidenceCPD coupling.
|
|
49
|
+
self._correlations: list[tuple["Node", "Node", "float | None"]] = []
|
|
47
50
|
self._no_undermine = False
|
|
48
51
|
# opaque, serialized but uninterpreted by the library (see docs/aspic.md):
|
|
49
52
|
self.desc: str | None = None # a longer description
|
|
@@ -63,21 +66,24 @@ class _Claim(Node):
|
|
|
63
66
|
|
|
64
67
|
# --- defeasible and strict support (methods on the downstream conclusion) --
|
|
65
68
|
|
|
66
|
-
def support(self, src: "Node", lr: float) -> "Node":
|
|
69
|
+
def support(self, src: "Node", lr: "float | None" = None) -> "Node":
|
|
67
70
|
"""Add a defeasible argument *for* this conclusion (`lr > 1`). Returns
|
|
68
71
|
`src`, so an inline source can be built further upstream, as with core
|
|
69
|
-
`add_input`.
|
|
70
|
-
|
|
72
|
+
`add_input`. `lr` may be omitted (`None`) to declare the edge before its
|
|
73
|
+
strength is known — the topology compiles and renders (dashed), but the
|
|
74
|
+
network cannot be solved until every weight is assigned."""
|
|
75
|
+
if lr is not None and not lr > 1:
|
|
71
76
|
raise ValueError(
|
|
72
77
|
f"support lr must be > 1 (got {lr}); use rebut for an argument against"
|
|
73
78
|
)
|
|
74
79
|
self._require_new_edge(src)
|
|
75
|
-
self._edges.append((src, float(lr), "support"))
|
|
80
|
+
self._edges.append((src, None if lr is None else float(lr), "support"))
|
|
76
81
|
return src
|
|
77
82
|
|
|
78
|
-
def rebut(self, src: "Node", lr: float) -> "Node":
|
|
79
|
-
"""Add a defeasible argument *against* this conclusion (`0 < lr < 1`).
|
|
80
|
-
|
|
83
|
+
def rebut(self, src: "Node", lr: "float | None" = None) -> "Node":
|
|
84
|
+
"""Add a defeasible argument *against* this conclusion (`0 < lr < 1`). `lr`
|
|
85
|
+
may be omitted (`None`) to declare the edge before its strength is known."""
|
|
86
|
+
if lr is not None and not 0 < lr < 1:
|
|
81
87
|
raise ValueError(
|
|
82
88
|
f"rebut lr must be in (0, 1) (got {lr}); use support for an argument for"
|
|
83
89
|
)
|
|
@@ -89,7 +95,7 @@ class _Claim(Node):
|
|
|
89
95
|
stacklevel=2,
|
|
90
96
|
)
|
|
91
97
|
self._require_new_edge(src)
|
|
92
|
-
self._edges.append((src, float(lr), "rebut"))
|
|
98
|
+
self._edges.append((src, None if lr is None else float(lr), "rebut"))
|
|
93
99
|
return src
|
|
94
100
|
|
|
95
101
|
def strict(self, src: "Node") -> "Node":
|
|
@@ -101,18 +107,19 @@ class _Claim(Node):
|
|
|
101
107
|
|
|
102
108
|
# --- attacks (also methods on the downstream node) ------------------------
|
|
103
109
|
|
|
104
|
-
def undermine(self, by: "Node", lr: float) -> "Node":
|
|
110
|
+
def undermine(self, by: "Node", lr: "float | None" = None) -> "Node":
|
|
105
111
|
"""Attack this premise with `by` (`0 < lr < 1`). Mechanically a rebut into
|
|
106
112
|
the attacked node, named distinctly because it is the ASPIC-correct verb
|
|
107
|
-
for attacking a premise. An axiom cannot be undermined.
|
|
113
|
+
for attacking a premise. An axiom cannot be undermined. `lr` may be omitted
|
|
114
|
+
(`None`) to declare the edge before its strength is known."""
|
|
108
115
|
if self._no_undermine:
|
|
109
116
|
raise ValueError(
|
|
110
117
|
f"cannot undermine {self.name!r}: an axiom has no defeasible premise to attack"
|
|
111
118
|
)
|
|
112
|
-
if not 0 < lr < 1:
|
|
119
|
+
if lr is not None and not 0 < lr < 1:
|
|
113
120
|
raise ValueError(f"undermine lr must be in (0, 1) (got {lr})")
|
|
114
121
|
self._require_new_edge(by)
|
|
115
|
-
self._edges.append((by, float(lr), "undermine"))
|
|
122
|
+
self._edges.append((by, None if lr is None else float(lr), "undermine"))
|
|
116
123
|
return by
|
|
117
124
|
|
|
118
125
|
def undercut(self, source: "Node", by: "Node") -> "Node":
|
|
@@ -123,6 +130,39 @@ class _Claim(Node):
|
|
|
123
130
|
self._undercuts.append((source, by))
|
|
124
131
|
return by
|
|
125
132
|
|
|
133
|
+
def correlate(self, source_a: "Node", source_b: "Node",
|
|
134
|
+
coupling: "float | None" = None) -> "_Claim":
|
|
135
|
+
"""Declare residual correlation between two **defeasible sources** of this
|
|
136
|
+
conclusion — redundancy (two reports of one fact, a shared cause) or
|
|
137
|
+
synergy. Lowered to a pairwise `CorrelatedEvidenceCPD` coupling `J`:
|
|
138
|
+
negative discounts the pair (redundant / sub-additive), positive boosts it.
|
|
139
|
+
`J` is the engine-native quantity (a caller with a correlation or a pair of
|
|
140
|
+
conditional probabilities does the inverse map). `coupling` may be omitted
|
|
141
|
+
to scaffold the pair before its value is chosen. Returns self for chaining.
|
|
142
|
+
|
|
143
|
+
This overrides the core `Node.correlate`: a `_Claim` declares the pairing
|
|
144
|
+
against its argument sources here, and `compile` lowers it onto whichever
|
|
145
|
+
core node carries the defeasible inputs.
|
|
146
|
+
"""
|
|
147
|
+
sources = {id(s) for s, _lr, kind in self._edges if kind in ("support", "rebut")}
|
|
148
|
+
for x in (source_a, source_b):
|
|
149
|
+
if id(x) not in sources:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"{getattr(x, 'name', x)!r} is not a support/rebut source of "
|
|
152
|
+
f"{self.name!r}; correlate couples two of its defeasible sources"
|
|
153
|
+
)
|
|
154
|
+
if source_a is source_b:
|
|
155
|
+
raise ValueError("cannot correlate a source with itself")
|
|
156
|
+
if any({id(source_a), id(source_b)} == {id(a), id(b)}
|
|
157
|
+
for a, b, _J in self._correlations):
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"{source_a.name!r} and {source_b.name!r} are already correlated on "
|
|
160
|
+
f"{self.name!r}"
|
|
161
|
+
)
|
|
162
|
+
self._correlations.append(
|
|
163
|
+
(source_a, source_b, None if coupling is None else float(coupling)))
|
|
164
|
+
return self
|
|
165
|
+
|
|
126
166
|
def compile(self) -> "BayesianNetwork":
|
|
127
167
|
"""Lower this argument (as the target) to a core `BayesianNetwork`."""
|
|
128
168
|
from .compile import compile_argument
|
|
@@ -166,6 +166,7 @@ def _lower(c: "_Claim") -> None:
|
|
|
166
166
|
d = Node(f"{c.name}/defeasible", prior=c.prior)
|
|
167
167
|
for src, lr in defeasible:
|
|
168
168
|
d.add_input(src, lr=lr)
|
|
169
|
+
_apply_correlations(c, d, resolve)
|
|
169
170
|
c.noisy_or(leak=0.0)
|
|
170
171
|
for src in strict:
|
|
171
172
|
c.add_input(src, activation=1.0)
|
|
@@ -173,3 +174,14 @@ def _lower(c: "_Claim") -> None:
|
|
|
173
174
|
else:
|
|
174
175
|
for src, lr in defeasible:
|
|
175
176
|
c.add_input(src, lr=lr)
|
|
177
|
+
_apply_correlations(c, c, resolve)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply_correlations(c: "_Claim", host: "Node", resolve) -> None:
|
|
181
|
+
"""Lower `c`'s declared source correlations onto `host` — the core node that
|
|
182
|
+
actually carries the defeasible inputs (the conclusion itself, or its hidden
|
|
183
|
+
`/defeasible` node when strict edges divorced it). Uses `Node.correlate`
|
|
184
|
+
explicitly: `c` overrides `correlate` with the argument-level declaration, so
|
|
185
|
+
the core coupling must be reached through the base class."""
|
|
186
|
+
for a, b, coupling in getattr(c, "_correlations", []):
|
|
187
|
+
Node.correlate(host, resolve(a), resolve(b), coupling=coupling)
|
|
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
|
|
|
30
30
|
from ..core import BayesianNetwork, Node
|
|
31
31
|
from .argument import _Claim
|
|
32
32
|
|
|
33
|
-
SCHEMA_VERSION = "0.
|
|
33
|
+
SCHEMA_VERSION = "0.2" # 0.2 adds the optional `correlations` array
|
|
34
34
|
|
|
35
35
|
# Above this many nodes the brute-force ExactSolver fallback (2**n joint
|
|
36
36
|
# enumeration, used only on non-polytree arguments) gets painfully slow. See the
|
|
@@ -154,7 +154,10 @@ class Argument:
|
|
|
154
154
|
from .argument import Axiom, Premise, _Claim
|
|
155
155
|
|
|
156
156
|
bn = self.bn
|
|
157
|
-
|
|
157
|
+
# a pending argument (some edge weight unassigned) cannot be solved, so the
|
|
158
|
+
# posterior fields serialize as null; the topology round-trips regardless.
|
|
159
|
+
complete = bn.is_complete
|
|
160
|
+
solver = self.solver if complete else None
|
|
158
161
|
claims = [c for c in _reachable(self.target) if isinstance(c, _Claim)]
|
|
159
162
|
non_claim = [c for c in _reachable(self.target) if not isinstance(c, _Claim)]
|
|
160
163
|
if non_claim:
|
|
@@ -178,16 +181,16 @@ class Argument:
|
|
|
178
181
|
return name[:-3] if name.endswith("CPD") else name
|
|
179
182
|
|
|
180
183
|
def node_dict(c: "_Claim") -> dict:
|
|
181
|
-
lo, hi = self.posterior_range(c)
|
|
184
|
+
lo, hi = self.posterior_range(c) if complete else (None, None)
|
|
182
185
|
return {
|
|
183
186
|
"id": ids[c],
|
|
184
187
|
"type": type_of(c),
|
|
185
188
|
"label": c.name,
|
|
186
189
|
"desc": c.desc,
|
|
187
190
|
"prior": float(c.prior),
|
|
188
|
-
"posterior": float(solver.prob(c, 1)),
|
|
189
|
-
"min_posterior": float(lo),
|
|
190
|
-
"max_posterior": float(hi),
|
|
191
|
+
"posterior": float(solver.prob(c, 1)) if solver else None,
|
|
192
|
+
"min_posterior": None if lo is None else float(lo),
|
|
193
|
+
"max_posterior": None if hi is None else float(hi),
|
|
191
194
|
"input_groups_sizes": self.input_groups_sizes(c),
|
|
192
195
|
"cpd_type": cpd_type(c),
|
|
193
196
|
"revealable_to_judge": c.revealable_to_judge,
|
|
@@ -220,8 +223,19 @@ class Argument:
|
|
|
220
223
|
for src in c._strict:
|
|
221
224
|
edges.append(edge(src, "strict", None))
|
|
222
225
|
|
|
226
|
+
# residual correlations between two defeasible sources of a conclusion
|
|
227
|
+
# (lowered to CorrelatedEvidenceCPD couplings); not source->target edges.
|
|
228
|
+
correlations = []
|
|
229
|
+
for c in claims:
|
|
230
|
+
for a, b, coupling in getattr(c, "_correlations", []):
|
|
231
|
+
correlations.append({
|
|
232
|
+
"target": ids[c],
|
|
233
|
+
"sources": [ids[a], ids[b]],
|
|
234
|
+
"coupling": None if coupling is None else float(coupling),
|
|
235
|
+
})
|
|
236
|
+
|
|
223
237
|
return {"version": SCHEMA_VERSION, "target": ids[self.target],
|
|
224
|
-
"nodes": nodes, "edges": edges}
|
|
238
|
+
"nodes": nodes, "edges": edges, "correlations": correlations}
|
|
225
239
|
|
|
226
240
|
def to_json(self, *, indent: int = 2) -> str:
|
|
227
241
|
return json.dumps(self.to_dict(), indent=indent)
|
|
@@ -267,15 +281,26 @@ class Argument:
|
|
|
267
281
|
edge_meta[ed["id"]] = {"label": ed.get("label"),
|
|
268
282
|
"revealable_to_judge": ed.get("revealable_to_judge")}
|
|
269
283
|
|
|
284
|
+
for cor in data.get("correlations", []):
|
|
285
|
+
a, b = cor["sources"]
|
|
286
|
+
by_id[cor["target"]].correlate(
|
|
287
|
+
by_id[a], by_id[b], coupling=cor.get("coupling"))
|
|
288
|
+
|
|
270
289
|
arg = cls(by_id[data["target"]])
|
|
271
290
|
arg._edge_meta = edge_meta
|
|
272
291
|
return arg
|
|
273
292
|
|
|
274
293
|
@classmethod
|
|
275
|
-
def from_json(cls, s: str) -> "Argument":
|
|
276
|
-
|
|
294
|
+
def from_json(cls, s: str, *, source: str = "<string>") -> "Argument":
|
|
295
|
+
if not s.strip():
|
|
296
|
+
raise ValueError(f"cannot load an Argument from empty JSON ({source})")
|
|
297
|
+
try:
|
|
298
|
+
data = json.loads(s)
|
|
299
|
+
except json.JSONDecodeError as exc:
|
|
300
|
+
raise ValueError(f"{source} is not valid JSON: {exc}") from exc
|
|
301
|
+
return cls.from_dict(data)
|
|
277
302
|
|
|
278
303
|
@classmethod
|
|
279
304
|
def load(cls, path: str) -> "Argument":
|
|
280
305
|
with open(path) as f:
|
|
281
|
-
return cls.from_json(f.read())
|
|
306
|
+
return cls.from_json(f.read(), source=path)
|
|
@@ -10,7 +10,12 @@ from __future__ import annotations
|
|
|
10
10
|
from typing import Mapping, Optional
|
|
11
11
|
|
|
12
12
|
from ..visualization import draw_layered, marginals
|
|
13
|
-
from ..visualization.image import
|
|
13
|
+
from ..visualization.image import (
|
|
14
|
+
_NEUTRAL_EDGE,
|
|
15
|
+
_lr_colormap,
|
|
16
|
+
_require_matplotlib,
|
|
17
|
+
lr_colorbar_spec,
|
|
18
|
+
)
|
|
14
19
|
from ..visualization.style import fmt
|
|
15
20
|
from .argument import Axiom, Premise, _Claim
|
|
16
21
|
from .compile import _reachable
|
|
@@ -21,6 +26,7 @@ _AXIOM = "#ffe6a8"
|
|
|
21
26
|
_CONCLUSION = "#ffffff"
|
|
22
27
|
_STRICT = {"color": "#222222", "style": "-", "width": 2.6} # bold black (forcing)
|
|
23
28
|
_UNDERCUT = {"color": "#7d3c98", "style": ":", "width": 1.6} # purple, attacks an edge
|
|
29
|
+
_CORRELATION = "#2a9d8f" # teal, undirected dashed: residual correlation between sources
|
|
24
30
|
_CONFIRMING = "#3b4cc0" # coolwarm_r extremes, for the legend swatches
|
|
25
31
|
_DISCONFIRMING = "#b40426"
|
|
26
32
|
|
|
@@ -64,8 +70,12 @@ def render_argument(
|
|
|
64
70
|
# support/rebut/undermine coloured by LR; strict bold black. Emit each edge
|
|
65
71
|
# then its undercutters, so the layout puts an undercutter beside the source
|
|
66
72
|
# it attacks (v=c only sets the layout; the arrow lands on the source->c edge).
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
# a defeasible edge whose lr is not yet assigned draws dashed and grey with
|
|
74
|
+
# no label ("edge exists, strength TBD"); an assigned one is coloured by lr.
|
|
75
|
+
rows = [(src, {"u": src, "v": c, "label": "" if lr is None else f"lr={fmt(lr)}",
|
|
76
|
+
"color": _NEUTRAL_EDGE if lr is None else color_for_lr(lr),
|
|
77
|
+
"style": "--" if lr is None else "-",
|
|
78
|
+
"width": 1.4 if lr is None else 2.0})
|
|
69
79
|
for src, lr, _kind in c._edges]
|
|
70
80
|
rows += [(s, {"u": s, "v": c, "label": "strict", **_STRICT}) for s in c._strict]
|
|
71
81
|
|
|
@@ -82,6 +92,12 @@ def render_argument(
|
|
|
82
92
|
for by in bys:
|
|
83
93
|
edges.append({"u": by, "v": c, "attacks": (source, c), **_UNDERCUT})
|
|
84
94
|
|
|
95
|
+
# residual correlation between two sources: an undirected dashed coupling.
|
|
96
|
+
for a, b, coupling in getattr(c, "_correlations", []):
|
|
97
|
+
edges.append({"u": a, "v": b, "arrow": False, "style": "--",
|
|
98
|
+
"color": _CORRELATION, "width": 1.3,
|
|
99
|
+
"label": "" if coupling is None else f"J={fmt(coupling)}"})
|
|
100
|
+
|
|
85
101
|
if values is None and beliefs:
|
|
86
102
|
m = marginals(target.compile(), evidence)
|
|
87
103
|
values = {c: m[c] for c in claims if c in m}
|
|
@@ -106,6 +122,7 @@ def render_argument(
|
|
|
106
122
|
color_of=color_of, label_of=label_of, font=_ARG_FONT,
|
|
107
123
|
ax=ax, title=title, path=path, emphasize=set(evidence or {}),
|
|
108
124
|
positions=positions, orientation=orientation,
|
|
125
|
+
center=(0.0, 0.0) if layout == "radial" else None,
|
|
109
126
|
colorbar=lr_colorbar_spec(all_lrs) if colorbar else None,
|
|
110
127
|
legend=_argument_legend(claims) if legend else None,
|
|
111
128
|
)
|
|
@@ -122,13 +139,17 @@ def _argument_legend(claims) -> list:
|
|
|
122
139
|
node_row.append(("conclusion", {"marker": "o", "fill": _CONCLUSION}))
|
|
123
140
|
|
|
124
141
|
edge_row = []
|
|
125
|
-
lrs = [lr for c in claims for _src, lr, _kind in c._edges]
|
|
142
|
+
lrs = [lr for c in claims for _src, lr, _kind in c._edges if lr is not None]
|
|
126
143
|
if any(lr > 1 for lr in lrs):
|
|
127
144
|
edge_row.append(("confirming", {"color": _CONFIRMING, "linestyle": "-"}))
|
|
128
145
|
if any(lr < 1 for lr in lrs):
|
|
129
146
|
edge_row.append(("disconfirming", {"color": _DISCONFIRMING, "linestyle": "-"}))
|
|
147
|
+
if any(lr is None for c in claims for _src, lr, _kind in c._edges):
|
|
148
|
+
edge_row.append(("unassigned", {"color": _NEUTRAL_EDGE, "linestyle": "--"}))
|
|
130
149
|
if any(c._strict for c in claims):
|
|
131
150
|
edge_row.append(("strict", {"color": _STRICT["color"], "linestyle": "-"}))
|
|
132
151
|
if any(c._undercuts for c in claims):
|
|
133
152
|
edge_row.append(("undercut", {"color": _UNDERCUT["color"], "linestyle": ":"}))
|
|
153
|
+
if any(getattr(c, "_correlations", None) for c in claims):
|
|
154
|
+
edge_row.append(("correlation", {"color": _CORRELATION, "linestyle": "--"}))
|
|
134
155
|
return [r for r in (node_row, edge_row) if r]
|
|
@@ -6,9 +6,11 @@ and query with the `ExactSolver` (later: loopy BP).
|
|
|
6
6
|
from .bp import LoopySolver
|
|
7
7
|
from .cpd import (
|
|
8
8
|
CPD,
|
|
9
|
+
CorrelatedEvidenceCPD,
|
|
9
10
|
IndependentEvidenceCPD,
|
|
10
11
|
NoisyAndCPD,
|
|
11
12
|
NoisyOrCPD,
|
|
13
|
+
PendingWeightError,
|
|
12
14
|
TabularCPD,
|
|
13
15
|
)
|
|
14
16
|
from .exact import ExactSolver
|
|
@@ -24,6 +26,8 @@ __all__ = [
|
|
|
24
26
|
"CPD",
|
|
25
27
|
"TabularCPD",
|
|
26
28
|
"IndependentEvidenceCPD",
|
|
29
|
+
"CorrelatedEvidenceCPD",
|
|
27
30
|
"NoisyOrCPD",
|
|
28
31
|
"NoisyAndCPD",
|
|
32
|
+
"PendingWeightError",
|
|
29
33
|
]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from .base import CPD
|
|
1
|
+
from .base import CPD, PendingWeightError
|
|
2
|
+
from .correlated_evidence import CorrelatedEvidenceCPD
|
|
2
3
|
from .independent_evidence import IndependentEvidenceCPD
|
|
3
4
|
from .noisy_and import NoisyAndCPD
|
|
4
5
|
from .noisy_or import NoisyOrCPD
|
|
@@ -6,7 +7,9 @@ from .tabular import TabularCPD
|
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"CPD",
|
|
10
|
+
"PendingWeightError",
|
|
9
11
|
"IndependentEvidenceCPD",
|
|
12
|
+
"CorrelatedEvidenceCPD",
|
|
10
13
|
"NoisyAndCPD",
|
|
11
14
|
"NoisyOrCPD",
|
|
12
15
|
"TabularCPD",
|
|
@@ -22,6 +22,14 @@ if TYPE_CHECKING:
|
|
|
22
22
|
from .tabular import TabularCPD
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
class PendingWeightError(ValueError):
|
|
26
|
+
"""An edge exists but its weight (an LR, or a noisy activation) is not yet
|
|
27
|
+
assigned, so the CPD cannot be made into a probability table. Subclasses
|
|
28
|
+
`ValueError` so existing handlers still catch it; raised distinctly so
|
|
29
|
+
`BayesianNetwork.from_nodes` can tell a *pending* network (one still being
|
|
30
|
+
authored — topology drawn, weights TBD) from a genuinely malformed one."""
|
|
31
|
+
|
|
32
|
+
|
|
25
33
|
class CPD(ABC):
|
|
26
34
|
node: "Node" # the node this CPD is for
|
|
27
35
|
|