Coverage for src/lsqfitgp/copula/_distr.py: 94%
213 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-04 11:43 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-04 11:43 +0000
1# lsqfitgp/copula/_distr.py
2#
3# Copyright (c) 2023, 2026, Giacomo Petrillo
4#
5# This file is part of lsqfitgp.
6#
7# lsqfitgp is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# lsqfitgp is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with lsqfitgp. If not, see <http://www.gnu.org/licenses/>.
20"""Define Distr and distribution."""
22import abc 1cba
23import collections 1cba
24import functools 1cba
25import inspect 1cba
26import math 1cba
27import numbers 1cba
28import types 1cba
30import jax 1cba
31import numpy 1cba
32from jax import numpy as jnp 1cba
34from lsqfitgp import _gvarext, _signature 1cba
35from lsqfitgp.copula import _base 1cba
37######### The following 5 functions are adapted from numpy.lib.mixins #########
40def _disables_array_ufunc(obj): 1cba
41 """Check if __array_ufunc__ is set to None."""
42 return getattr(obj, '__array_ufunc__', NotImplemented) is None 1ba
45def _binary_method(ufunc, name): 1cba
46 """Implement a forward binary method with a ufunc, e.g., __add__."""
48 def func(self, other): 1cba
49 if _disables_array_ufunc(other): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true1a
50 return NotImplemented
51 return ufunc(self, other) 1a
53 func.__name__ = f'__{name}__' 1cba
54 return func 1cba
57def _reflected_binary_method(ufunc, name): 1cba
58 """Implement a reflected binary method with a ufunc, e.g., __radd__."""
60 def func(self, other): 1cba
61 if _disables_array_ufunc(other): 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true1ba
62 return NotImplemented
63 return ufunc(other, self) 1ba
65 func.__name__ = f'__r{name}__' 1cba
66 return func 1cba
69def _numeric_methods(ufunc, name): 1cba
70 """Implement forward and reflected binary methods with a ufunc."""
71 return (_binary_method(ufunc, name), _reflected_binary_method(ufunc, name)) 1cba
74def _unary_method(ufunc, name): 1cba
75 """Implement a unary special method with a ufunc."""
77 def func(self): 1cba
78 return ufunc(self) 1a
80 func.__name__ = f'__{name}__' 1cba
81 return func 1cba
84###############################################################################
87class Distr(_base.DistrBase): 1cba
88 r"""
90 Abstract base class to represent probability distributions.
92 A `Distr` object represents a probability distribution of a variable in
93 :math:`\mathbb R^n`, and provides a transformation function from a
94 (multivariate) Normal variable to the target random variable.
96 The main functionality is defined in `DistrBase`. The additional attributes
97 and methods `params`, `signature`, and `invfcn` are not intended for common
98 usage.
100 Parameters
101 ----------
102 *params : tuple of scalar, array or Distr
103 The parameters of the distribution. If the parameters have leading axes
104 other than those required, the distribution is repeated i.i.d.
105 over those axes. If a parameter is an instance of `Distr` itself, it
106 is a random parameter and its distribution is accounted for.
107 shape : int or tuple of int
108 The shape of the array of i.i.d. variables to be represented, scalar by
109 default. If the variable is multivariate, this shape adds as leading
110 axes in the array. This shape broadcasts with the non-core shapes of the
111 parameters.
112 name : str, optional
113 If specified, the distribution is defined for usage with
114 `gvar.BufferDict` using `gvar.BufferDict.add_distribution`, and for
115 convenience the constructor returns an array of gvars with the
116 appropriate shape instead of the `Distr` object. See `add_distribution`.
118 Returns
119 -------
120 distr : Distr
121 If `name` is None (default), an object representing the distribution.
122 gvars : array of gvars
123 If `name` is specified, an array of primary gvars that can be set as
124 value in a `gvar.BufferDict` under a key that uses the just defined
125 name.
127 Attributes
128 ----------
129 params : tuple
130 The parameters as passed to the constructor.
131 signature : Signature
132 An object representing the signature of `invfcn`. This is a class
133 attribute.
135 Methods
136 -------
137 invfcn : classmethod
138 Transformation function from a (multivariate) Normal variable to the
139 target random variable.
141 Examples
142 --------
143 Use directly with `gvar.BufferDict` by setting `name`:
145 >>> copula = gvar.BufferDict({
146 ... 'A(x)': lgp.copula.beta(1, 1, name='A'),
147 ... 'B(y)': lgp.copula.beta(3, 5, name='B'),
148 ... })
149 >>> copula['x']
150 0.50(40)
151 >>> copula['y']
152 0.36(18)
154 Corresponding "unrolled" usage:
156 >>> A = lgp.copula.beta(1, 1)
157 >>> B = lgp.copula.beta(3, 5)
158 >>> A.add_distribution('A')
159 >>> B.add_distribution('B')
160 >>> copula = gvar.BufferDict({
161 ... 'A(x)': A.gvars(),
162 ... 'B(y)': B.gvars(),
163 ... })
165 Notice that, although the name used for `add_distribution` must be globally
166 unique, for convenience it is permitted to redefine the same distribution
167 family with the same parameters, even from another `Distr` instance.
169 To generate automatically sensible names and avoid repeating them twice, use
170 `makedict`:
172 >>> lgp.copula.makedict({
173 ... 'x': lgp.copula.beta(1, 1),
174 ... 'y': lgp.copula.beta(3, 5),
175 ... })
176 BufferDict({'__copula_beta{1, 1}(x)': 0.0(1.0), '__copula_beta{3, 5}(y)': 0.0(1.0)})
178 Define a distribution with a random parameter:
180 >>> X = lgp.copula.halfnorm(np.sqrt(lgp.copula.invgamma(1, 1)))
181 >>> X
182 halfnorm(sqrt(invgamma(1, 1)))
184 Now `X` represents the model
186 .. math::
187 \sigma^2 &\sim \mathrm{InvGamma}(1, 1), \\
188 X \mid \sigma &\sim \mathrm{HalfNorm}(\sigma).
190 In general it is possible to transform a `Distr` with `numpy` ufuncs and
191 continuous arithmetic operations.
193 Repeated usage of `Distr` instances for random parameters will share
194 those parameters in the distributions. The following code:
196 >>> sigma2 = lgp.copula.invgamma(1, 1)
197 >>> X = lgp.copula.halfnorm(np.sqrt(sigma2))
198 >>> Y = lgp.copula.halfcauchy(np.sqrt(sigma2))
200 Corresponds to the model
202 .. math::
203 \sigma^2 &\sim \mathrm{InvGamma}(1, 1), \\
204 X \mid \sigma &\sim \mathrm{HalfNorm}(\sigma), \\
205 Y \mid \sigma &\sim \mathrm{HalfCauchy}(\sigma),
207 with the same parameter :math:`\sigma^2` shared between the two
208 distributions. However, if the distributions are now put into a
209 `gvar.BufferDict`, with
211 >>> sigma2.add_distribution('distr_sigma2')
212 >>> X.add_distribution('distr_X')
213 >>> Y.add_distribution('distr_Y')
214 >>> bd = gvar.BufferDict({
215 ... 'distr_sigma2(sigma2)': sigma2.gvars(),
216 ... 'distr_X(X)': X.gvars(),
217 ... 'distr_Y(Y)': Y.gvars(),
218 ... })
220 then this relationship breaks down; the model represented by the dictionary
221 `bd` is
223 .. math::
224 \sigma^2 &\sim \mathrm{InvGamma}(1, 1), \\
225 X \mid \sigma_X &\sim \mathrm{HalfNorm}(\sigma_X), \quad
226 & \sigma_X^2 &\sim \mathrm{InvGamma}(1, 1), \\
227 Y \mid \sigma_Y &\sim \mathrm{HalfCauchy}(\sigma_Y), \quad
228 & \sigma_Y^2 &\sim \mathrm{InvGamma}(1, 1),
230 with separate, independent parameters :math:`\sigma,\sigma_X,\sigma_Y`,
231 because each dictionary entry is evaluated separately. Indeed, trying to do
232 this with `makedict` will raise an error:
234 >>> bd = lgp.copula.makedict({'sigma2': sigma2, 'X': X, 'Y': Y})
235 ValueError: cross-key occurrences of object(s):
236 invgamma with id 6201535248: <sigma2>, <X.0.0>, <Y.0.0>
238 To use all the distributions at once while preserving the relationships,
239 put them into a container of choice and wrap it as a `Copula` object:
241 >>> sigmaXY = lgp.copula.Copula({'sigma2': sigma2, 'X': X, 'Y': Y})
243 The `Copula` provides a `partial_invfcn` function to map Normal variables
244 to a structure, with the same layout as the input one, of desired variates.
245 The whole `Copula` can be used in `gvar.BufferDict`:
247 >>> bd = lgp.copula.makedict({'sigmaXY': sigmaXY})
248 >>> bd
249 BufferDict({"__copula_{'sigma2': invgamma{1, 1}, 'X': halfnorm{sqrt{_Path{path=[{DictKey{key='sigma2'},}]}}}, 'Y': halfcauchy{sqrt{_Path{path=[{DictKey{key='sigma2'},}]}}}}(sigmaXY)": array([0.0(1.0), 0.0(1.0), 0.0(1.0)], dtype=object)})
250 >>> bd['sigmaXY']
251 {'sigma2': 1.4(1.7), 'X': 0.81(89), 'Y': 1.2(1.7)}
252 >>> gvar.corr(bd['sigmaXY']['X'], bd['sigmaXY']['Y'])
253 0.21950577757757836
255 Although the actual dictionary value is a flat array, getting the unwrapped
256 key reproduces the original structure.
258 To apply arbitrary transformations, use manually `invfcn`:
260 >>> @functools.partial(lgp.gvar_gufunc, signature='(n)->(n)')
261 >>> @functools.partial(jnp.vectorize, signature='(n)->(n)')
262 >>> def model_invfcn(normal_params):
263 ... sigma2 = lgp.copula.invgamma.invfcn(normal_params[0], 1, 1)
264 ... sigma = jnp.sqrt(sigma2)
265 ... X = lgp.copula.halfnorm.invfcn(normal_params[1], sigma)
266 ... Y = lgp.copula.halfcauchy.invfcn(normal_params[2], sigma)
267 ... return jnp.stack([sigma, X, Y])
269 The `jax.numpy.vectorize` decorator makes `model_invfcn` support
270 broadcasting on additional input axes, while `gvar_gufunc` makes it accept
271 gvars as input.
273 See Also
274 --------
275 DistrBase, Copula, gvar.BufferDict.uniform
277 Notes
278 -----
279 Concrete subclasses must define `invfcn`, and define the class attribute
280 `signature` to the numpy signature string of `invfcn`, unless `invfcn` is an
281 ufunc and its number of parameters can be inferred. `invfcn` must be
282 vectorized.
284 """
286 @classmethod 1cba
287 @abc.abstractmethod 1cba
288 def invfcn(cls, x, *params): 1cba
289 r"""
291 Transform a Normal variable to the desired distribution.
293 Maps a (multivariate) Normal variable to a variable with the desired
294 marginal distribution. In symbols: :math:`y = F^{-1}(\Phi(x))`. This
295 function is a generalized ufunc, jax traceable, vmappable one time, and
296 differentiable one time. The signature is accessible through the
297 class attribute `signature`.
299 Parameters
300 ----------
301 x : array_like
302 The input Normal variable.
303 *params : array_like
304 The parameters of the distribution.
306 Returns
307 -------
308 y : array_like
309 The output variable with the desired marginal distribution.
311 """ # noqa: DOC202
312 pass
314 def _get_x_core_shape(self, *preprocessed_params): 1cba
315 sig = self.signature.eval(None, *preprocessed_params) 1cba
316 return sig.core_in_shapes[0] 1cba
318 def _eval_shapes(self, shape): 1cba
320 # check number of parameters
321 if self.signature.nin != 1 + len(self.params): 1cba
322 msg = ( 1a
323 f'{self.__class__.__name__} distribution has '
324 f'{self.signature.nin - 1} parameters, but {len(self.params)} '
325 'parameters were passed to the constructor'
326 )
327 raise TypeError(msg) 1a
329 # convert shape to tuple
330 if isinstance(shape, numbers.Integral): 1cba
331 shape = (shape,) 1a
332 else:
333 shape = tuple(shape) 1cba
335 # make sure parameters have a shape
336 array_params = [ 1cba
337 p if hasattr(p, 'shape') else jnp.asarray(p) for p in self.params
338 ]
340 # parse signature of cls.invfcn
341 x_core_shape = self._get_x_core_shape(*array_params) 1cba
342 x = jax.ShapeDtypeStruct(shape + x_core_shape, 'd') 1cba
343 sig = self.signature.eval(x, *array_params) 1cba
344 self._in_shape_1 = sig.in_shapes[0] 1cba
345 (self.distrshape,) = sig.core_out_shapes 1cba
346 (self.shape,) = sig.out_shapes 1cba
348 self._compute_in_shape() 1cba
350 def _compute_in_shape(self): 1cba
351 in_size = math.prod(self._in_shape_1) 1cba
352 cache = set() 1cba
353 for p in self.params: 1cba
354 if isinstance(p, __class__): 1cba
355 in_size += p._compute_in_size(cache) 1ba
356 if in_size == 1: 1cba
357 self.in_shape = () 1cba
358 else:
359 self.in_shape = (in_size,) 1a
360 self._ancestor_count = len(cache) 1cba
362 def _compute_in_size(self, cache): 1cba
363 if (out := super()._compute_in_size(cache)) is not None: 1ba
364 return out 1a
365 in_size = math.prod(self._in_shape_1) 1ba
366 for p in self.params: 1ba
367 if isinstance(p, __class__): 1ba
368 in_size += p._compute_in_size(cache) 1a
369 return in_size 1ba
371 def _partial_invfcn_internal(self, x, i, cache): 1cba
372 if (out := super()._partial_invfcn_internal(x, i, cache)) is not None: 1cba
373 return out 1a
375 concrete_params = [] 1cba
376 for p in self.params: 1cba
377 if isinstance(p, __class__): 1cba
378 p, i = p._partial_invfcn_internal(x, i, cache) 1ba
379 else:
380 p = jnp.asarray(p) 1cba
382 concrete_params.append(p) 1cba
384 in_size = math.prod(self._in_shape_1) 1cba
385 assert i + in_size <= x.size 1cba
386 last = x[i : i + in_size].reshape(self._in_shape_1) 1cba
388 y = self.invfcn(last, *concrete_params) 1cba
389 if y.shape != self.shape or y.dtype != self.dtype: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true1cba
390 msg = (
391 f'{self.__class__.__name__}.invfcn returned '
392 f'array with shape {y.shape} and dtype {y.dtype}, while '
393 f'{self.shape} and {self.dtype} were expected'
394 )
395 raise ValueError(msg)
397 cache[self] = y 1cba
398 return y, i + in_size 1cba
400 @functools.cached_property 1cba
401 def _partial_invfcn(self): 1cba
403 # determine signature
404 shapestr = lambda shape: ','.join(map(str, shape)) 1cba
405 signature = f'({shapestr(self.in_shape)})->({shapestr(self.shape)})' 1cba
407 # wrap to support gvars
408 @functools.partial(_gvarext.gvar_gufunc, signature=signature) 1cba
409 # @jit
410 @functools.partial(jnp.vectorize, signature=signature) 1cba
411 def _partial_invfcn(x): 1cba
412 assert x.shape == self.in_shape 1cba
413 if not self.in_shape: 1cba
414 x = x[None] 1cba
415 cache = {} 1cba
416 y, i = self._partial_invfcn_internal(x, 0, cache) 1cba
417 assert i == x.size 1cba
418 assert len(cache) == 1 + self._ancestor_count 1cba
419 return y 1cba
421 return _partial_invfcn 1cba
423 def __init_subclass__(cls, **kw): 1cba
424 super().__init_subclass__(**kw) 1cba
426 # check and/or set signature attribute (the gufunc signature of invfcn)
427 if not hasattr(cls, 'signature'): 1cba
428 sig = inspect.signature(cls.invfcn) 1cba
429 if not all( 429 ↛ 437line 429 didn't jump to line 437 because the condition on line 429 was never true1cba
430 p.kind
431 in (
432 inspect.Parameter.POSITIONAL_ONLY,
433 inspect.Parameter.POSITIONAL_OR_KEYWORD,
434 )
435 for p in sig.parameters.values()
436 ):
437 msg = (
438 'can not automatically infer signature of '
439 f'{cls.__qualname__}.invfcn'
440 )
441 raise ValueError(msg)
442 cls.signature = ','.join(['()'] * len(sig.parameters)) + '->()' 1cba
443 if not isinstance(cls.signature, _signature.Signature): 443 ↛ 445line 443 didn't jump to line 445 because the condition on line 443 was always true1cba
444 cls.signature = _signature.Signature(cls.signature) 1cba
445 cls.signature.check_nargs(cls.invfcn) 1cba
447 # set dtype to float if not specified
448 if getattr(cls, 'dtype', NotImplemented) is NotImplemented: 1cba
449 cls.dtype = jax.dtypes.canonicalize_dtype(jnp.float64) 1cba
451 # set __signature__ to take positional parameters from invfcn
452 sig = inspect.signature(cls.invfcn) 1cba
453 pos_params = list(sig.parameters.values())[1:] 1cba
454 sig = inspect.signature(cls.__new__) 1cba
455 key_params = [ 1cba
456 p
457 for i, p in enumerate(sig.parameters.values())
458 if p.kind
459 in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
460 and i > 0
461 ]
462 cls.__signature__ = inspect.Signature(pos_params + key_params) 1cba
464 def __new__(cls, *params, shape=(), name=None): 1cba
466 self = super().__new__(cls) 1cba
467 self.params = params 1cba
468 self._eval_shapes(shape) 1cba
470 if name is None: 1cba
471 return self 1cba
472 else:
473 self.add_distribution(name) 1a
474 return self.gvars() 1a
476 class _Descr(collections.namedtuple('Distr', 'family shape params')): 1cba
477 """Static representation of a Distr object."""
479 def __repr__(self): 1cba
480 args = list(map(repr, self.params)) 1cba
481 if len(self.shape) == 1: 1cba
482 args += [f'shape={self.shape[0]}'] 1a
483 elif self.shape: 1cba
484 args += [f'shape={self.shape}'] 1a
485 arglist = ', '.join(args) 1cba
486 return f'{self.family.__name__}({arglist})' 1cba
488 def _compute_staticdescr(self, path, cache): 1cba
489 if (obj := super()._compute_staticdescr(path, cache)) is not None: 1cba
490 return obj 1a
492 params = [] 1cba
493 for i, p in enumerate(self.params): 1cba
494 if isinstance(p, __class__): 1cba
495 p = p._compute_staticdescr([*path, i], cache) 1ba
496 else:
497 p = numpy.asarray(p).tolist() 1cba
498 params.append(p) 1cba
500 return self._Descr(self.__class__, self.shape, tuple(params)) 1cba
502 def _shapestr(self, shape): 1cba
503 if shape: 1ba
504 return ( 1a
505 str(shape)
506 .replace(',)', ')')
507 .replace('(', '[')
508 .replace(')', ']')
509 .replace(' ', '')
510 )
511 else:
512 return '' 1b
514 def __repr__(self, path='', cache=None): 1cba
516 if isinstance(cache := super().__repr__(path, cache), str): 1cba
517 return cache 1a
519 args = [] 1cba
520 for i, p in enumerate(self.params): 1cba
521 if isinstance(p, __class__): 1cba
522 p = p.__repr__('.'.join((path, str(i))).lstrip('.'), cache) 1ba
523 elif hasattr(p, 'shape'): 1cba
524 p = f'Array{self._shapestr(p.shape)}' 1ba
525 else:
526 p = repr(p) 1cba
527 args.append(p) 1cba
529 if len(self.shape) == 1: 1cba
530 args += [f'shape={self.shape[0]}'] 1a
531 elif self.shape: 1cba
532 args += [f'shape={self.shape}'] 1a
534 return f'{self.__class__.__name__}({", ".join(args)})' 1cba
536 def __array_ufunc__(self, ufunc, method, *inputs, **kw): 1cba
537 if method != '__call__' or kw or ufunc.signature: 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true1ba
538 return NotImplemented
539 ufunc_class = UFunc.make_subclass(ufunc) 1ba
540 return ufunc_class(*inputs) 1ba
542 # continuous binary operations
543 __add__, __radd__ = _numeric_methods(numpy.add, 'add') 1cba
544 __sub__, __rsub__ = _numeric_methods(numpy.subtract, 'sub') 1cba
545 __mul__, __rmul__ = _numeric_methods(numpy.multiply, 'mul') 1cba
546 # __matmul__, __rmatmul__ = _numeric_methods(numpy.matmul, 'matmul')
547 __truediv__, __rtruediv__ = _numeric_methods(numpy.divide, 'truediv') 1cba
548 __mod__, __rmod__ = _numeric_methods(numpy.remainder, 'mod') 1cba
549 __divmod__, __rdivmod__ = _numeric_methods(numpy.divmod, 'divmod') 1cba
550 __pow__, __rpow__ = _numeric_methods(numpy.power, 'pow') 1cba
552 # continuous unary operations
553 __neg__ = _unary_method(numpy.negative, 'neg') 1cba
554 __pos__ = _unary_method(numpy.positive, 'pos') 1cba
555 __abs__ = _unary_method(numpy.absolute, 'abs') 1cba
558class UFunc: 1cba
559 """Base class of objects representing ufuncs applied to Distr instances."""
561 def __new__(cls, *args): 1cba
562 return super().__new__(cls, *args) 1ba
563 # this __new__ serves to forbid keyword arguments
565 @classmethod 1cba
566 def invfcn(cls, x, *args): # noqa: ARG003, the signature is inspected 1cba
567 return cls._ufunc(*args) 1ba
569 def _get_x_core_shape(self, *_): 1cba
570 return (0,) 1ba
572 @classmethod 1cba
573 @functools.cache # functools.cache not available in 3.8 1cba
574 def make_subclass(cls, ufunc): 1cba
575 def exec_body(ns): 1ba
576 ns['_ufunc'] = getattr(jnp, ufunc.__name__) 1ba
577 ns['signature'] = ','.join(['(0)'] + ufunc.nin * ['()']) + '->()' 1ba
579 return types.new_class(ufunc.__name__, (__class__, Distr), exec_body=exec_body) 1ba
582def distribution(invfcn, signature=None, dtype=None): 1cba
583 r"""
585 Decorate a transformation function to define a distribution.
587 Parameters
588 ----------
589 invfcn : function
590 The transformation function from a (multivariate) standard Normal
591 variable to the target random variable. The signature must be
592 ``invfcn(x, *params)``. It must be jax-traceable. It does not need to
593 be vectorized.
594 signature : str, optional
595 The signature of `invfcn`, as a numpy signature string. If not
596 specified, `invfcn` is assumed to take and output scalars.
597 dtype : dtype, optional
598 The dtype of the output of `invfcn`. If not specified, it is assumed to
599 be floating point.
601 Returns
602 -------
603 cls : Distr
604 The new distribution class.
606 Examples
607 --------
608 >>> @lgp.copula.distribution
609 ... def uniform(x, a, b):
610 ... return a + (b - a) * jax.scipy.stats.norm.cdf(x)
612 >>> @functools.partial(lgp.copula.distribution, signature='(n,m)->(n)')
613 ... def wishart(x):
614 ... " this parametrization is terrible, do not use "
615 ... return x @ x.T
617 """
619 def exec_body(ns): 1a
620 if signature is not None: 620 ↛ 622line 620 didn't jump to line 622 because the condition on line 620 was always true1a
621 ns['signature'] = signature 1a
622 if dtype is not None: 622 ↛ 624line 622 didn't jump to line 624 because the condition on line 622 was always true1a
623 ns['dtype'] = dtype 1a
624 ns['invfcn'] = staticmethod(jnp.vectorize(invfcn, signature=signature)) 1a
626 return types.new_class(invfcn.__name__, (Distr,), exec_body=exec_body) 1a