Coverage for src/lsqfitgp/copula/_copula.py: 89%

134 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-04 11:43 +0000

1# lsqfitgp/copula/_copula.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/>. 

19 

20"""Defines Copula.""" 

21 

22import functools 1bca

23import pprint 1bca

24 

25import gvar 1bca

26import jax 1bca

27from jax import numpy as jnp 1bca

28from jax import tree, tree_util, vmap 1bca

29 

30from lsqfitgp import _array, _gvarext 1bca

31from lsqfitgp.copula import _base 1bca

32 

33 

34class Copula(_base.DistrBase): 1bca

35 """ 

36 Represents a tree of probability distributions. 

37 

38 By "tree" it is intended an arbitrarily nested structure of containers 

39 (e.g., `dict` or `list`) where each leaf node is either a `Distr` object or 

40 another `Copula`. 

41 

42 The function of a `Copula` is to keep into account the relationships 

43 between all the `Distr` objects when defining the function `partial_invfcn` 

44 that maps Normal variates to the desired random variable. The same `Distr` 

45 object appearing in multiple places will not be sampled more than once. 

46 

47 This class inherits the functionality defined in `DistrBase` without 

48 additions. The attributes `shape`, `dtype`, `distrshape`, that represents 

49 properties of the output of `partial_invfcn`, are trees matching the 

50 structure of the tree of variables. The same holds for the output of 

51 `partial_invfcn`. `in_shape` instead is an ordinary array shape, indicating 

52 that the input Normal variables are a raveled single array. 

53 

54 Parameters 

55 ---------- 

56 variables : tree of Distr or Copula 

57 The tree of distributions to wrap. The containers are copied, so 

58 successive modifications will not be reflected on the `Copula`. 

59 

60 See Also 

61 -------- 

62 DistrBase, Distr, jax.tree_util 

63 

64 Examples 

65 -------- 

66 Define a model into a dictionary one piece at a time, then wrap it as a 

67 `Copula`: 

68 

69 >>> m = {} 

70 >>> m['a'] = lgp.copula.halfnorm(1.5) 

71 >>> m['b'] = lgp.copula.halfcauchy(2) 

72 >>> m['c'] = [ 

73 ... lgp.copula.uniform(m['a'], m['b']), 

74 ... lgp.copula.uniform(m['b'], m['a']), 

75 ... ] 

76 >>> cop = lgp.copula.Copula(m) 

77 >>> cop 

78 Copula({'a': halfnorm(1.5), 

79 'b': halfcauchy(2), 

80 'c': [uniform(<a>, <b>), uniform(<b>, <a>)]}) 

81 

82 Notice how, when showing the object on the REPL, multiple appearances of 

83 the same variables are replaced by identifiers derived from the dictionary 

84 keys. 

85 

86 The model may then be extended to create a variant. This does not affect 

87 `cop`: 

88 

89 >>> m['d'] = lgp.copula.invgamma(m['c'][0], m['c'][1]) 

90 >>> cop2 = lgp.copula.Copula(m) 

91 >>> cop2 

92 Copula({'a': halfnorm(1.5), 

93 'b': halfcauchy(2), 

94 'c': [uniform(<a>, <b>), uniform(<b>, <a>)], 

95 'd': invgamma(<c.0>, <c.1>)}) 

96 >>> cop 

97 Copula({'a': halfnorm(1.5), 

98 'b': halfcauchy(2), 

99 'c': [uniform(<a>, <b>), uniform(<b>, <a>)]}) 

100 """ 

101 

102 @staticmethod 1bca

103 def _tree_path_str(path): 1bca

104 """Format a jax pytree key path as a compact, readable string.""" 

105 

106 def parsekey(key): 1a

107 if hasattr(key, 'key'): 107 ↛ 109line 107 didn't jump to line 109 because the condition on line 107 was always true1a

108 return key.key 1a

109 elif hasattr(key, 'idx'): 

110 return key.idx 

111 else: 

112 return key 

113 

114 def keystr(key): 1a

115 key = parsekey(key) 1a

116 return str(key).replace('.', r'\.') 1a

117 

118 return '.'.join(map(keystr, path)) 1a

119 

120 @classmethod 1bca

121 def _jaxext_dict_sorting(cls, pytree): 1bca

122 """ 

123 Replace dicts in pytree with a custom dict subclass. 

124 

125 The subclass is such that the insertion order is maintained, see 

126 https://github.com/google/jax/issues/4085. 

127 """ 

128 

129 def is_dict(obj): 1a

130 return obj.__class__ is dict 1a

131 

132 def patch_dict(obj): 1a

133 if is_dict(obj): 1a

134 return tree.map(patch_dict, cls._Dict(obj)) 1a

135 else: 

136 return obj 1a

137 

138 return tree.map(patch_dict, pytree, is_leaf=is_dict) 1a

139 

140 @tree_util.register_pytree_with_keys_class 1bca

141 class _Dict(dict): 1bca

142 def tree_flatten_with_keys(self): 1bca

143 treedef = dict.fromkeys(self) 1a

144 keys_values = [(tree_util.DictKey(k), v) for k, v in self.items()] 1a

145 return keys_values, treedef 1a

146 

147 @classmethod 1bca

148 def tree_unflatten(cls, treedef, values): 1bca

149 return cls(zip(treedef, values, strict=True)) 1a

150 

151 def __init__(self, variables): 1bca

152 variables = self._jaxext_dict_sorting(variables) 1a

153 

154 def check_type(path, obj): 1a

155 if not isinstance(obj, _base.DistrBase): 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true1a

156 msg = ( 

157 f'only Distr or Copula objects can be ' 

158 f'contained in a Copula, found {obj!r} at ' 

159 f'<{self._tree_path_str(path)}>' 

160 ) 

161 raise TypeError(msg) 

162 return obj 1a

163 

164 self._variables = tree.map_with_path(check_type, variables) 1a

165 cache = set() 1a

166 self.in_shape = (self._compute_in_size(cache),) 1a

167 self._ancestor_count = len(cache) - 1 1a

168 self.shape = self._map_getattr('shape') 1a

169 self.distrshape = self._map_getattr('distrshape') 1a

170 self.dtype = self._map_getattr('dtype') 1a

171 

172 def _compute_in_size(self, cache): 1bca

173 if (out := super()._compute_in_size(cache)) is not None: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true1a

174 return out 

175 

176 def accumulate(in_size, obj): 1a

177 return in_size + obj._compute_in_size(cache) 1a

178 

179 return tree.reduce(accumulate, self._variables, 0) 1a

180 

181 def _map_getattr(self, attr): 1bca

182 def get_attr(obj): 1a

183 if isinstance(obj, __class__): # ty: ignore[unresolved-reference] 1a

184 return obj._map_getattr(attr) 1a

185 else: 

186 return getattr(obj, attr) 1a

187 

188 return tree.map(get_attr, self._variables) 1a

189 

190 def _partial_invfcn_internal(self, x, i, cache): 1bca

191 if (out := super()._partial_invfcn_internal(x, i, cache)) is not None: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true1a

192 return out 

193 

194 distributions, treedef = tree.flatten(self._variables) 1a

195 outputs = [] 1a

196 for distr in distributions: 1a

197 out, i = distr._partial_invfcn_internal(x, i, cache) 1a

198 outputs.append(out) 1a

199 out = tree.unflatten(treedef, outputs) 1a

200 

201 cache[self] = out 1a

202 return out, i 1a

203 

204 @functools.cached_property 1bca

205 def _partial_invfcn(self): 1bca

206 

207 # non vectorized version, check core shapes and call recursive impl 

208 # @jit 

209 def partial_invfcn_0(x): 1a

210 assert x.shape == self.in_shape 1a

211 cache = {} 1a

212 y, i = self._partial_invfcn_internal(x, 0, cache) 1a

213 assert i == x.size 1a

214 assert len(cache) == 1 + self._ancestor_count 1a

215 return y 1a

216 

217 partial_invfcn_0_deriv = jax.jacfwd(partial_invfcn_0) 1a

218 

219 # add 1-axis vectorization 

220 partial_invfcn_1 = vmap(partial_invfcn_0) 1a

221 partial_invfcn_1_deriv = vmap(partial_invfcn_0_deriv) 1a

222 

223 # add gvar support 

224 def partial_invfcn_2(x): 1a

225 

226 if x.dtype == object: 1a

227 # unpack the gvars 

228 in_mean = gvar.mean(x) 1a

229 in_jac, indices = _gvarext.jacobian(x) 1a

230 

231 # apply function 

232 out_mean = partial_invfcn_1(in_mean) 1a

233 jac = partial_invfcn_1_deriv(in_mean) 1a

234 

235 # concatenate derivatives and repack as gvars 

236 def contract_and_pack(out_mean, jac): 1a

237 # indices: 

238 # b = broadcast 

239 # i = input 

240 # ... = output 

241 # g = gvar indices 

242 out_jac = jnp.einsum('b...i,big->b...g', jac, in_jac) 1a

243 return _gvarext.from_jacobian(out_mean, out_jac, indices) 1a

244 

245 return tree.map(contract_and_pack, out_mean, jac) 1a

246 

247 else: 

248 return partial_invfcn_1(x) 1a

249 

250 # add full vectorization 

251 def partial_invfcn_3(x): 1a

252 x = _array.asarray(x) 1a

253 assert x.shape[-1:] == self.in_shape 1a

254 head = x.shape[:-1] 1a

255 x = x.reshape((-1, *self.in_shape)) 1a

256 y = partial_invfcn_2(x) 1a

257 

258 def reshape_y(y, shape): 1a

259 assert y.shape[1:] == shape 1a

260 y = y.reshape(head + shape) 1a

261 if y.dtype == object and not y.ndim: 1a

262 y = y.item() 1a

263 return y 1a

264 

265 return tree.map(reshape_y, y, self.shape) 1a

266 

267 return partial_invfcn_3 1a

268 

269 def __repr__(self, path='', cache=None): 1bca

270 

271 if isinstance(cache := super().__repr__(path, cache), str): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true1a

272 return cache 

273 

274 def subrepr(k, obj): 1a

275 if isinstance(obj, _base.DistrBase): 275 ↛ 279line 275 didn't jump to line 279 because the condition on line 275 was always true1a

276 k = self._tree_path_str(k) 1a

277 return obj.__repr__(f'{path}.{k}'.lstrip('.'), cache) 1a

278 else: 

279 return repr(obj) 

280 

281 class NoQuotesRepr: 1a

282 def __init__(self, s): 1a

283 self.s = s 1a

284 

285 def __repr__(self): 1a

286 return self.s 1a

287 

288 out = tree.map_with_path(subrepr, self._variables) 1a

289 out = tree.map(NoQuotesRepr, out) 1a

290 out = pprint.pformat(out, sort_dicts=False) 1a

291 return f'{self.__class__.__name__}({out})' 1a

292 

293 def _compute_staticdescr(self, path, cache): 1bca

294 def compute(key, x): 1a

295 return x._compute_staticdescr([*path, key], cache) 1a

296 

297 return tree.map_with_path(compute, self._variables) 1a