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.
Files changed (95) hide show
  1. pylea/__init__.py +376 -0
  2. pylea/compile/__init__.py +1 -0
  3. pylea/compile/_order_worker.py +31 -0
  4. pylea/compile/cost.py +209 -0
  5. pylea/compile/lower.py +191 -0
  6. pylea/compile/network.py +139 -0
  7. pylea/compile/order.py +405 -0
  8. pylea/compile/plan.py +158 -0
  9. pylea/compile/planner.py +169 -0
  10. pylea/draw/__init__.py +172 -0
  11. pylea/draw/coordinates.py +192 -0
  12. pylea/draw/dependency.py +169 -0
  13. pylea/draw/glyph.py +345 -0
  14. pylea/draw/layout.py +526 -0
  15. pylea/draw/sketch.py +303 -0
  16. pylea/draw/svg.py +136 -0
  17. pylea/draw/tikz.py +124 -0
  18. pylea/eval/__init__.py +1 -0
  19. pylea/eval/backend.py +404 -0
  20. pylea/eval/interp.py +235 -0
  21. pylea/eval/kernels.py +194 -0
  22. pylea/examples/__init__.py +13 -0
  23. pylea/examples/bioassay.py +129 -0
  24. pylea/examples/birthday.py +35 -0
  25. pylea/examples/disease.py +46 -0
  26. pylea/examples/electoral_college.py +92 -0
  27. pylea/examples/german_tank.py +133 -0
  28. pylea/examples/holdem.py +265 -0
  29. pylea/examples/monty_hall.py +46 -0
  30. pylea/examples/percolation.py +101 -0
  31. pylea/examples/secret_santa.py +51 -0
  32. pylea/examples/simple.py +288 -0
  33. pylea/examples/sprinkler.py +92 -0
  34. pylea/ir/__init__.py +33 -0
  35. pylea/ir/bits.py +23 -0
  36. pylea/ir/body.py +207 -0
  37. pylea/ir/dependency.py +225 -0
  38. pylea/ir/inline.py +140 -0
  39. pylea/ir/prim.py +296 -0
  40. pylea/ir/program.py +329 -0
  41. pylea/ir/reference.py +84 -0
  42. pylea/sample/__init__.py +58 -0
  43. pylea/sample/_exact_worker.py +79 -0
  44. pylea/sample/certify.py +479 -0
  45. pylea/sample/collapse.py +145 -0
  46. pylea/sample/cost_model.py +201 -0
  47. pylea/sample/estimate.py +103 -0
  48. pylea/sample/packed.py +192 -0
  49. pylea/sample/pilot.py +242 -0
  50. pylea/sample/portfolio.py +421 -0
  51. pylea/sample/query.py +256 -0
  52. pylea/sample/run.py +479 -0
  53. pylea/sample/schedule.py +261 -0
  54. pylea/sample/sweep.py +622 -0
  55. pylea/semantics/__init__.py +1 -0
  56. pylea/semantics/likelihood_table.py +278 -0
  57. pylea/semantics/matrices.py +133 -0
  58. pylea/simplify/__init__.py +44 -0
  59. pylea/simplify/absorb.py +93 -0
  60. pylea/simplify/boolean.py +220 -0
  61. pylea/simplify/common.py +580 -0
  62. pylea/simplify/cse.py +96 -0
  63. pylea/simplify/dce.py +97 -0
  64. pylea/simplify/driver.py +152 -0
  65. pylea/simplify/fold.py +622 -0
  66. pylea/simplify/implication.py +238 -0
  67. pylea/simplify/inline.py +48 -0
  68. pylea/simplify/lift.py +315 -0
  69. pylea/simplify/mask.py +176 -0
  70. pylea/simplify/observe.py +173 -0
  71. pylea/syntax/__init__.py +1 -0
  72. pylea/syntax/choice.py +99 -0
  73. pylea/syntax/comparison.py +72 -0
  74. pylea/syntax/compiled.py +251 -0
  75. pylea/syntax/dpp.py +1366 -0
  76. pylea/syntax/encoding.py +108 -0
  77. pylea/syntax/enum.py +576 -0
  78. pylea/syntax/estimate.py +86 -0
  79. pylea/syntax/expectation.py +159 -0
  80. pylea/syntax/flip.py +57 -0
  81. pylea/syntax/integer.py +224 -0
  82. pylea/syntax/method.py +161 -0
  83. pylea/syntax/observe.py +73 -0
  84. pylea/syntax/pmf.py +85 -0
  85. pylea/syntax/prob.py +82 -0
  86. pylea/syntax/tuple.py +177 -0
  87. pylea/syntax/variable.py +244 -0
  88. pylea/utility/__init__.py +1 -0
  89. pylea/utility/bif_to_dpp.py +646 -0
  90. pylea/utility/dft_to_dpp.py +363 -0
  91. pylea-0.1.0.dist-info/METADATA +99 -0
  92. pylea-0.1.0.dist-info/RECORD +95 -0
  93. pylea-0.1.0.dist-info/WHEEL +5 -0
  94. pylea-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. 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
+ ![The diagram of test](test.svg)
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
+ ![The dependency graph of sprinkler_network](sprinkler_network.svg)
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)