/
distribution.py
474 lines (375 loc) · 15.9 KB
/
distribution.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
import numpy as np
import theano.tensor as tt
from theano import function
from ..memoize import memoize
from ..model import Model, get_named_nodes
from ..vartypes import string_types
from .dist_math import bound
__all__ = ['DensityDist', 'Distribution', 'Continuous', 'Bound',
'Discrete', 'NoDistribution', 'TensorType', 'draw_values']
class _Unpickling(object):
pass
class Distribution(object):
"""Statistical distribution"""
def __new__(cls, name, *args, **kwargs):
if name is _Unpickling:
return object.__new__(cls) # for pickle
try:
model = Model.get_context()
except TypeError:
raise TypeError("No model on context stack, which is needed to "
"use the Normal('x', 0,1) syntax. "
"Add a 'with model:' block")
if isinstance(name, string_types):
data = kwargs.pop('observed', None)
total_size = kwargs.pop('total_size', None)
dist = cls.dist(*args, **kwargs)
return model.Var(name, dist, data, total_size)
else:
raise TypeError("Name needs to be a string but got: %s" % name)
def __getnewargs__(self):
return _Unpickling,
@classmethod
def dist(cls, *args, **kwargs):
dist = object.__new__(cls)
dist.__init__(*args, **kwargs)
return dist
def __init__(self, shape, dtype, testval=None, defaults=[], transform=None):
self.shape = np.atleast_1d(shape)
if False in (np.floor(self.shape) == self.shape):
raise TypeError("Expected int elements in shape")
self.dtype = dtype
self.type = TensorType(self.dtype, self.shape)
self.testval = testval
self.defaults = defaults
self.transform = transform
def default(self):
return self.get_test_val(self.testval, self.defaults)
def get_test_val(self, val, defaults):
if val is None:
for v in defaults:
if hasattr(self, v) and np.all(np.isfinite(self.getattr_value(v))):
return self.getattr_value(v)
else:
return self.getattr_value(val)
if val is None:
raise AttributeError(str(self) + " has no finite default value to use, checked: " +
str(defaults) + " pass testval argument or adjust so value is finite.")
def getattr_value(self, val):
if isinstance(val, string_types):
val = getattr(self, val)
if isinstance(val, tt.TensorVariable):
return val.tag.test_value
if isinstance(val, tt.TensorConstant):
return val.value
return val
def TensorType(dtype, shape):
return tt.TensorType(str(dtype), np.atleast_1d(shape) == 1)
class NoDistribution(Distribution):
def __init__(self, shape, dtype, testval=None, defaults=[], transform=None, parent_dist=None, *args, **kwargs):
super(NoDistribution, self).__init__(shape=shape, dtype=dtype,
testval=testval, defaults=defaults,
*args, **kwargs)
self.parent_dist = parent_dist
def __getattr__(self, name):
try:
self.__dict__[name]
except KeyError:
return getattr(self.parent_dist, name)
def logp(self, x):
return 0
class Discrete(Distribution):
"""Base class for discrete distributions"""
def __init__(self, shape=(), dtype='int64', defaults=['mode'], *args, **kwargs):
if dtype != 'int64':
raise TypeError('Discrete classes expect dtype to be int64.')
super(Discrete, self).__init__(
shape, dtype, defaults=defaults, *args, **kwargs)
class Continuous(Distribution):
"""Base class for continuous distributions"""
def __init__(self, shape=(), dtype='float64', defaults=['median', 'mean', 'mode'], *args, **kwargs):
super(Continuous, self).__init__(
shape, dtype, defaults=defaults, *args, **kwargs)
class DensityDist(Distribution):
"""Distribution based on a given log density function."""
def __init__(self, logp, shape=(), dtype='float64', testval=0, *args, **kwargs):
super(DensityDist, self).__init__(
shape, dtype, testval, *args, **kwargs)
self.logp = logp
class MultivariateContinuous(Continuous):
pass
class MultivariateDiscrete(Discrete):
pass
def draw_values(params, point=None):
"""
Draw (fix) parameter values. Handles a number of cases:
1) The parameter is a scalar
2) The parameter is an *RV
a) parameter can be fixed to the value in the point
b) parameter can be fixed by sampling from the *RV
c) parameter can be fixed using tag.test_value (last resort)
3) The parameter is a tensor variable/constant. Can be evaluated using
theano.function, but a variable may contain nodes which
a) are named parameters in the point
b) are *RVs with a random method
"""
# Distribution parameters may be nodes which have named node-inputs
# specified in the point. Need to find the node-inputs to replace them.
givens = {}
for param in params:
if hasattr(param, 'name'):
named_nodes = get_named_nodes(param)
if param.name in named_nodes:
named_nodes.pop(param.name)
for name, node in named_nodes.items():
if not isinstance(node, (tt.sharedvar.TensorSharedVariable,
tt.TensorConstant)):
givens[name] = (node, draw_value(node, point=point))
values = [None for _ in params]
for i, param in enumerate(params):
# "Homogonise" output
values[i] = np.atleast_1d(draw_value(
param, point=point, givens=givens.values()))
if len(values) == 1:
return values[0]
else:
return values
@memoize
def _compile_theano_function(param, vars, givens=None):
"""Compile theano function for a given parameter and input variables.
This function is memoized to avoid repeating costly theano compilations
when repeatedly drawing values, which is done when generating posterior
predictive samples.
Parameters
----------
param : Model variable from which to draw value
vars : Children variables of `param`
givens : Variables to be replaced in the Theano graph
Returns
-------
A compiled theano function that takes the values of `vars` as input
positional args
"""
return function(vars, param, givens=givens,
rebuild_strict=True,
on_unused_input='ignore',
allow_input_downcast=True)
def draw_value(param, point=None, givens=()):
if hasattr(param, 'name'):
if hasattr(param, 'model'):
if point is not None and param.name in point:
value = point[param.name]
elif hasattr(param, 'random') and param.random is not None:
value = param.random(point=point, size=None)
else:
value = param.tag.test_value
else:
input_pairs = ([g[0] for g in givens],
[g[1] for g in givens])
value = _compile_theano_function(param,
input_pairs[0])(*input_pairs[1])
else:
value = param
# Sanitising values may be necessary.
if hasattr(value, 'value'):
value = value.value
elif hasattr(value, 'get_value'):
value = value.get_value()
if hasattr(param, 'dtype'):
value = np.atleast_1d(value).astype(param.dtype)
if hasattr(param, 'shape'):
try:
shape = param.shape.tag.test_value
except:
shape = param.shape
if len(shape) == 0 and len(value) == 1:
value = value[0]
return value
def broadcast_shapes(*args):
"""Return the shape resulting from broadcasting multiple shapes.
Represents numpy's broadcasting rules.
Parameters
----------
*args : array-like of int
Tuples or arrays or lists representing the shapes of arrays to be broadcast.
Returns
-------
Resulting shape or None if broadcasting is not possible.
"""
x = list(np.atleast_1d(args[0])) if args else ()
for arg in args[1:]:
y = list(np.atleast_1d(arg))
if len(x) < len(y):
x, y = y, x
x[-len(y):] = [j if i == 1 else i if j == 1 else i if i == j else 0
for i, j in zip(x[-len(y):], y)]
if not all(x):
return None
return tuple(x)
def infer_shape(shape):
try:
shape = tuple(shape or ())
except TypeError: # If size is an int
shape = tuple((shape,))
except ValueError: # If size is np.array
shape = tuple(shape)
return shape
def reshape_sampled(sampled, size, dist_shape):
dist_shape = infer_shape(dist_shape)
repeat_shape = infer_shape(size)
return np.reshape(sampled, repeat_shape + dist_shape)
def replicate_samples(generator, size, repeats, *args, **kwargs):
n = int(np.prod(repeats))
if n == 1:
samples = generator(size=size, *args, **kwargs)
else:
samples = np.array([generator(size=size, *args, **kwargs)
for _ in range(n)])
samples = np.reshape(samples, tuple(repeats) + tuple(size))
return samples
def generate_samples(generator, *args, **kwargs):
"""Generate samples from the distribution of a random variable.
Parameters
----------
generator : function
Function to generate the random samples. The function is
expected take parameters for generating samples and
a keyword argument `size` which determines the shape
of the samples.
The *args and **kwargs (stripped of the keywords below) will be
passed to the generator function.
keyword arguments
~~~~~~~~~~~~~~~~
dist_shape : int or tuple of int
The shape of the random variable (i.e., the shape attribute).
size : int or tuple of int
The required shape of the samples.
broadcast_shape: tuple of int or None
The shape resulting from the broadcasting of the parameters.
If not specified it will be inferred from the shape of the
parameters. This may be required when the parameter shape
does not determine the shape of a single sample, for example,
the shape of the probabilities in the Categorical distribution.
Any remaining *args and **kwargs are passed on to the generator function.
"""
dist_shape = kwargs.pop('dist_shape', ())
size = kwargs.pop('size', None)
broadcast_shape = kwargs.pop('broadcast_shape', None)
params = args + tuple(kwargs.values())
if broadcast_shape is None:
broadcast_shape = broadcast_shapes(*[np.atleast_1d(p).shape for p in params
if not isinstance(p, tuple)])
if broadcast_shape == ():
broadcast_shape = (1,)
args = tuple(p[0] if isinstance(p, tuple) else p for p in args)
for key in kwargs:
p = kwargs[key]
kwargs[key] = p[0] if isinstance(p, tuple) else p
if np.all(dist_shape[-len(broadcast_shape):] == broadcast_shape):
prefix_shape = tuple(dist_shape[:-len(broadcast_shape)])
else:
prefix_shape = tuple(dist_shape)
repeat_shape = infer_shape(size)
if broadcast_shape == (1,) and prefix_shape == ():
if size is not None:
samples = generator(size=size, *args, **kwargs)
else:
samples = generator(size=1, *args, **kwargs)
else:
if size is not None:
samples = replicate_samples(generator,
broadcast_shape,
repeat_shape + prefix_shape,
*args, **kwargs)
else:
samples = replicate_samples(generator,
broadcast_shape,
prefix_shape,
*args, **kwargs)
return reshape_sampled(samples, size, dist_shape)
class Bounded(Distribution):
R"""
An upper, lower or upper+lower bounded distribution
Parameters
----------
distribution : pymc3 distribution
Distribution to be transformed into a bounded distribution
lower : float (optional)
Lower bound of the distribution, set to -inf to disable.
upper : float (optional)
Upper bound of the distribibution, set to inf to disable.
tranform : 'infer' or object
If 'infer', infers the right transform to apply from the supplied bounds.
If transform object, has to supply .forward() and .backward() methods.
See pymc3.distributions.transforms for more information.
"""
def __init__(self, distribution, lower, upper, transform='infer', *args, **kwargs):
import pymc3.distributions.transforms as transforms
self.dist = distribution.dist(*args, **kwargs)
self.__dict__.update(self.dist.__dict__)
self.__dict__.update(locals())
if hasattr(self.dist, 'mode'):
self.mode = self.dist.mode
if transform == 'infer':
default = self.dist.default()
if not np.isinf(lower) and not np.isinf(upper):
self.transform = transforms.interval(lower, upper)
if default <= lower or default >= upper:
self.testval = 0.5 * (upper + lower)
if not np.isinf(lower) and np.isinf(upper):
self.transform = transforms.lowerbound(lower)
if default <= lower:
self.testval = lower + 1
if np.isinf(lower) and not np.isinf(upper):
self.transform = transforms.upperbound(upper)
if default >= upper:
self.testval = upper - 1
if issubclass(distribution, Discrete):
self.transform = None
def _random(self, lower, upper, point=None, size=None):
samples = np.zeros(size).flatten()
i, n = 0, len(samples)
while i < len(samples):
sample = self.dist.random(point=point, size=n)
select = sample[np.logical_and(sample > lower, sample <= upper)]
samples[i:(i + len(select))] = select[:]
i += len(select)
n -= len(select)
if size is not None:
return np.reshape(samples, size)
else:
return samples
def random(self, point=None, size=None, repeat=None):
lower, upper = draw_values([self.lower, self.upper], point=point)
return generate_samples(self._random, lower, upper, point,
dist_shape=self.shape,
size=size)
def logp(self, value):
return bound(self.dist.logp(value),
value >= self.lower, value <= self.upper)
class Bound(object):
R"""
Creates a new upper, lower or upper+lower bounded distribution
Parameters
----------
distribution : pymc3 distribution
Distribution to be transformed into a bounded distribution
lower : float (optional)
Lower bound of the distribution
upper : float (optional)
Example
-------
boundedNormal = pymc3.Bound(pymc3.Normal, lower=0.0)
par = boundedNormal(mu=0.0, sd=1.0, testval=1.0)
"""
def __init__(self, distribution, lower=-np.inf, upper=np.inf):
self.distribution = distribution
self.lower = lower
self.upper = upper
def __call__(self, *args, **kwargs):
first, args = args[0], args[1:]
return Bounded(first, self.distribution, self.lower, self.upper,
*args, **kwargs)
def dist(self, *args, **kwargs):
return Bounded.dist(self.distribution, self.lower, self.upper,
*args, **kwargs)