probability-flow 1.4.3__tar.gz → 1.4.4__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.
Files changed (28) hide show
  1. {probability_flow-1.4.3 → probability_flow-1.4.4}/CHANGELOG.md +28 -2
  2. {probability_flow-1.4.3 → probability_flow-1.4.4}/PKG-INFO +1 -1
  3. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/__init__.py +1 -1
  4. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/graph.py +9 -1
  5. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/independent.py +65 -6
  6. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/serialize.py +1 -1
  7. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/variable.py +30 -0
  8. {probability_flow-1.4.3 → probability_flow-1.4.4}/pyproject.toml +1 -1
  9. {probability_flow-1.4.3 → probability_flow-1.4.4}/.gitignore +0 -0
  10. {probability_flow-1.4.3 → probability_flow-1.4.4}/LICENSE +0 -0
  11. {probability_flow-1.4.3 → probability_flow-1.4.4}/README.md +0 -0
  12. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/cpd.py +0 -0
  13. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/decompose.py +0 -0
  14. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/edges.py +0 -0
  15. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/eliminate.py +0 -0
  16. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/engines.py +0 -0
  17. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/evidence.py +0 -0
  18. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/factors.py +0 -0
  19. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/generation.py +0 -0
  20. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/inference.py +0 -0
  21. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/junction.py +0 -0
  22. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/logical.py +0 -0
  23. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/noisy.py +0 -0
  24. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/ordering.py +0 -0
  25. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/py.typed +0 -0
  26. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/query.py +0 -0
  27. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/reveal.py +0 -0
  28. {probability_flow-1.4.3 → probability_flow-1.4.4}/probability_flow/viz.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.4
4
+
5
+ ### Added
6
+
7
+ - **`variable.set_baseline(probability)`**, for changing P(a node) with every
8
+ parent absent after the CPD is written.
9
+
10
+ It takes a PROBABILITY, which is the unit `set_prior` takes and the unit the
11
+ file format stores. There is no relative form and no `bits=` flag: a
12
+ `shift_baseline(0.2)` beside a `set_baseline(0.2)` is two calls that read the
13
+ same and mean different things, since `0.2` is a plausible probability and a
14
+ plausible number of bits.
15
+
16
+ ### Fixed
17
+
18
+ - **A removed id could be handed out again.** Removing a variable from the
19
+ middle of a graph left a hole that the next auto-assigned id stepped past,
20
+ but removing the LAST variable simply freed its id, and the next variable
21
+ took it back. Anything still referring to the removed node then pointed at a
22
+ different proposition, silently. Ids are retired on removal now.
23
+
24
+ - **Three more silent no-ops in the additive family.** `cpd.baseline`,
25
+ `cpd.gates` and `Gate.weight_when_gated` all accepted an assignment, changed
26
+ nothing and reported nothing, because the value every query reads is a
27
+ derived copy. `baseline` was the worst: it read back as the number you had
28
+ assigned, so the object agreed with you while going on answering the old
29
+ question. All three raise now.
30
+
3
31
  ## 1.4.3
4
32
 
5
33
  ### Added
@@ -78,8 +106,6 @@
78
106
  the row where both hold, where an `and` over the two would be `1.0`. Tagging
79
107
  that `logical` would misdescribe the numbers.
80
108
 
81
- ## Unreleased
82
-
83
109
  ### Fixed
84
110
 
85
111
  - **An evidence node's `name` is free text.** The reader used to refuse any name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: probability-flow
3
- Version: 1.4.3
3
+ Version: 1.4.4
4
4
  Summary: Discrete causal graphs and exact inference.
5
5
  Author-email: Alex Roman <dr.alexroman@gmail.com>
6
6
  License-Expression: MIT
@@ -38,7 +38,7 @@ from .serialize import FORMAT, VERSION, from_json, to_json
38
38
  from .variable import BINARY, Definition, Variable
39
39
  from .viz import render, to_text
40
40
 
41
- __version__ = "1.4.3"
41
+ __version__ = "1.4.4"
42
42
 
43
43
  __all__ = [
44
44
  "__version__",
@@ -39,6 +39,13 @@ class CausalGraph:
39
39
  self._by_name: "dict[str, Variable]" = {}
40
40
  self._position: "dict[int, int]" = {}
41
41
  self._by_id: "dict[str, Variable]" = {}
42
+ #: EVERY ID THIS GRAPH HAS EVER ISSUED, including ones whose variable
43
+ #: has since been removed. `_by_id` is not enough on its own: removing
44
+ #: the LAST variable freed its id, and the next auto-assigned one took
45
+ #: it back, so a decoration or an external reference keyed to the old
46
+ #: `v2` would silently attach to a different proposition. Ids are the
47
+ #: schema every external reference uses, so a removed one is retired.
48
+ self._issued: "set[str]" = set()
42
49
  #: Free-form provenance emitted at the top of a document: library
43
50
  #: version, generator identity, and a seed where one exists. The seed is
44
51
  #: what makes "regenerate the affected twelve" possible instead of
@@ -108,7 +115,7 @@ class CausalGraph:
108
115
  # variable used to hand the reader an auto id that was already
109
116
  # taken, so the graph could not round-trip at all.
110
117
  n = len(self._variables)
111
- while f"v{n}" in self._by_id:
118
+ while f"v{n}" in self._by_id or f"v{n}" in self._issued:
112
119
  n += 1
113
120
  variable.id = f"v{n}"
114
121
  if variable.id in self._by_id:
@@ -125,6 +132,7 @@ class CausalGraph:
125
132
  self._variables.append(variable)
126
133
  self._by_name[variable.name] = variable
127
134
  self._by_id[variable.id] = variable
135
+ self._issued.add(variable.id)
128
136
  self._position[id(variable)] = len(self._variables) - 1
129
137
  self._parent_list[id(variable)] = []
130
138
  self._child_list[id(variable)] = []
@@ -101,7 +101,7 @@ class Gate:
101
101
  whose polarity is ambiguous.
102
102
  """
103
103
 
104
- __slots__ = ("source", "modulates", "weight_when_gated")
104
+ __slots__ = ("source", "modulates", "_weight_when_gated")
105
105
 
106
106
  def __init__(self, source, modulates,
107
107
  weight_when_gated: float = 0.0):
@@ -116,7 +116,18 @@ class Gate:
116
116
  )
117
117
  self.source = source
118
118
  self.modulates = modulates
119
- self.weight_when_gated = float(weight_when_gated)
119
+ self._weight_when_gated = float(weight_when_gated)
120
+
121
+ @property
122
+ def weight_when_gated(self) -> float:
123
+ """What the modulated edge carries while this gate holds, in bits. READ-ONLY.
124
+
125
+ The CPD converts it to nats once, at construction, so assigning here
126
+ changed nothing and reported nothing. A gate's weight is part of what
127
+ the gate IS, so changing it means declaring the gate again and setting
128
+ the CPD, which is the same amount of typing and cannot half-apply.
129
+ """
130
+ return self._weight_when_gated
120
131
 
121
132
  def __repr__(self) -> str:
122
133
  kind = ("hard" if self.weight_when_gated == 0.0
@@ -224,15 +235,15 @@ class IndependentEvidence(CPD):
224
235
 
225
236
  self.child = None # bound by set_cpd
226
237
  self.parents = tuple(parents)
227
- self.baseline = float(baseline)
228
- self.gates = tuple(gates)
238
+ self._baseline = float(baseline)
239
+ self._gates = tuple(gates)
229
240
  #: AUTHORED BITS, stored verbatim. Not re-derived from the nats below,
230
241
  #: because bits -> nats -> bits does not return the number that was
231
242
  #: written: speeding-camera carries 1.5154639175257731 and a document
232
243
  #: must hold what its author wrote.
233
244
  self._weights = {id(p): float(ratios.get(p, 0.0)) for p in parents}
234
245
 
235
- self._log_baseline = math.log(self.baseline / (1.0 - self.baseline))
246
+ self._log_baseline = math.log(self._baseline / (1.0 - self._baseline))
236
247
  #: nats, derived once. Everything below this line is nats, because
237
248
  #: math.log is, and converting at every comparison is how a 1.4427
238
249
  #: factor goes missing.
@@ -254,6 +265,54 @@ class IndependentEvidence(CPD):
254
265
  from types import MappingProxyType
255
266
  return MappingProxyType(self._weights)
256
267
 
268
+ @property
269
+ def gates(self):
270
+ """The gates on this CPD. READ-ONLY.
271
+
272
+ `_gated` is built from these once, at construction, so replacing the
273
+ tuple changed nothing. Gates are declared when the CPD is written.
274
+ """
275
+ return self._gates
276
+
277
+ @property
278
+ def baseline(self) -> float:
279
+ """P(child) with every parent absent, as a PROBABILITY. Read-only.
280
+
281
+ `_log_baseline` holds the same number in nats and is what every query
282
+ reads, so `cpd.baseline = 0.2` used to change nothing, report nothing,
283
+ and then read back as 0.2: the object lied about itself. Use
284
+ `set_baseline`.
285
+ """
286
+ return self._baseline
287
+
288
+ def set_baseline(self, probability: float) -> None:
289
+ """Set the baseline, as a PROBABILITY.
290
+
291
+ ABSOLUTE, SO IT IS A PROBABILITY, which is the same unit `set_prior`
292
+ takes and the same unit the file format stores. The baseline is a claim
293
+ about the world -- what the child is worth when none of its parents
294
+ hold -- so it is the kind of thing you can write a sentence about, and
295
+ sentences about it are in probabilities.
296
+
297
+ THERE IS NO RELATIVE FORM, ON PURPOSE. A `shift_baseline(0.2)` beside
298
+ a `set_baseline(0.2)` is two calls that read the same and mean
299
+ different things, because 0.2 is a plausible probability AND a
300
+ plausible number of bits. That is the ambiguity a `bits=True` flag
301
+ would introduce, spread across two names instead of one keyword.
302
+ """
303
+ if not (isinstance(probability, (int, float))
304
+ and not isinstance(probability, bool)
305
+ and 0.0 < probability < 1.0):
306
+ raise ValueError(
307
+ f"a baseline is a probability strictly between 0 and 1 (got "
308
+ f"{probability!r}). It is P(child) with every parent absent, so "
309
+ "0 and 1 are both excluded: a baseline of 0 is a child no "
310
+ "evidence can ever raise, and the log-odds of either is "
311
+ "infinite."
312
+ )
313
+ self._baseline = float(probability)
314
+ self._log_baseline = math.log(self._baseline / (1.0 - self._baseline))
315
+
257
316
  def set_weight(self, parent, bits: float) -> None:
258
317
  """Set what one parent's edge carries, in BITS, keeping the derived form.
259
318
 
@@ -517,7 +576,7 @@ class IndependentEvidence(CPD):
517
576
  kept = {id(p) for p in parents}
518
577
  out._weights = {id(p): self.weights.get(id(p), 0.0) for p in parents}
519
578
  out._log_w = {k: v * _LN2 for k, v in out.weights.items()}
520
- out.gates = tuple(g for g in self.gates
579
+ out._gates = tuple(g for g in self.gates
521
580
  if id(g.source) in kept and id(g.modulates) in kept)
522
581
  out._gated = {id(g.modulates): (g.source, g.weight_when_gated * _LN2)
523
582
  for g in out.gates}
@@ -28,7 +28,7 @@ import json
28
28
  from .variable import short
29
29
 
30
30
  FORMAT = "probability-flow/causal-graph"
31
- VERSION = "1.4.3"
31
+ VERSION = "1.4.4"
32
32
 
33
33
  #: Every key a document may carry, at each level. An unknown key is REJECTED.
34
34
  #:
@@ -599,6 +599,36 @@ class Variable:
599
599
  """P(this | its causes). See `CausalGraph.set_cpd` for what `spec` may be."""
600
600
  self.graph._set_cpd(self, spec)
601
601
 
602
+ def _additive_cpd(self, doing: str):
603
+ """This node's CPD, if it is the family that has a baseline."""
604
+ try:
605
+ cpd = self.cpd
606
+ except KeyError:
607
+ # `graph._cpd` raises rather than returning None, so this is the
608
+ # path a caller takes before writing any numbers at all.
609
+ raise ValueError(
610
+ f"{short(self.name)!r} has no CPD yet, so there is no baseline "
611
+ "to set. Give it one first, with set_cpd() or set_prior()."
612
+ ) from None
613
+ if not hasattr(cpd, "set_baseline"):
614
+ leak = " Its `leak` is the same idea for this family." \
615
+ if hasattr(cpd, "leak") else ""
616
+ raise ValueError(
617
+ f"{short(self.name)!r} has a {type(cpd).__name__}, which has no "
618
+ f"baseline to {doing}. A baseline is P(child) with every parent "
619
+ "absent, which only the additive family states as a single "
620
+ f"number.{leak}"
621
+ )
622
+ return cpd
623
+
624
+ def set_baseline(self, probability: float) -> None:
625
+ """Set P(this) with every parent absent, as a PROBABILITY.
626
+
627
+ The same unit `set_prior` takes, because it is the same kind of claim:
628
+ what this node is worth before any of its causes do anything.
629
+ """
630
+ self._additive_cpd("set").set_baseline(probability)
631
+
602
632
  @property
603
633
  def parents(self) -> "tuple[Variable, ...]":
604
634
  return self.graph._parents(self)
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "probability-flow"
11
- version = "1.4.3"
11
+ version = "1.4.4"
12
12
  description = "Discrete causal graphs and exact inference."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"