pavi-pyro 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.
pavi/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """PAVI: plate-amortized variational inference for Pyro models."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("pavi-pyro")
7
+ except PackageNotFoundError: # Not installed, e.g. imported from a source checkout
8
+ __version__ = "unknown"
pavi/core/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from .encoding_manager import EncodingManager, FactorizedEM, JointEM
2
+ from .pavi_autoguide import PAVIGuide
3
+ from .transform_scheme import CascadingScheme, NormalizingFlowScheme
4
+
5
+ __all__ = [
6
+ "PAVIGuide",
7
+ "CascadingScheme",
8
+ "NormalizingFlowScheme",
9
+ "EncodingManager",
10
+ "JointEM",
11
+ "FactorizedEM",
12
+ ]
@@ -0,0 +1,436 @@
1
+ """Encoding managers: the per-instance encodings of PAVI.
2
+
3
+ In PAVI, the posterior of each instance of a site (random variable) inside a
4
+ plate is a normalizing flow whose transforms are shared across the plate and
5
+ conditioned on a small learnable **encoding** of the instance. The encoding
6
+ encodes the statistics of the instance's posterior, which is much cheaper than
7
+ storing these statistics for every instance (as in the mean-field
8
+ approximation). Encoding managers create these encodings and give each site its
9
+ context vector. They differ in how the encoding of an instance in several
10
+ plates (e.g. subject ``s`` and vertex ``n``) is built:
11
+
12
+ - ``JointEM``: every instance has its own encoding ``E^{s,n}``, stored in one
13
+ table per plate combination (size: product of the plate sizes).
14
+ - ``FactorizedEM``: the encoding is built from one encoding per plate,
15
+ concatenated ``[E^s, E^n]`` or summed ``E^s + E^n`` (size: sum of the plate
16
+ sizes).
17
+
18
+ Both managers share the same options:
19
+
20
+ - ``share_across_sites``: whether sites in the same plates reuse the same
21
+ encodings (default) or each site gets its own tables.
22
+ - ``sparse`` and ``device_params``: sparse gradients and storage of the tables
23
+ on another device (e.g. CPU memory) to save GPU memory.
24
+ - ``expose``: restrict the manager to some sites.
25
+
26
+ Encodings are ``torch.nn.Embedding`` tables stored in ``guide.encodings``, with
27
+ stable parameter names: ``encodings.<plates>.weight`` for shared tables and
28
+ ``encodings.<site>.<plates>.weight`` for per-site tables, where ``<plates>`` is
29
+ the names of the plates of the table joined by ``|``, from the outermost to the
30
+ innermost plate (e.g. ``encodings.subjects|vertices.weight``).
31
+ """
32
+
33
+ from abc import ABC, abstractmethod
34
+ from collections.abc import Iterable, Mapping
35
+ from typing import Literal, cast
36
+
37
+ import numpy as np
38
+ import torch
39
+ from pyro.infer.autoguide import AutoGuide
40
+ from pyro.nn.module import PyroModule
41
+
42
+ PlateKey = str | Iterable[str]
43
+ """A plate, or a combination of plates: a name, names joined by ``|``, or an iterable of names."""
44
+
45
+ EncodingSizeType = int | Mapping[PlateKey, int]
46
+ """Encoding size specification: either a single integer applied to every table,
47
+ or a mapping from plates (or plate combinations) to encoding sizes."""
48
+
49
+
50
+ def _to_plates(key: PlateKey) -> tuple[str, ...]:
51
+ """Convert a plate key (``"a"``, ``"a|b"`` or ``("a", "b")``) to a tuple of plate names."""
52
+ if isinstance(key, str):
53
+ return tuple(key.split("|"))
54
+ return tuple(key)
55
+
56
+
57
+ class EncodingManager(ABC):
58
+ """Base class of the encoding managers.
59
+
60
+ An encoding manager stores encoding tables, i.e. ``torch.nn.Embedding``
61
+ modules with one row per instance of a plate (or of a plate combination),
62
+ and builds the context vector of each site from them. Subclasses define
63
+ which tables a site uses (``_site_tables``) and how they are combined
64
+ (``_combine``).
65
+
66
+ The manager is tightly coupled with ``PAVIGuide``: during guide
67
+ initialization, ``init_encodings()`` creates the tables in
68
+ ``guide.encodings``, and ``get_encodings()`` returns the encodings of the
69
+ plate instances selected by ``idx``, which condition the transforms.
70
+
71
+ Args:
72
+ encoding_size: The size of the encodings: an ``int`` for every table, or
73
+ a mapping keyed by plates (see the subclasses for the accepted keys).
74
+ A size of 0 disables the corresponding encodings.
75
+ share_across_sites: If ``True`` (default), sites in the same plates
76
+ reuse the same tables. If ``False``, each site gets its own tables.
77
+ expose: Optional list of the sites this manager handles. ``None``
78
+ (default) means all the sites in plates. Other sites get no
79
+ encodings (context of size 0).
80
+ sparse: If ``True``, embedding gradients are sparse: only the encodings
81
+ of the current mini-batch are updated, which saves GPU memory.
82
+ Optimize them with ``torch.optim.SparseAdam`` (``InferenceModule``
83
+ does it automatically).
84
+ device_params: Device of the tables, e.g. ``"cpu"`` to keep large tables
85
+ out of GPU memory while the rest of the guide runs on the GPU.
86
+ ``None`` (default) uses the device of the guide.
87
+ init_scale: Standard deviation of the Normal initialization of the
88
+ encodings.
89
+
90
+ Attributes:
91
+ encoding_size: The encoding size specification, with keys converted to
92
+ tuples of plate names.
93
+ site_tables: Maps each managed site to the list of its tables, as
94
+ ``(path, plates)`` pairs: the path of the table in
95
+ ``guide.encodings`` and the plates indexing its rows.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ encoding_size: EncodingSizeType,
101
+ *,
102
+ share_across_sites: bool = True,
103
+ expose: list[str] | None = None,
104
+ sparse: bool = False,
105
+ device_params: str | torch.device | None = None,
106
+ init_scale: float = 0.1,
107
+ ) -> None:
108
+ if isinstance(encoding_size, Mapping):
109
+ self.encoding_size: int | dict[tuple[str, ...], int] = {_to_plates(k): v for k, v in encoding_size.items()}
110
+ elif isinstance(encoding_size, int):
111
+ self.encoding_size = encoding_size
112
+ else:
113
+ raise TypeError(f"`encoding_size` must be an int or a mapping, got {type(encoding_size).__name__}.")
114
+
115
+ self.share_across_sites = share_across_sites
116
+ self.expose = expose
117
+ self.sparse = sparse
118
+ self.device_params = device_params
119
+ self.init_scale = init_scale
120
+ self.site_tables: dict[str, list[tuple[tuple[str, ...], tuple[str, ...]]]] = {}
121
+ self._table_sizes: dict[tuple[str, ...], int] = {}
122
+
123
+ @abstractmethod
124
+ def _site_tables(self, plates: tuple[str, ...]) -> list[tuple[str, ...]]:
125
+ """Return the plates of each table used by a site in ``plates`` (outermost plate first)."""
126
+ raise NotImplementedError
127
+
128
+ @abstractmethod
129
+ def _table_size(self, plates: tuple[str, ...], name_site: str) -> int:
130
+ """Return the encoding size of the table indexed by ``plates``."""
131
+ raise NotImplementedError
132
+
133
+ @abstractmethod
134
+ def _combine(self, encodings: list[torch.Tensor]) -> torch.Tensor:
135
+ """Combine the (broadcast) encodings of the tables of a site into its context."""
136
+ raise NotImplementedError
137
+
138
+ @abstractmethod
139
+ def _context_size(self, sizes: list[int], name_site: str) -> int:
140
+ """Return the context size of a site whose tables have the given sizes.
141
+
142
+ Raises:
143
+ ValueError: If the sizes cannot be combined.
144
+ """
145
+ raise NotImplementedError
146
+
147
+ def init_encodings(self, guide: AutoGuide, *args, **kwargs) -> None:
148
+ """Create the encoding tables in ``guide.encodings``.
149
+
150
+ Uses ``guide.sample_plate``, ``guide.plate_size``, ``guide.plate_dim`` and
151
+ ``guide.device``. Called once by ``PAVIGuide.__init__``.
152
+
153
+ Args:
154
+ guide: The guide onto which the tables are registered.
155
+ *args: Unused.
156
+ **kwargs: Unused.
157
+
158
+ Raises:
159
+ KeyError: If ``encoding_size`` has no entry for a table needed by a site.
160
+ """
161
+ device = self.device_params if self.device_params is not None else guide.device
162
+ guide.encodings = PyroModule(name="encodings")
163
+ self.site_tables = {}
164
+ self._table_sizes = {}
165
+
166
+ for name_site, site_plates in guide.sample_plate.items():
167
+ if self.expose is not None and name_site not in self.expose:
168
+ continue
169
+ # Order plates from the outermost to the innermost one, so that tables
170
+ # do not depend on the order of the plates in the model.
171
+ plates = tuple(sorted(site_plates, key=lambda p: guide.plate_dim[p]))
172
+
173
+ tables: list[tuple[tuple[str, ...], tuple[str, ...]]] = []
174
+ for table_plates in self._site_tables(plates):
175
+ size = self._table_size(table_plates, name_site)
176
+ if size <= 0:
177
+ continue
178
+ key = "|".join(table_plates)
179
+ path: tuple[str, ...] = (key,) if self.share_across_sites else (name_site, key)
180
+ if path not in self._table_sizes:
181
+ self._add_table(guide, path, int(np.prod([guide.plate_size[p] for p in table_plates])), size, device)
182
+ self._table_sizes[path] = size
183
+ tables.append((path, table_plates))
184
+
185
+ if tables:
186
+ self._context_size([self._table_sizes[path] for path, _ in tables], name_site) # Validate the sizes
187
+ self.site_tables[name_site] = tables
188
+
189
+ def _add_table(self, guide: AutoGuide, path: tuple[str, ...], num_rows: int, size: int, device) -> None:
190
+ """Create an embedding table and register it at ``path`` under ``guide.encodings``."""
191
+ parent = guide.encodings
192
+ for name in path[:-1]:
193
+ if not hasattr(parent, name):
194
+ setattr(parent, name, PyroModule(name=name))
195
+ parent = getattr(parent, name)
196
+
197
+ # PyroModule[...] builds a subclass of its argument dynamically, which type checkers cannot see.
198
+ embedding_cls = cast(type[torch.nn.Embedding], PyroModule[torch.nn.Embedding])
199
+ table = embedding_cls(num_embeddings=num_rows, embedding_dim=size, sparse=self.sparse, device=device)
200
+ with torch.no_grad():
201
+ table.weight.normal_(0.0, self.init_scale)
202
+ if self.device_params is not None:
203
+ # Read by InferenceModule._apply to keep the table on this device (a plain attribute, not a tensor or module).
204
+ table.device_params = self.device_params # type: ignore[assignment]
205
+ setattr(parent, path[-1], table)
206
+
207
+ def _get_table(self, guide: AutoGuide, path: tuple[str, ...]) -> torch.nn.Embedding:
208
+ module = guide.encodings
209
+ for name in path:
210
+ module = getattr(module, name)
211
+ return module
212
+
213
+ def get_encodings(
214
+ self,
215
+ guide: AutoGuide,
216
+ name_sample: str,
217
+ idx: dict | None = None,
218
+ device: str | torch.device | None = None,
219
+ *args,
220
+ **kwargs,
221
+ ) -> torch.Tensor:
222
+ """Return the context of a site for the plate instances selected by ``idx``.
223
+
224
+ Args:
225
+ guide: The guide holding the encoding tables.
226
+ name_sample: The name of the site.
227
+ idx: Optional mapping from plate names to subsample indices. Plates
228
+ missing from ``idx`` (or mapped to ``None``) use all their instances.
229
+ device: Device of the output. Defaults to ``guide.device``.
230
+ *args: Unused.
231
+ **kwargs: Unused.
232
+
233
+ Returns:
234
+ A tensor of shape ``(batch_dims..., context_size)``, broadcastable to
235
+ the batch shape of the site, or an empty tensor if the site has no
236
+ encodings.
237
+ """
238
+ if device is None:
239
+ device = guide.device
240
+ if idx is not None and not isinstance(idx, dict):
241
+ raise TypeError("`idx` must be a dict mapping plate names to indices.")
242
+
243
+ tables = self.site_tables.get(name_sample)
244
+ if not tables:
245
+ return torch.tensor([], device=device)
246
+
247
+ site_plates = guide.sample_plate[name_sample]
248
+ len_batch_shape = max(-guide.plate_dim[p] for p in site_plates)
249
+ plate2idx = {p: (idx or {}).get(p) for p in site_plates}
250
+
251
+ # Batch shape of the site: plate sizes at their plate dims, 1 elsewhere.
252
+ batch_shape = [1] * len_batch_shape
253
+ for plate in site_plates:
254
+ indices = plate2idx[plate]
255
+ batch_shape[guide.plate_dim[plate]] = guide.plate_size[plate] if indices is None else len(indices)
256
+
257
+ encodings = []
258
+ for path, table_plates in tables:
259
+ table = self._get_table(guide, path)
260
+ encoding = self._lookup(guide, table, table_plates, plate2idx, len_batch_shape)
261
+ encodings.append(encoding.to(device).expand(*batch_shape, table.embedding_dim))
262
+ return self._combine(encodings)
263
+
264
+ @staticmethod
265
+ def _lookup(guide: AutoGuide, table: torch.nn.Embedding, plates: tuple[str, ...], plate2idx: dict, len_batch_shape: int) -> torch.Tensor:
266
+ """Look up the rows of ``table`` and reshape them to the plate dims of ``plates``."""
267
+ table_device = table.weight.device
268
+ indices = []
269
+ for plate in plates:
270
+ if plate2idx[plate] is None:
271
+ indices.append(torch.arange(guide.plate_size[plate], device=table_device))
272
+ elif isinstance(plate2idx[plate], torch.Tensor):
273
+ indices.append(plate2idx[plate].to(device=table_device, dtype=torch.long))
274
+ else:
275
+ indices.append(torch.as_tensor(np.asarray(plate2idx[plate]), device=table_device, dtype=torch.long))
276
+
277
+ # Row-major linearization of the multi-plate indices: plates are ordered
278
+ # from the outermost to the innermost, as the rows of the table.
279
+ flat_idx = torch.zeros((), dtype=torch.long, device=table_device)
280
+ stride = 1
281
+ for i in reversed(range(len(plates))):
282
+ shape = [1] * len(plates)
283
+ shape[i] = -1
284
+ flat_idx = flat_idx + indices[i].reshape(shape) * stride
285
+ stride *= guide.plate_size[plates[i]]
286
+
287
+ # Plates are sorted by plate dim, so the looked-up axes keep their order
288
+ # when the singleton dims of the other plates are inserted.
289
+ shape = [1] * len_batch_shape
290
+ for plate, plate_indices in zip(plates, indices, strict=True):
291
+ shape[guide.plate_dim[plate]] = len(plate_indices)
292
+ return table(flat_idx).reshape(*shape, table.embedding_dim)
293
+
294
+ def get_sample_encodings_size(self, guide: AutoGuide, name_sample: str) -> int:
295
+ """Return the context size of a site (0 if the site has no encodings).
296
+
297
+ Args:
298
+ guide: The guide (unused, kept for API consistency).
299
+ name_sample: The name of the site.
300
+
301
+ Returns:
302
+ The size of the last dimension of ``get_encodings(guide, name_sample)``.
303
+ """
304
+ tables = self.site_tables.get(name_sample)
305
+ if not tables:
306
+ return 0
307
+ return self._context_size([self._table_sizes[path] for path, _ in tables], name_sample)
308
+
309
+
310
+ class JointEM(EncodingManager):
311
+ """One encoding per instance, for every plate combination.
312
+
313
+ Every instance of a site gets its own encoding, even for sites in several
314
+ plates: the table of a plate combination has as many rows as the product of
315
+ the plate sizes (e.g. subjects × vertices). This is the most flexible
316
+ manager, and the natural choice for sites in a single plate; for nested
317
+ plates, the table can become very large (see ``FactorizedEM``).
318
+
319
+ Args:
320
+ encoding_size: An ``int`` (the same for every plate combination) or a
321
+ mapping keyed by plate combinations, with plate names joined by
322
+ ``|`` in any order (e.g. ``{"subjects": 8, "subjects|vertices": 4}``).
323
+ A size of 0 disables the encodings of that combination.
324
+ share_across_sites: If ``True`` (default), sites in the same plates share
325
+ one table. If ``False``, each site gets its own table.
326
+ **kwargs: ``expose``, ``sparse``, ``device_params`` and ``init_scale``,
327
+ see ``EncodingManager``.
328
+
329
+ Example:
330
+ ```py
331
+ JointEM({"subjects": 8, "subjects|vertices": 4})
332
+ # sites in `subjects` use `encodings.subjects.weight` (S rows of size 8),
333
+ # sites in both plates use `encodings.subjects|vertices.weight` (S × N rows of size 4)
334
+ ```
335
+ """
336
+
337
+ def __init__(self, encoding_size: EncodingSizeType, *, share_across_sites: bool = True, **kwargs) -> None:
338
+ super().__init__(encoding_size, share_across_sites=share_across_sites, **kwargs)
339
+ if isinstance(self.encoding_size, dict):
340
+ sizes: dict[frozenset[str], int] = {}
341
+ for plates, size in self.encoding_size.items():
342
+ if frozenset(plates) in sizes:
343
+ raise ValueError(f"Plate combination {'|'.join(plates)} is given twice in `encoding_size`.")
344
+ sizes[frozenset(plates)] = size
345
+ self._sizes = sizes
346
+
347
+ def _site_tables(self, plates: tuple[str, ...]) -> list[tuple[str, ...]]:
348
+ return [plates]
349
+
350
+ def _table_size(self, plates: tuple[str, ...], name_site: str) -> int:
351
+ if isinstance(self.encoding_size, int):
352
+ return self.encoding_size
353
+ try:
354
+ return self._sizes[frozenset(plates)]
355
+ except KeyError:
356
+ raise KeyError(
357
+ f"Plate combination {'|'.join(plates)!r} of site {name_site!r} is missing in `encoding_size`.",
358
+ ) from None
359
+
360
+ def _combine(self, encodings: list[torch.Tensor]) -> torch.Tensor:
361
+ return encodings[0]
362
+
363
+ def _context_size(self, sizes: list[int], name_site: str) -> int:
364
+ return sizes[0]
365
+
366
+
367
+ class FactorizedEM(EncodingManager):
368
+ """Encodings built from one encoding per plate.
369
+
370
+ The encoding of an instance in several plates combines per-plate encodings,
371
+ e.g. ``[E^s, E^n]`` (``agg_type="concat"``) or ``E^s + E^n``
372
+ (``agg_type="sum"``) for subject ``s`` and vertex ``n``. Only the sum of the
373
+ plate sizes is stored, instead of their product with ``JointEM``. With
374
+ ``share_across_sites=True`` (default), every site of a plate reuses the same
375
+ plate encodings: this is the configuration of the PAVI brain application,
376
+ whose subject encodings are refitted for new subjects.
377
+
378
+ Args:
379
+ encoding_size: An ``int`` (the same for every plate) or a mapping keyed
380
+ by single plate names (e.g. ``{"subjects": 120, "vertices": 8}``).
381
+ A size of 0 disables the encodings of that plate.
382
+ agg_type: How per-plate encodings are combined: ``"concat"`` (default;
383
+ context size = sum of the plate sizes) or ``"sum"`` (all the plates
384
+ of a site must have the same encoding size).
385
+ share_across_sites: If ``True`` (default), all sites of a plate share its
386
+ table. If ``False``, each site gets its own table per plate.
387
+ **kwargs: ``expose``, ``sparse``, ``device_params`` and ``init_scale``,
388
+ see ``EncodingManager``.
389
+
390
+ Example:
391
+ ```py
392
+ FactorizedEM({"subjects": 120, "vertices": 8})
393
+ # tables `encodings.subjects.weight` (S × 120) and `encodings.vertices.weight` (N × 8);
394
+ # a site in both plates gets a context of size 128
395
+ ```
396
+ """
397
+
398
+ def __init__(
399
+ self,
400
+ encoding_size: EncodingSizeType,
401
+ *,
402
+ agg_type: Literal["concat", "sum"] = "concat",
403
+ share_across_sites: bool = True,
404
+ **kwargs,
405
+ ) -> None:
406
+ super().__init__(encoding_size, share_across_sites=share_across_sites, **kwargs)
407
+ if isinstance(self.encoding_size, dict):
408
+ for plates in self.encoding_size:
409
+ if len(plates) != 1:
410
+ raise ValueError(f"FactorizedEM takes one plate per `encoding_size` key, got {'|'.join(plates)!r}.")
411
+ if agg_type not in ("concat", "sum"):
412
+ raise ValueError("`agg_type` must be 'concat' or 'sum'.")
413
+ self.agg_type = agg_type
414
+
415
+ def _site_tables(self, plates: tuple[str, ...]) -> list[tuple[str, ...]]:
416
+ return [(plate,) for plate in plates]
417
+
418
+ def _table_size(self, plates: tuple[str, ...], name_site: str) -> int:
419
+ if isinstance(self.encoding_size, int):
420
+ return self.encoding_size
421
+ try:
422
+ return self.encoding_size[plates]
423
+ except KeyError:
424
+ raise KeyError(f"Plate {plates[0]!r} of site {name_site!r} is missing in `encoding_size`.") from None
425
+
426
+ def _combine(self, encodings: list[torch.Tensor]) -> torch.Tensor:
427
+ if self.agg_type == "concat":
428
+ return torch.cat(encodings, dim=-1)
429
+ return torch.stack(encodings, dim=0).sum(dim=0)
430
+
431
+ def _context_size(self, sizes: list[int], name_site: str) -> int:
432
+ if self.agg_type == "concat":
433
+ return sum(sizes)
434
+ if len(set(sizes)) > 1:
435
+ raise ValueError(f"With agg_type='sum', the plates of site {name_site!r} must have the same encoding size, got {sizes}.")
436
+ return sizes[0]