pylea 0.1.0__py3-none-any.whl
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.
- pylea/__init__.py +376 -0
- pylea/compile/__init__.py +1 -0
- pylea/compile/_order_worker.py +31 -0
- pylea/compile/cost.py +209 -0
- pylea/compile/lower.py +191 -0
- pylea/compile/network.py +139 -0
- pylea/compile/order.py +405 -0
- pylea/compile/plan.py +158 -0
- pylea/compile/planner.py +169 -0
- pylea/draw/__init__.py +172 -0
- pylea/draw/coordinates.py +192 -0
- pylea/draw/dependency.py +169 -0
- pylea/draw/glyph.py +345 -0
- pylea/draw/layout.py +526 -0
- pylea/draw/sketch.py +303 -0
- pylea/draw/svg.py +136 -0
- pylea/draw/tikz.py +124 -0
- pylea/eval/__init__.py +1 -0
- pylea/eval/backend.py +404 -0
- pylea/eval/interp.py +235 -0
- pylea/eval/kernels.py +194 -0
- pylea/examples/__init__.py +13 -0
- pylea/examples/bioassay.py +129 -0
- pylea/examples/birthday.py +35 -0
- pylea/examples/disease.py +46 -0
- pylea/examples/electoral_college.py +92 -0
- pylea/examples/german_tank.py +133 -0
- pylea/examples/holdem.py +265 -0
- pylea/examples/monty_hall.py +46 -0
- pylea/examples/percolation.py +101 -0
- pylea/examples/secret_santa.py +51 -0
- pylea/examples/simple.py +288 -0
- pylea/examples/sprinkler.py +92 -0
- pylea/ir/__init__.py +33 -0
- pylea/ir/bits.py +23 -0
- pylea/ir/body.py +207 -0
- pylea/ir/dependency.py +225 -0
- pylea/ir/inline.py +140 -0
- pylea/ir/prim.py +296 -0
- pylea/ir/program.py +329 -0
- pylea/ir/reference.py +84 -0
- pylea/sample/__init__.py +58 -0
- pylea/sample/_exact_worker.py +79 -0
- pylea/sample/certify.py +479 -0
- pylea/sample/collapse.py +145 -0
- pylea/sample/cost_model.py +201 -0
- pylea/sample/estimate.py +103 -0
- pylea/sample/packed.py +192 -0
- pylea/sample/pilot.py +242 -0
- pylea/sample/portfolio.py +421 -0
- pylea/sample/query.py +256 -0
- pylea/sample/run.py +479 -0
- pylea/sample/schedule.py +261 -0
- pylea/sample/sweep.py +622 -0
- pylea/semantics/__init__.py +1 -0
- pylea/semantics/likelihood_table.py +278 -0
- pylea/semantics/matrices.py +133 -0
- pylea/simplify/__init__.py +44 -0
- pylea/simplify/absorb.py +93 -0
- pylea/simplify/boolean.py +220 -0
- pylea/simplify/common.py +580 -0
- pylea/simplify/cse.py +96 -0
- pylea/simplify/dce.py +97 -0
- pylea/simplify/driver.py +152 -0
- pylea/simplify/fold.py +622 -0
- pylea/simplify/implication.py +238 -0
- pylea/simplify/inline.py +48 -0
- pylea/simplify/lift.py +315 -0
- pylea/simplify/mask.py +176 -0
- pylea/simplify/observe.py +173 -0
- pylea/syntax/__init__.py +1 -0
- pylea/syntax/choice.py +99 -0
- pylea/syntax/comparison.py +72 -0
- pylea/syntax/compiled.py +251 -0
- pylea/syntax/dpp.py +1366 -0
- pylea/syntax/encoding.py +108 -0
- pylea/syntax/enum.py +576 -0
- pylea/syntax/estimate.py +86 -0
- pylea/syntax/expectation.py +159 -0
- pylea/syntax/flip.py +57 -0
- pylea/syntax/integer.py +224 -0
- pylea/syntax/method.py +161 -0
- pylea/syntax/observe.py +73 -0
- pylea/syntax/pmf.py +85 -0
- pylea/syntax/prob.py +82 -0
- pylea/syntax/tuple.py +177 -0
- pylea/syntax/variable.py +244 -0
- pylea/utility/__init__.py +1 -0
- pylea/utility/bif_to_dpp.py +646 -0
- pylea/utility/dft_to_dpp.py +363 -0
- pylea-0.1.0.dist-info/METADATA +99 -0
- pylea-0.1.0.dist-info/RECORD +95 -0
- pylea-0.1.0.dist-info/WHEEL +5 -0
- pylea-0.1.0.dist-info/licenses/LICENSE +21 -0
- pylea-0.1.0.dist-info/top_level.txt +1 -0
pylea/__init__.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""# *Pylea:* discrete probabilistic programming in Python.
|
|
2
|
+
|
|
3
|
+
Pylea is a library for computational discrete probability theory.
|
|
4
|
+
Given an arbitrary probabilistic model in which every variable can
|
|
5
|
+
take at most finitely many values, Pylea can compute the probability
|
|
6
|
+
of events, expected values, probability mass functions and any other quantity
|
|
7
|
+
of interest.
|
|
8
|
+
Such a probabilistic model is written as a *discrete probabilistic program (DPP)*:
|
|
9
|
+
a Python function decorated with `@DPP`, using a restricted syntax in which
|
|
10
|
+
coin flips are drawn with `flip` and events are conditioned on using `observe`.
|
|
11
|
+
|
|
12
|
+
*Example.* A disease occurs in a fraction ``10**-4`` of the population. A test for it is positive for
|
|
13
|
+
99% of the patients who have the disease and for 2% of those who do not. The DPP
|
|
14
|
+
`has_disease` gives the probability that a patient has the disease, given one positive and
|
|
15
|
+
one negative test result:
|
|
16
|
+
|
|
17
|
+
>>> from pylea import DPP, Bool, flip, observe, prob
|
|
18
|
+
>>> @DPP
|
|
19
|
+
... def test(has_it: Bool) -> Bool:
|
|
20
|
+
... positive_given_disease = flip(0.99) # true positive rate
|
|
21
|
+
... positive_given_no_disease = flip(0.02) # false positive rate
|
|
22
|
+
... true_positive = has_it & positive_given_disease
|
|
23
|
+
... false_positive = ~has_it & positive_given_no_disease
|
|
24
|
+
... return true_positive | false_positive
|
|
25
|
+
>>> @DPP
|
|
26
|
+
... def has_disease() -> Bool:
|
|
27
|
+
... x = flip(1e-4)
|
|
28
|
+
... t1 = test(x)
|
|
29
|
+
... t2 = test(x)
|
|
30
|
+
... y = t1 & ~t2
|
|
31
|
+
... observe(y)
|
|
32
|
+
... return x
|
|
33
|
+
>>> round(prob(has_disease), 8)
|
|
34
|
+
5.051e-05
|
|
35
|
+
|
|
36
|
+
A DPP calls other DPPs as ordinary functions, as `has_disease` calls `test` twice. The same
|
|
37
|
+
DPP is `pylea.examples.disease`. Pylea's DSL includes many more features, most of them
|
|
38
|
+
demonstrated in the examples and guide below.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Examples
|
|
42
|
+
|
|
43
|
+
Each example links to a notebook, shown with its output and available for download, and
|
|
44
|
+
illustrates a class of models for which Pylea can be used.
|
|
45
|
+
|
|
46
|
+
- **Fault trees**: [This notebook](examples/fault_trees.html) shows how to build
|
|
47
|
+
*fault trees* in Pylea. These are used in reliability engineering to
|
|
48
|
+
estimate the failure probability of safety-critical systems.
|
|
49
|
+
- **Bayesian networks**: [Bayesian networks](examples/bayesian_networks.html) can be understood
|
|
50
|
+
as special examples of DPPs. Pylea enables flexible queries on Bayesian networks, easy
|
|
51
|
+
integration with other classes of discrete probabilistic models, and fast inference.
|
|
52
|
+
- **Discrete Bayesian statistics**: [This example](examples/bayesian_statistics.html)
|
|
53
|
+
goes through examples of using Pylea for Bayesian statistics, including classic textbook problems
|
|
54
|
+
based on historical real-world applications.
|
|
55
|
+
- **Games of chance**: [This notebook](examples/games_of_chance.html) illustrates that Pylea
|
|
56
|
+
can be used to compute winning probabilities in games of chance using the classic
|
|
57
|
+
*Monty Hall problem* and *Texas hold'em poker* as examples.
|
|
58
|
+
|
|
59
|
+
`pylea.utility` converts Bayesian networks in the `.bif` format and fault trees in the
|
|
60
|
+
`.dft` format into DPPs. More example DPPs are in `pylea.examples`.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Writing a DPP
|
|
64
|
+
|
|
65
|
+
Python runs the body of a `@DPP` function once, when the function is defined, and records
|
|
66
|
+
each step. Probabilities are computed only when the DPP is queried. Three rules follow.
|
|
67
|
+
|
|
68
|
+
**Every variable has a type.** The type is a subclass of `Variable`: `Bool`, or a type
|
|
69
|
+
declared by subclassing `Enum`, `UInt` or `Tuple`, as shown below. It fixes the finite set
|
|
70
|
+
of values the variable can take. Every parameter of a DPP is annotated with its type, and
|
|
71
|
+
the DPP returns one variable or a tuple of variables.
|
|
72
|
+
|
|
73
|
+
**A random variable has no value while the body runs.** It therefore cannot be used in
|
|
74
|
+
Python's `if`, `while`, `and`, `or` or `not`; a `Bool` used there raises `TypeError`.
|
|
75
|
+
`&`, `|`, `~` and `match`, below, take their place. Loops and `if` statements over
|
|
76
|
+
ordinary Python values work as usual: a `for` loop over ``range(3)`` records its steps
|
|
77
|
+
three times, and a Python function called in the body records the steps it makes.
|
|
78
|
+
|
|
79
|
+
**A DPP takes only variables.** `flip`, `observe` and the other constructions below work
|
|
80
|
+
only inside the body of a `@DPP` function, and a parameter cannot be a number. A number,
|
|
81
|
+
such as a probability, is given to a Python function that builds the DPP:
|
|
82
|
+
|
|
83
|
+
>>> def coin(p: float) -> DPP:
|
|
84
|
+
... @DPP
|
|
85
|
+
... def heads() -> Bool:
|
|
86
|
+
... return flip(p)
|
|
87
|
+
... return heads
|
|
88
|
+
>>> prob(coin(0.3))
|
|
89
|
+
0.3
|
|
90
|
+
|
|
91
|
+
### Coin flips and Boolean operations
|
|
92
|
+
|
|
93
|
+
`flip(p)` is a coin flip: a `Bool` that is true with probability `p`. Each call is a new
|
|
94
|
+
coin flip, independent of every other, and a variable used twice has the same value both
|
|
95
|
+
times. `&`, `|`, `~` and `^` combine `Bool`s, `Bool.any` and `Bool.all` combine any number
|
|
96
|
+
of them, and `at_least` is true where at least a given number of its arguments are. A
|
|
97
|
+
cooling system fails if both pumps fail or at least two of its three sensors fail, and a
|
|
98
|
+
pump fails if its motor or the shared power supply fails:
|
|
99
|
+
|
|
100
|
+
>>> from pylea import at_least
|
|
101
|
+
>>> @DPP
|
|
102
|
+
... def cooling_fails() -> Bool:
|
|
103
|
+
... power = flip(0.001)
|
|
104
|
+
... pump_a = power | flip(0.01)
|
|
105
|
+
... pump_b = power | flip(0.01)
|
|
106
|
+
... sensors = [flip(0.02) for _ in range(3)]
|
|
107
|
+
... return (pump_a & pump_b) | at_least(2, *sensors)
|
|
108
|
+
>>> round(prob(cooling_fails), 6)
|
|
109
|
+
0.002283
|
|
110
|
+
|
|
111
|
+
### Calling a DPP
|
|
112
|
+
|
|
113
|
+
A DPP calls another as a function, as `has_disease` calls `test` above. Each call makes its
|
|
114
|
+
own coin flips, so the two calls of `test` there are two independent tests. A DPP with
|
|
115
|
+
several outputs returns a tuple:
|
|
116
|
+
|
|
117
|
+
>>> @DPP
|
|
118
|
+
... def two_coins() -> tuple[Bool, Bool]:
|
|
119
|
+
... return flip(0.5), flip(0.5)
|
|
120
|
+
>>> @DPP
|
|
121
|
+
... def same_side() -> Bool:
|
|
122
|
+
... a, b = two_coins()
|
|
123
|
+
... return ~(a ^ b)
|
|
124
|
+
>>> prob(same_side)
|
|
125
|
+
0.5
|
|
126
|
+
|
|
127
|
+
### Conditioning
|
|
128
|
+
|
|
129
|
+
`observe(e)` conditions on the `Bool` `e` being true. A query then gives probabilities
|
|
130
|
+
conditional on `e`, and so does a query of any DPP calling this one.
|
|
131
|
+
|
|
132
|
+
>>> @DPP
|
|
133
|
+
... def first_given_either() -> Bool:
|
|
134
|
+
... a, b = flip(0.5), flip(0.5)
|
|
135
|
+
... observe(a | b)
|
|
136
|
+
... return a
|
|
137
|
+
>>> round(prob(first_given_either), 6)
|
|
138
|
+
0.666667
|
|
139
|
+
|
|
140
|
+
``observe(e, if_true=0.9, if_false=0.3)`` conditions on the report of an unreliable witness
|
|
141
|
+
that `e` is true: the report has probability 0.9 where `e` is true and 0.3 where it is
|
|
142
|
+
false.
|
|
143
|
+
|
|
144
|
+
### Enumerations: `Enum`
|
|
145
|
+
|
|
146
|
+
An `Enum` type lists its values in `elements`:
|
|
147
|
+
|
|
148
|
+
>>> from pylea import Enum, probability_mass_function
|
|
149
|
+
>>> class Weather(Enum):
|
|
150
|
+
... elements = ["dry", "rain", "snow"]
|
|
151
|
+
>>> @DPP
|
|
152
|
+
... def weather() -> Weather:
|
|
153
|
+
... return Weather.random({"dry": 0.7, "rain": 0.25, "snow": 0.05})
|
|
154
|
+
|
|
155
|
+
`Enum.random` draws a value with the given probabilities, `Enum.uniform` draws every value
|
|
156
|
+
with the same probability, and `Enum.const` is a fixed value, as in
|
|
157
|
+
``Weather.const("dry")``. A variable compares with `==` and `!=` against another of its
|
|
158
|
+
type or against one of the `elements`. `<`, `<=`, `>` and `>=` follow the order of
|
|
159
|
+
`elements`, not the order of the values themselves.
|
|
160
|
+
|
|
161
|
+
`Enum.match` chooses a variable or a value for each element, and needs a key for every
|
|
162
|
+
element. Where every choice is a value rather than a variable, `data_type` names the type
|
|
163
|
+
of the result. `Bool.match` is the same, with the keys `True` and `False`.
|
|
164
|
+
|
|
165
|
+
>>> @DPP
|
|
166
|
+
... def slippery() -> Bool:
|
|
167
|
+
... w = weather()
|
|
168
|
+
... return w.match({"dry": flip(0.01), "rain": flip(0.2), "snow": True})
|
|
169
|
+
>>> round(prob(slippery), 6)
|
|
170
|
+
0.107
|
|
171
|
+
|
|
172
|
+
`Enum.cpt` builds a conditional probability table: for each tuple of values of the
|
|
173
|
+
parents, the probabilities of the elements, in the order of `elements`. The table is
|
|
174
|
+
called on the parent variables, as a DPP is:
|
|
175
|
+
|
|
176
|
+
>>> class Grass(Enum):
|
|
177
|
+
... elements = ["dry", "damp", "wet"]
|
|
178
|
+
>>> grass_given_weather = Grass.cpt([Weather], {
|
|
179
|
+
... ("dry",): [0.8, 0.2, 0.0],
|
|
180
|
+
... ("rain",): [0.0, 0.3, 0.7],
|
|
181
|
+
... ("snow",): [0.2, 0.6, 0.2]})
|
|
182
|
+
>>> @DPP
|
|
183
|
+
... def wet_grass() -> Bool:
|
|
184
|
+
... return grass_given_weather(weather()) == "wet"
|
|
185
|
+
>>> round(prob(wet_grass), 6)
|
|
186
|
+
0.185
|
|
187
|
+
|
|
188
|
+
`Enum.map` applies an ordinary Python function to the values of one or several `Enum`
|
|
189
|
+
variables. `data_type` names the type of the result, which is the variable's own type
|
|
190
|
+
where it is left out. It stores the result for every combination of values, and raises
|
|
191
|
+
`ValueError` where there are too many combinations.
|
|
192
|
+
|
|
193
|
+
>>> class Die(Enum):
|
|
194
|
+
... elements = [1, 2, 3, 4, 5, 6]
|
|
195
|
+
>>> @DPP
|
|
196
|
+
... def big_product() -> Bool:
|
|
197
|
+
... a, b = Die.uniform(), Die.uniform()
|
|
198
|
+
... return a.map(lambda x, y: x * y > 20, b, data_type=Bool)
|
|
199
|
+
>>> round(prob(big_product), 6)
|
|
200
|
+
0.166667
|
|
201
|
+
|
|
202
|
+
### Integers: `UInt`
|
|
203
|
+
|
|
204
|
+
A `UInt` type holds the integers from 0 to ``2**width - 1``. `+` adds two variables of
|
|
205
|
+
the type, or a variable and a number. The sum is taken modulo ``2**width``, so the type
|
|
206
|
+
must be wide enough for the largest sum. `count` gives the number of `Bool`s that are
|
|
207
|
+
true; Python's `sum` does not work on variables.
|
|
208
|
+
|
|
209
|
+
>>> from pylea import UInt, count
|
|
210
|
+
>>> class Heads(UInt):
|
|
211
|
+
... width = 4 # 0 to 15
|
|
212
|
+
>>> @DPP
|
|
213
|
+
... def many_heads() -> Bool:
|
|
214
|
+
... morning = count((flip(0.5) for _ in range(5)), data_type=Heads)
|
|
215
|
+
... evening = count((flip(0.5) for _ in range(5)), data_type=Heads)
|
|
216
|
+
... return morning + evening >= 7
|
|
217
|
+
>>> prob(many_heads)
|
|
218
|
+
0.171875
|
|
219
|
+
|
|
220
|
+
### Tuples and drawing without replacement
|
|
221
|
+
|
|
222
|
+
A `Tuple` type combines several types into one: `Tuple.of` builds a value from one
|
|
223
|
+
variable per type, and `Tuple.components` takes it apart again. `pick` draws one of a
|
|
224
|
+
list of variables, each with the same probability, and returns it with the rest of the
|
|
225
|
+
list; given a number `n` as well, it draws `n` in turn. The two together deal two cards
|
|
226
|
+
from a deck of four:
|
|
227
|
+
|
|
228
|
+
>>> from pylea import Tuple, pick
|
|
229
|
+
>>> class Rank(Enum):
|
|
230
|
+
... elements = ["K", "A"]
|
|
231
|
+
>>> class Colour(Enum):
|
|
232
|
+
... elements = ["red", "black"]
|
|
233
|
+
>>> class Card(Tuple):
|
|
234
|
+
... types = (Rank, Colour)
|
|
235
|
+
>>> @DPP
|
|
236
|
+
... def two_aces() -> Bool:
|
|
237
|
+
... deck = [Card.of(Rank.const(r), Colour.const(c)) for r in Rank for c in Colour]
|
|
238
|
+
... (first, second), rest = pick(deck, 2)
|
|
239
|
+
... first_rank, _ = first.components()
|
|
240
|
+
... second_rank, _ = second.components()
|
|
241
|
+
... return (first_rank == "A") & (second_rank == "A")
|
|
242
|
+
>>> round(prob(two_aces), 6)
|
|
243
|
+
0.166667
|
|
244
|
+
|
|
245
|
+
## Querying a DPP
|
|
246
|
+
|
|
247
|
+
A query takes a DPP without parameters that returns what is asked about:
|
|
248
|
+
|
|
249
|
+
* `prob` gives the probability that a DPP returning one `Bool` returns true.
|
|
250
|
+
* `probability_mass_function` gives the probability of each value of a DPP returning one
|
|
251
|
+
`Enum`, as a dict.
|
|
252
|
+
* `expectation` gives the expected value of a DPP returning one variable whose values are
|
|
253
|
+
numbers, such as a `UInt` or an `Enum` of numbers.
|
|
254
|
+
|
|
255
|
+
Each is conditional on the DPP's observations. To ask about one output of a DPP with
|
|
256
|
+
several, or about a combination of them, write a DPP that calls it and returns what is
|
|
257
|
+
asked about:
|
|
258
|
+
|
|
259
|
+
>>> from pylea import expectation
|
|
260
|
+
>>> @DPP
|
|
261
|
+
... def weather_given_wet_grass() -> Weather:
|
|
262
|
+
... w = weather()
|
|
263
|
+
... observe(grass_given_weather(w) == "wet")
|
|
264
|
+
... return w
|
|
265
|
+
>>> pmf = probability_mass_function(weather_given_wet_grass)
|
|
266
|
+
>>> {w: round(p, 4) for w, p in pmf.items()}
|
|
267
|
+
{'dry': 0.0, 'rain': 0.9459, 'snow': 0.0541}
|
|
268
|
+
>>> @DPP
|
|
269
|
+
... def heads_in_ten() -> Heads:
|
|
270
|
+
... return count((flip(0.5) for _ in range(10)), data_type=Heads)
|
|
271
|
+
>>> round(expectation(heads_in_ten), 6)
|
|
272
|
+
5.0
|
|
273
|
+
|
|
274
|
+
**An answer can be an estimate.** The `method` argument selects how a query is computed:
|
|
275
|
+
``"exact"`` computes the value exactly, ``"smc"`` estimates it by sampling, and ``"auto"``,
|
|
276
|
+
the default, runs whichever is predicted to be faster. A sampled answer is computed to
|
|
277
|
+
relative error `rel_err`, 0.01 by default, and `seed` makes it reproducible. Every answer
|
|
278
|
+
is an `Estimate`, a float with the attributes `low` and `high`, which bound the true value
|
|
279
|
+
with probability at least `confidence`, 0.95 by default; for an exact answer both equal
|
|
280
|
+
the value. Arithmetic on an `Estimate` gives a plain float, without the bounds.
|
|
281
|
+
|
|
282
|
+
>>> p = prob(first_given_either, method="smc", rel_err=0.05, seed=0)
|
|
283
|
+
>>> bool(p.low <= 2 / 3 <= p.high)
|
|
284
|
+
True
|
|
285
|
+
|
|
286
|
+
`DPP.evaluated` gives the distribution of a DPP's outputs for each value of its inputs, as
|
|
287
|
+
a `LikelihoodTable`. Printed, it has one row per input value and one column per output
|
|
288
|
+
value:
|
|
289
|
+
|
|
290
|
+
>>> print(test.evaluated())
|
|
291
|
+
| False | True
|
|
292
|
+
--------------------
|
|
293
|
+
False | 0.98 | 0.02
|
|
294
|
+
True | 0.01 | 0.99
|
|
295
|
+
|
|
296
|
+
A table is called inside a `@DPP` function as the DPP it came from is, so a DPP can be
|
|
297
|
+
computed once and the result used in many later DPPs. Where the DPP observes, the
|
|
298
|
+
probabilities for one input value sum to the probability, on that input, that every
|
|
299
|
+
observation holds, rather than to 1; this is what makes calling the table the same as
|
|
300
|
+
calling the DPP. Printing divides them by their sum, and `LikelihoodTable.normalised`
|
|
301
|
+
gives a table of the divided values, as in the
|
|
302
|
+
[German tank problem](examples/bayesian_statistics.html).
|
|
303
|
+
|
|
304
|
+
## Drawing a DPP
|
|
305
|
+
|
|
306
|
+
`pylea.syntax.dpp.DPP.diagram` draws a DPP as a diagram: one dot per variable, one box per
|
|
307
|
+
assignment, read bottom to top from the inputs to the outputs. The Boolean gates take
|
|
308
|
+
their usual symbols, a coin flip is a circle carrying its probability, an observation is an
|
|
309
|
+
eye, and a call is a box carrying the name of the DPP it calls. The diagram of `test`
|
|
310
|
+
above:
|
|
311
|
+
|
|
312
|
+
>>> test.diagram()
|
|
313
|
+
<Diagram test: 15 nodes, ... x ...>
|
|
314
|
+
|
|
315
|
+

|
|
316
|
+
|
|
317
|
+
The wire entering at the bottom carries the parameter it stands for. A wire leaving at the
|
|
318
|
+
top is named the same way where the DPP returned a variable; this one returns an
|
|
319
|
+
expression, which has no name to show.
|
|
320
|
+
|
|
321
|
+
`pylea.draw.Diagram.svg` gives the markup, `pylea.draw.Diagram.tikz` a figure for a LaTeX
|
|
322
|
+
document, `pylea.draw.Diagram.save` a file, and `pylea.draw.Diagram.show` opens one in a
|
|
323
|
+
browser; a notebook renders it where it stands.
|
|
324
|
+
|
|
325
|
+
There is no nesting: a called DPP is drawn by asking it for its own diagram.
|
|
326
|
+
`pylea.syntax.dpp.DPP.simplified` draws what the rewrite rules of `pylea.simplify` make of
|
|
327
|
+
a DPP, which is how a DPP that compiles more slowly than expected is read.
|
|
328
|
+
|
|
329
|
+
`pylea.syntax.dpp.DPP.dependency_graph` draws the DPP's variables instead of its
|
|
330
|
+
assignments: one box per variable the DPP is given or returns, and an arrow to each
|
|
331
|
+
variable computed from it. For a DPP whose outputs are the variables of a Bayesian
|
|
332
|
+
network, that is the network:
|
|
333
|
+
|
|
334
|
+
>>> from pylea.examples.sprinkler import sprinkler_network
|
|
335
|
+
>>> sprinkler_network.dependency_graph()
|
|
336
|
+
<Diagram sprinkler_network: 4 nodes, ... x ...>
|
|
337
|
+
|
|
338
|
+

|
|
339
|
+
|
|
340
|
+
It reads from the top down: the cloudiness is drawn first, the sprinkler state and the rain
|
|
341
|
+
from it, and the wetness of the grass from those two. A box carries the name the DPP
|
|
342
|
+
returned the variable as. What is drawn is what each variable is computed from, so two
|
|
343
|
+
outputs sharing an internal variable the DPP does not return are drawn apart, and
|
|
344
|
+
conditioning is not drawn at all.
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
## Modules
|
|
348
|
+
|
|
349
|
+
* `pylea.syntax`, the language: `@DPP`, variables, and the primitives beneath.
|
|
350
|
+
* `pylea.semantics`, the matrices of the primitives and the semantics of a DPP.
|
|
351
|
+
* `pylea.ir`, the intermediate representation a DPP is traced into.
|
|
352
|
+
* `pylea.simplify`, the rewrite rules applied to a DPP before it is lowered.
|
|
353
|
+
* `pylea.compile`, the tensor network, the contraction tree, and the resulting plans.
|
|
354
|
+
* `pylea.eval`, running a plan on numpy or on a torch device.
|
|
355
|
+
* `pylea.sample`, the sampling engine and the choice between the two engines.
|
|
356
|
+
* `pylea.draw`, drawing a DPP as a diagram.
|
|
357
|
+
* `pylea.utility`, converters from `.bif` Bayesian networks and `.dft` fault trees.
|
|
358
|
+
* `pylea.examples`, example DPPs.
|
|
359
|
+
"""
|
|
360
|
+
from pylea.semantics.likelihood_table import LikelihoodTable
|
|
361
|
+
from pylea.syntax.choice import pick
|
|
362
|
+
from pylea.syntax.dpp import DPP, Bool, at_least
|
|
363
|
+
from pylea.syntax.enum import Enum
|
|
364
|
+
from pylea.syntax.estimate import Estimate
|
|
365
|
+
from pylea.syntax.expectation import expectation
|
|
366
|
+
from pylea.syntax.flip import flip
|
|
367
|
+
from pylea.syntax.integer import UInt, count
|
|
368
|
+
from pylea.syntax.observe import observe
|
|
369
|
+
from pylea.syntax.pmf import probability_mass_function
|
|
370
|
+
from pylea.syntax.prob import prob
|
|
371
|
+
from pylea.syntax.tuple import Tuple
|
|
372
|
+
from pylea.syntax.variable import Variable
|
|
373
|
+
|
|
374
|
+
__all__ = ["DPP", "Bool", "Enum", "Tuple", "UInt", "Variable", "flip", "observe",
|
|
375
|
+
"at_least", "count", "pick", "prob", "probability_mass_function",
|
|
376
|
+
"expectation", "Estimate", "LikelihoodTable"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Compilation: a DPP becomes the plans the evaluator runs."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Subprocess worker running one bounded `pylea.compile.order.AdaptiveOrderFinder` search.
|
|
2
|
+
|
|
3
|
+
An `AdaptiveOrderFinder` with a `max_time` runs its search here, so that the search can be
|
|
4
|
+
stopped at that bound: cotengra tests its own budget only between trials, and on a wide
|
|
5
|
+
enough network a single trial runs far beyond it. The protocol is one pickled request on
|
|
6
|
+
stdin, ``((optimize, escalate_cost, max_time), (indices, output, size, kappa))``, and one
|
|
7
|
+
pickled response on stdout, ``{"order": order}`` or ``{"error": message}``. The finder's
|
|
8
|
+
settings travel rather than the finder itself, so a subclass defined anywhere still runs.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pickle
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
from pylea.compile.order import AdaptiveOrderFinder
|
|
18
|
+
|
|
19
|
+
(optimize, escalate_cost, max_time), (indices, output, size, kappa) = pickle.load(
|
|
20
|
+
sys.stdin.buffer)
|
|
21
|
+
finder = AdaptiveOrderFinder(optimize, escalate_cost, max_time)
|
|
22
|
+
try:
|
|
23
|
+
response = {"order": finder.find_here(indices, output, size, kappa=kappa)}
|
|
24
|
+
except Exception as exc: # noqa: BLE001, reported to the parent
|
|
25
|
+
response = {"error": f"{type(exc).__name__}: {exc}"}
|
|
26
|
+
pickle.dump(response, sys.stdout.buffer)
|
|
27
|
+
sys.stdout.buffer.flush()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
pylea/compile/cost.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Plan cost estimation.
|
|
2
|
+
|
|
3
|
+
`contraction_cost` counts the scalar multiplications the plans perform and `step_count` the
|
|
4
|
+
steps the interpreter executes. `tree_cost` counts the cost of the contraction trees the
|
|
5
|
+
plans follow as if no index were sliced, which depends on neither the slicing nor the
|
|
6
|
+
machine. A step depending on no sliced index runs once outside the
|
|
7
|
+
slice loop rather than once per slice, and both counts follow the interpreter in that. Both
|
|
8
|
+
are returned as floats, infinite where the count exceeds the float range, so that a plan
|
|
9
|
+
whose work no float can hold still compiles and is recognised by its cost rather than
|
|
10
|
+
failing in the arithmetic of the estimate.
|
|
11
|
+
|
|
12
|
+
`predicted_ms` converts the two counts to milliseconds and takes the larger. A plan doing
|
|
13
|
+
little arithmetic is bound by per-step overhead, at `MS_PER_STEP` each. One doing a great
|
|
14
|
+
deal is bound by the throughput of the contraction kernel, which `SLOPE` and `INTERCEPT`
|
|
15
|
+
describe as a power law in the operation count. `predicted_ms_from_cost` applies that power
|
|
16
|
+
law alone, for a cost arrived at without building a plan, and returns the overhead floor
|
|
17
|
+
below it, there being no step count to fall back on.
|
|
18
|
+
|
|
19
|
+
The result is good to an order of magnitude, which is what `pylea.compile.planner` needs to
|
|
20
|
+
place an instance on one side or the other of a multi-second search. It orders instances
|
|
21
|
+
and does not time them. Every constant here belongs to one machine and to `pylea.eval`.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from math import prod
|
|
27
|
+
|
|
28
|
+
from pylea.compile.plan import Contract, Load, Plan, Select
|
|
29
|
+
|
|
30
|
+
# Scalar multiplications per millisecond once the contraction kernel saturates, and the wall
|
|
31
|
+
# clock of one plan step where it does not. Timed over the fault-tree corpus on an
|
|
32
|
+
# Apple M3 Pro; re-time on another machine or after changing `pylea.eval`.
|
|
33
|
+
OPS_PER_MS = 1.0e8
|
|
34
|
+
MS_PER_STEP = 3.5e-3
|
|
35
|
+
|
|
36
|
+
# The power law ``predicted = 10 ** (SLOPE * log10(cost) + INTERCEPT)``, fitted over the
|
|
37
|
+
# arithmetic-bound plans of the fault-tree corpus on an Apple M3 Pro. Re-fit on another machine
|
|
38
|
+
# or after changing `pylea.eval`. A third coefficient for step or element count cannot be
|
|
39
|
+
# added: those two and the operation count are collinear across the corpus.
|
|
40
|
+
SLOPE = 0.8389
|
|
41
|
+
INTERCEPT = -5.6853
|
|
42
|
+
|
|
43
|
+
# The cost at which one step's worth of arithmetic equals one step's overhead. Below it the
|
|
44
|
+
# step count governs rather than the power law.
|
|
45
|
+
OVERHEAD_FLOOR_COST = MS_PER_STEP * OPS_PER_MS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def contraction_cost(plans: dict[int, Plan]) -> float:
|
|
49
|
+
"""The scalar multiplications `plans` perform, slices included.
|
|
50
|
+
|
|
51
|
+
>>> from pylea import DPP, Bool, flip
|
|
52
|
+
>>> from pylea.compile.lower import compile_program
|
|
53
|
+
>>> @DPP
|
|
54
|
+
... def both() -> Bool:
|
|
55
|
+
... return flip(0.5) & flip(0.25)
|
|
56
|
+
>>> plans, _root = compile_program(both.program())
|
|
57
|
+
>>> contraction_cost(plans)
|
|
58
|
+
12.0
|
|
59
|
+
"""
|
|
60
|
+
return _as_float(sum(_body_work(p)[1] for p in plans.values()))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def step_count(plans: dict[int, Plan]) -> float:
|
|
64
|
+
"""The steps the interpreter executes, a step counting once per slice it varies in.
|
|
65
|
+
|
|
66
|
+
>>> from pylea import DPP, Bool, flip
|
|
67
|
+
>>> from pylea.compile.lower import compile_program
|
|
68
|
+
>>> @DPP
|
|
69
|
+
... def both() -> Bool:
|
|
70
|
+
... return flip(0.5) & flip(0.25)
|
|
71
|
+
>>> plans, _root = compile_program(both.program())
|
|
72
|
+
>>> step_count(plans)
|
|
73
|
+
5.0
|
|
74
|
+
"""
|
|
75
|
+
return _as_float(sum(_body_work(p)[0] for p in plans.values()))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tree_cost(plans: dict[int, Plan]) -> float:
|
|
79
|
+
"""The cost of the contraction trees `plans` follow, each index held rather than
|
|
80
|
+
sliced: the entries of every leaf plus the multiplications of every pairwise
|
|
81
|
+
contraction, each counted once.
|
|
82
|
+
|
|
83
|
+
A sliced index is absent from every value downstream of the `Select` that removed it,
|
|
84
|
+
so a contraction whose operands lack it would, unsliced, multiply its count by the
|
|
85
|
+
index's size. For a plan that slices nothing this is `contraction_cost` plus the
|
|
86
|
+
leaves' entries:
|
|
87
|
+
|
|
88
|
+
>>> from pylea import DPP, Bool, flip
|
|
89
|
+
>>> from pylea.compile.lower import compile_program
|
|
90
|
+
>>> @DPP
|
|
91
|
+
... def both() -> Bool:
|
|
92
|
+
... return flip(0.5) & flip(0.25)
|
|
93
|
+
>>> plans, _root = compile_program(both.program())
|
|
94
|
+
>>> tree_cost(plans)
|
|
95
|
+
24.0
|
|
96
|
+
"""
|
|
97
|
+
return _as_float(sum(_tree_work(p) for p in plans.values()))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tree_work(plan: Plan) -> int:
|
|
101
|
+
# removed[v]: the sliced indices value v lacks that its unsliced counterpart holds. A
|
|
102
|
+
# sliced index is summed, unsliced, by the contraction after which no live value holds
|
|
103
|
+
# it any more, sliced indices never being free; `holders` counts the live values.
|
|
104
|
+
uses = [0] * len(plan.steps)
|
|
105
|
+
for step in plan.steps:
|
|
106
|
+
for src in _operands(step):
|
|
107
|
+
uses[src] += 1
|
|
108
|
+
removed: list[frozenset[int]] = []
|
|
109
|
+
holders = [0] * len(plan.sliced_dims)
|
|
110
|
+
total = 0
|
|
111
|
+
|
|
112
|
+
def consume(v: int) -> None:
|
|
113
|
+
uses[v] -= 1
|
|
114
|
+
if uses[v] == 0:
|
|
115
|
+
for j in removed[v]:
|
|
116
|
+
holders[j] -= 1
|
|
117
|
+
|
|
118
|
+
for i, step in enumerate(plan.steps):
|
|
119
|
+
if isinstance(step, Load):
|
|
120
|
+
held: frozenset[int] = frozenset()
|
|
121
|
+
total += prod(plan.shapes[i])
|
|
122
|
+
elif isinstance(step, Contract):
|
|
123
|
+
involved = removed[step.a] | removed[step.b]
|
|
124
|
+
total += (step.batch * step.left * step.sum * step.right
|
|
125
|
+
* prod(plan.sliced_dims[j] for j in involved))
|
|
126
|
+
consume(step.a)
|
|
127
|
+
consume(step.b)
|
|
128
|
+
held = frozenset(j for j in involved if holders[j] > 0)
|
|
129
|
+
else:
|
|
130
|
+
src = step.src
|
|
131
|
+
held = removed[src] | ({step.which} if isinstance(step, Select) else set())
|
|
132
|
+
consume(src)
|
|
133
|
+
removed.append(held)
|
|
134
|
+
if uses[i]:
|
|
135
|
+
for j in held:
|
|
136
|
+
holders[j] += 1
|
|
137
|
+
return total
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _operands(step) -> tuple[int, ...]:
|
|
141
|
+
if isinstance(step, Load):
|
|
142
|
+
return ()
|
|
143
|
+
if isinstance(step, Contract):
|
|
144
|
+
return (step.a, step.b)
|
|
145
|
+
return (step.src,)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _as_float(count: int) -> float:
|
|
149
|
+
"""`count` as a float, or infinity where it exceeds the float range.
|
|
150
|
+
|
|
151
|
+
Converting such an integer raises `OverflowError` rather than rounding to infinity.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
return float(count)
|
|
155
|
+
except OverflowError:
|
|
156
|
+
return math.inf
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _body_work(plan: Plan) -> tuple[int, int]:
|
|
160
|
+
"""``(steps run, scalar multiplications performed)`` for one plan."""
|
|
161
|
+
slices = max(plan.n_slices, 1)
|
|
162
|
+
steps = ops = 0
|
|
163
|
+
for i, step in enumerate(plan.steps):
|
|
164
|
+
reps = slices if plan.varying[i] else 1
|
|
165
|
+
steps += reps
|
|
166
|
+
if isinstance(step, Contract):
|
|
167
|
+
ops += step.batch * step.left * step.sum * step.right * reps
|
|
168
|
+
return steps, ops
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def predicted_ms(plans: dict[int, Plan]) -> float:
|
|
172
|
+
"""The estimated wall clock of evaluating `plans`, in milliseconds.
|
|
173
|
+
|
|
174
|
+
A DPP this small is bound by its step count rather than by its arithmetic, so the
|
|
175
|
+
estimate is `MS_PER_STEP` times the steps:
|
|
176
|
+
|
|
177
|
+
>>> from pylea import DPP, Bool, flip
|
|
178
|
+
>>> from pylea.compile.lower import compile_program
|
|
179
|
+
>>> @DPP
|
|
180
|
+
... def both() -> Bool:
|
|
181
|
+
... return flip(0.5) & flip(0.25)
|
|
182
|
+
>>> plans, _root = compile_program(both.program())
|
|
183
|
+
>>> round(predicted_ms(plans), 6)
|
|
184
|
+
0.0175
|
|
185
|
+
"""
|
|
186
|
+
steps, ops = 0, 0
|
|
187
|
+
for p in plans.values():
|
|
188
|
+
s, o = _body_work(p)
|
|
189
|
+
steps += s
|
|
190
|
+
ops += o
|
|
191
|
+
return max(_as_float(steps) * MS_PER_STEP, predicted_ms_from_cost(_as_float(ops)))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def predicted_ms_from_cost(cost: float) -> float:
|
|
195
|
+
"""Milliseconds from an operation count alone, for a cost obtained without a plan.
|
|
196
|
+
|
|
197
|
+
Below `OVERHEAD_FLOOR_COST` there is no step count to fall back on, so this returns what
|
|
198
|
+
the floor itself would cost rather than a number below anything observable.
|
|
199
|
+
|
|
200
|
+
>>> predicted_ms_from_cost(1e3)
|
|
201
|
+
1e-05
|
|
202
|
+
>>> round(predicted_ms_from_cost(1e12))
|
|
203
|
+
24071
|
|
204
|
+
"""
|
|
205
|
+
if cost <= 0.0:
|
|
206
|
+
return 0.0
|
|
207
|
+
if cost < OVERHEAD_FLOOR_COST:
|
|
208
|
+
return cost / OPS_PER_MS
|
|
209
|
+
return 10.0 ** (SLOPE * math.log10(cost) + INTERCEPT)
|