folx 0.2__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.
folx/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ from .interpreter import forward_laplacian
2
+ from .operators import (
3
+ ForwardLaplacianOperator,
4
+ LaplacianOperator,
5
+ LoopLaplacianOperator,
6
+ ParallelLaplacianOperator,
7
+ )
8
+ from .vmap import batched_vmap
9
+ from .wrapper import wrap_forward_laplacian, warp_without_fwd_laplacian
10
+ from .wrapped_functions import deregister_function, register_function
11
+
12
+
13
+ __all__ = [
14
+ "batched_vmap",
15
+ "forward_laplacian",
16
+ "ForwardLaplacianOperator",
17
+ "LaplacianOperator",
18
+ "LoopLaplacianOperator",
19
+ "ParallelLaplacianOperator",
20
+ "wrap_forward_laplacian",
21
+ "warp_without_fwd_laplacian",
22
+ "deregister_function",
23
+ "register_function",
24
+ ]
folx/api.py ADDED
@@ -0,0 +1,374 @@
1
+ from enum import IntFlag
2
+ from typing import Any, Callable, NamedTuple, Protocol, TypeAlias, TypeVar
3
+
4
+ import jax
5
+ import jax.numpy as jnp
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ from jax import core
9
+ from jaxtyping import Array, PyTree
10
+
11
+ T = TypeVar("T", bound=PyTree[Array])
12
+ R = TypeVar("R", bound=PyTree[Array])
13
+
14
+ ExtraArgs = tuple[Array, ...]
15
+ Arrays = tuple[Array, ...]
16
+
17
+ JAC_DIM = 0 # should be either 0 or -1. TODO: switching is not support.
18
+
19
+
20
+ class FwdJacobian(NamedTuple):
21
+ """
22
+ Represents the Jacobian of a tensor with respect to the function's initial arguments.
23
+ The Jacobian may either be sparse or dense. So, for a function f: R^n -> R^m, the
24
+ Jacobian is an n x m matrix. If the Jacobian is dense, we also store it as such.
25
+ However, it might be that the Jacobian is sparse, e.g., if f(x)=x. In such a case
26
+ the Jacobian tensor is mostly sparse along the last dimension. Instead of explicitly
27
+ storing all of the zeros, we store the non-zero elements and to which of the n inputs
28
+ it depends on. So, instead of storing a n xm matrix, we store a k x m matrix, where
29
+ k is maximum number of elements any element in m depends on. Additionally we store
30
+ the index tensor which has shape k x m and contains integer indices between 0 and n.
31
+
32
+ Note that we always compute sparsity patterns at compile time for efficiency reasons.
33
+ This means that x0_idx is a numpy array and not a jax array.
34
+
35
+ A few notes:
36
+ - If sparsity patterns are modified in jax functions, we have to disable omnistaging.
37
+ - Materializing the dense array is expensive and should be avoided if possible.
38
+ - As we do not explicitly keep track of m, it might be that two dense Jacobians differ
39
+ in the last dimension. This is not a problem as we can always pad the smaller one.
40
+ """
41
+
42
+ data: Array # shape (k, ...)
43
+ x0_idx: npt.NDArray[np.int32] | None = None # integer array of same shape as data
44
+
45
+ @property
46
+ def weak(self) -> bool:
47
+ return self.x0_idx is not None
48
+
49
+ @property
50
+ def unique_idx(self):
51
+ """
52
+ Returns an array containing the indices in that the Jacobian depends on.
53
+ """
54
+ if self.x0_idx is not None:
55
+ return np.unique(self.x0_idx)
56
+ else:
57
+ return np.arange(self.data.shape[JAC_DIM])
58
+
59
+ def materialize_for_idx(self, idx, max_idx: int | None = None):
60
+ """
61
+ Materializes the Jacobian for the given indices. If max_idx is not None, the
62
+ resulting Jacobian will have shape (..., max_idx). Otherwise, it will have
63
+ shape (..., max(idx) + 1). The latter only works if the idx tensor is a numpy
64
+ array.
65
+ """
66
+ assert self.weak
67
+ from .utils import broadcast_except, compact_repeated_dims_except
68
+
69
+ # If we have static indices, we can do some optimization as we can statically
70
+ # analyze the indices on whether they are identical along certain axes.
71
+ # If so we can reduce these dimensions to enable coalesced memory access.
72
+ if isinstance(idx, np.ndarray):
73
+ idx = compact_repeated_dims_except(idx, axis=JAC_DIM)[0]
74
+
75
+ # Broadcast to ensure shape compatibility
76
+ x, idx = broadcast_except((self.data, idx), axis=JAC_DIM)
77
+
78
+ # If we just broadcasted idx we should reduce these dims again.
79
+ if isinstance(idx, np.ndarray):
80
+ idx, copied_axes = compact_repeated_dims_except(idx, axis=JAC_DIM)
81
+ else:
82
+ copied_axes = ()
83
+
84
+ indexed_axes = np.setdiff1d(np.arange(idx.ndim), (*copied_axes, JAC_DIM))
85
+ new_order = (*indexed_axes, JAC_DIM, *copied_axes)
86
+ inv_order = tuple(np.argsort(new_order))
87
+
88
+ x = jnp.transpose(x, new_order)
89
+ idx = np.transpose(idx, new_order)
90
+ idx = idx[(..., *([0] * len(copied_axes)))] # remove copied axes
91
+
92
+ x_shape = (*x.shape[: len(indexed_axes)], -1, *x.shape[len(indexed_axes) + 1 :])
93
+ x = x.reshape(
94
+ np.prod(x.shape[: len(indexed_axes)], dtype=int), *x.shape[len(indexed_axes) :]
95
+ )
96
+ idx = idx.reshape(
97
+ np.prod(idx.shape[: len(indexed_axes)], dtype=int), *idx.shape[len(indexed_axes) :]
98
+ )
99
+
100
+ @jax.vmap
101
+ def aggregate(x, indices):
102
+ return jax.ops.segment_sum(x, indices, max_idx)
103
+
104
+ result = aggregate(x, idx).reshape(x_shape)
105
+ result = jnp.transpose(result, inv_order)
106
+ return result
107
+
108
+ def get_index_mask(self, outputs):
109
+ """
110
+ Returns the index mask for the given outputs. The index mask is an array of
111
+ shape broadcast(outputs, x0_idx) that contains the index of the output that
112
+ each element in the Jacobian depends on. If an element does not depend on any
113
+ output, the index is set to -1.
114
+ """
115
+ assert self.weak
116
+ from .utils import broadcast_except
117
+
118
+ outputs, mask = broadcast_except((outputs, self.mask), axis=JAC_DIM)
119
+
120
+ og_shape = mask.shape[1:]
121
+ flat_mask = mask.reshape(-1, np.prod(og_shape, dtype=int)).T
122
+ flat_outputs = outputs.reshape(-1, np.prod(og_shape, dtype=int)).T
123
+
124
+ @jax.vmap
125
+ def get_indices(mask, out_mask):
126
+ matching = mask[..., None] == out_mask
127
+ indices = jnp.argmax(matching, axis=-1)
128
+ indices = jnp.where(jnp.any(matching, axis=-1), indices, -1)
129
+ return indices
130
+
131
+ if isinstance(outputs, np.ndarray):
132
+ with core.new_main(core.EvalTrace, dynamic=True):
133
+ result = np.asarray(get_indices(flat_mask, flat_outputs), dtype=int).T
134
+ else:
135
+ result = get_indices(flat_mask, flat_outputs).T
136
+ return result.reshape(mask.shape)
137
+
138
+ @property
139
+ def data_shape(self):
140
+ return tuple(self.data.shape[i] for i in range(self.data.ndim) if i != JAC_DIM)
141
+
142
+ def construct_jac_for(self, idx):
143
+ """
144
+ Constructs the Jacobian for the given indices. If the Jacobian is dense, this
145
+ is just a simple indexing operation. If the Jacobian is sparse, we have to
146
+ materialize it first.
147
+ If idx is None we return the dense matrix.
148
+ """
149
+ if idx is None:
150
+ return self.dense_array
151
+ if len(idx) == 0:
152
+ return jnp.zeros((*self.data_shape, 0))
153
+ if self.x0_idx is None:
154
+ return self.data[idx]
155
+ return self.materialize_for_idx(self.get_index_mask(idx), len(idx))
156
+
157
+ @property
158
+ def dense_array(self) -> Array:
159
+ """
160
+ Returns the dense Jacobian. If the Jacobian is sparse, we materialize it first.
161
+ """
162
+ if self.x0_idx is None:
163
+ return self.data
164
+ ext_idx = (..., *((None,) * len(self.data_shape))) # this is for mypy
165
+ return self.construct_jac_for(np.arange(self.max_n + 1)[ext_idx])
166
+
167
+ @property
168
+ def max_n(self) -> int:
169
+ if self.x0_idx is not None:
170
+ return int(np.max(self.x0_idx))
171
+ return self.data.shape[JAC_DIM]
172
+
173
+ @property
174
+ def as_dense(self):
175
+ return FwdJacobian.from_dense(self.dense_array)
176
+
177
+ @property
178
+ def dense_or_sparse(self) -> Array:
179
+ return self.data
180
+
181
+ @property
182
+ def sparse(self) -> Array:
183
+ assert self.weak
184
+ return self.data
185
+
186
+ @property
187
+ def mask(self) -> np.ndarray:
188
+ if self.x0_idx is not None:
189
+ return self.x0_idx
190
+ else:
191
+ ext_idx = (..., *((None,) * len(self.data_shape))) # this is for mypy
192
+ return (
193
+ np.ones(self.data.shape, dtype=np.int32)
194
+ * np.arange(self.data.shape[JAC_DIM], dtype=np.int32)[ext_idx]
195
+ )
196
+
197
+ @property
198
+ def ndim(self) -> int:
199
+ return self.data.ndim
200
+
201
+ @classmethod
202
+ def from_dense(cls, array):
203
+ return cls(array, None)
204
+
205
+ def __add__(self, other):
206
+ assert isinstance(other, FwdJacobian)
207
+ # If any is not weak we just add dense jacobians
208
+ if not self.weak or not other.weak:
209
+ from .utils import add_jacobians
210
+ return FwdJacobian(add_jacobians(self.as_dense.data, other.as_dense.data))
211
+ # If both are weak, we can keep them sparse
212
+ if (other.x0_idx == self.x0_idx).all():
213
+ return FwdJacobian(self.data + other.data, np.broadcast_arrays(self.x0_idx, other.x0_idx)[0]) # type: ignore
214
+ return FwdJacobian(
215
+ data=jnp.concatenate((self.data, other.data), axis=JAC_DIM),
216
+ x0_idx=np.concatenate((self.x0_idx, other.x0_idx), axis=JAC_DIM), # type: ignore
217
+ )
218
+
219
+
220
+ class FwdLaplArray(NamedTuple):
221
+ """
222
+ Represents a triplet of a tensor, its Jacobian and its Laplacian with respect to
223
+ the function's initial arguments.
224
+ """
225
+
226
+ x: Array
227
+ jacobian: FwdJacobian
228
+ laplacian: Array
229
+
230
+ @property
231
+ def shape(self):
232
+ return self.x.shape
233
+
234
+ @property
235
+ def ndim(self):
236
+ return self.x.ndim
237
+
238
+ @property
239
+ def dense_jacobian(self):
240
+ return self.jacobian.dense_array
241
+
242
+ @property
243
+ def is_jacobian_weak(self):
244
+ return self.jacobian.weak
245
+
246
+ @property
247
+ def sparse_jacobian(self):
248
+ return self.jacobian.sparse
249
+
250
+ @property
251
+ def jacobian_mask(self):
252
+ return self.jacobian.mask
253
+
254
+ @property
255
+ def dense(self):
256
+ return FwdLaplArray(self.x, self.jacobian.as_dense, self.laplacian)
257
+
258
+
259
+ def IS_LPL_ARR(x):
260
+ return isinstance(x, FwdLaplArray)
261
+
262
+
263
+ def IS_LEAF(x):
264
+ return isinstance(x, (FwdLaplArray, Array))
265
+
266
+
267
+ FwdLaplArrays = tuple[FwdLaplArray, ...]
268
+ ArrayOrFwdLaplArray: TypeAlias = Array | FwdLaplArray
269
+
270
+
271
+ class FwdLaplArgs(NamedTuple):
272
+ """
273
+ Utility class that represents a tuple of tensors, their Jacobians and their
274
+ Laplacians with respect to the function's initial arguments.
275
+ """
276
+
277
+ arrays: FwdLaplArrays
278
+
279
+ @property
280
+ def x(self) -> Arrays:
281
+ return tuple(a.x for a in self.arrays)
282
+
283
+ @property
284
+ def jacobian(self) -> tuple[FwdJacobian, ...]:
285
+ return tuple(a.jacobian for a in self.arrays)
286
+
287
+ @property
288
+ def dense_jacobian(self) -> Arrays:
289
+ return tuple(a.dense_jacobian for a in self.arrays)
290
+
291
+ @property
292
+ def sparse_jacobian(self) -> Arrays:
293
+ return tuple(a.sparse_jacobian for a in self.arrays)
294
+
295
+ @property
296
+ def jacobian_mask(self):
297
+ return tuple(a.jacobian_mask for a in self.arrays)
298
+
299
+ @property
300
+ def all_jacobian_weak(self) -> bool:
301
+ return all(a.is_jacobian_weak for a in self.arrays)
302
+
303
+ @property
304
+ def any_jacobian_weak(self) -> bool:
305
+ return any(a.is_jacobian_weak for a in self.arrays)
306
+
307
+ @property
308
+ def dense(self):
309
+ return FwdLaplArgs(tuple(a.dense for a in self.arrays))
310
+
311
+ @property
312
+ def laplacian(self) -> Arrays:
313
+ return tuple(a.laplacian for a in self.arrays)
314
+
315
+ @property
316
+ def one_hot_sparse_jacobian(self):
317
+ jacobians = self.sparse_jacobian
318
+ return tuple(
319
+ tuple(
320
+ jacobians[j]
321
+ if i == j
322
+ else jnp.zeros((jacobians[i].shape[0], *jacobians[j].shape[1:]), dtype=jacobians[j].dtype)
323
+ for j in range(len(jacobians))
324
+ )
325
+ for i in range(len(jacobians))
326
+ )
327
+
328
+ def __len__(self) -> int:
329
+ return len(self.arrays)
330
+
331
+
332
+ Axes = Any
333
+
334
+ ArrayOrArrays: TypeAlias = Array | tuple[Array, ...] | list[Array]
335
+ ForwardFn = Callable[..., ArrayOrArrays]
336
+
337
+
338
+ class MergeFn(Protocol):
339
+ def __call__(self, args: Arrays, extra: ExtraArgs) -> Arrays:
340
+ ...
341
+
342
+
343
+ class ForwardLaplacianFns(NamedTuple):
344
+ forward: ForwardFn
345
+ jvp: Callable[[FwdLaplArgs, dict[str, Any]], tuple[ArrayOrArrays, FwdJacobian, ArrayOrArrays]]
346
+ jac_hessian_jac_trace: Callable[[FwdLaplArgs, int], ArrayOrArrays]
347
+
348
+
349
+ class JvpFn(Protocol):
350
+ def __call__(self, primals: Arrays, tangents: Arrays) -> tuple[Array, Array]:
351
+ ...
352
+
353
+
354
+ class CustomTraceJacHessianJac(Protocol):
355
+ def __call__(self, args: FwdLaplArgs, extra_args: ExtraArgs, merge: MergeFn, materialize_idx: Array) -> PyTree[Array]:
356
+ ...
357
+
358
+
359
+ class ForwardLaplacian(Protocol):
360
+ def __call__(self, *args: ArrayOrFwdLaplArray, sparsity_threshold: int , **kwargs) -> PyTree[ArrayOrFwdLaplArray]:
361
+ ...
362
+
363
+
364
+ class FunctionFlags(IntFlag):
365
+ GENERAL = 0
366
+ LINEAR_IN_FIRST = 1
367
+ LINEAR_IN_ONE = 2 | LINEAR_IN_FIRST
368
+ LINEAR = 4 | LINEAR_IN_ONE
369
+ REDUCTION = 8
370
+ MULTIPLICATION = 16 | LINEAR_IN_ONE
371
+ DOT_PRODUCT = 32 | REDUCTION | MULTIPLICATION
372
+ INDEXING = 64 | LINEAR
373
+ SCATTER = 128
374
+ JOIN_JVP = 256
folx/custom_hessian.py ADDED
@@ -0,0 +1,59 @@
1
+ import jax
2
+ import jax.numpy as jnp
3
+
4
+ from .api import (
5
+ Array,
6
+ ExtraArgs,
7
+ FwdLaplArgs,
8
+ MergeFn,
9
+ JAC_DIM
10
+ )
11
+ from .utils import trace_of_product
12
+
13
+
14
+ def slogdet_jac_hessian_jac(args: FwdLaplArgs, extra_args: ExtraArgs, merge: MergeFn, materialize_idx: Array | None):
15
+ # For slogdet we know how to compute the determinant faster.
16
+ # We can use the fact that the jacobian of logdet is A^-1.
17
+ # Thus, the hessian is A^-1 (x) A^-T. Where (x) is the kronecker product.
18
+ # We can now reformulate this to (A^-1 (x) I)(A^-1 (x) I)^T.
19
+ # If one wants to compute the product vec(M)(A^-1 (x) I), this can be
20
+ # efficiently evaluated as vec(MA^-1). As we multiply the Hessian from
21
+ # both sides with the jacobian tr(JHJ^T), this can be efficiently be done
22
+ # as tr(J@A^-1 @ A^-1^T@J^T) where the inner @ is the outer product.
23
+ assert len(args.x) == 1
24
+ A = args.x[0]
25
+ A_inv = jnp.linalg.inv(A)
26
+ J = args.jacobian[0].construct_jac_for(materialize_idx)
27
+ J = jnp.moveaxis(J, JAC_DIM, -1)
28
+ leading_dims = A.shape[:-2]
29
+ x0_dim = J.shape[-1]
30
+
31
+ def elementwise(A_inv, J):
32
+ # Naive implementation
33
+ # @functools.partial(jax.vmap, in_axes=(-1, None), out_axes=-1)
34
+ # @functools.partial(jax.vmap, in_axes=(None, -1), out_axes=-1)
35
+ # def inner(v1, v2):
36
+ # A_inv_v = A_inv@v1
37
+ # v_A_inv = v2.T@A_inv.T
38
+ # return -v_A_inv.reshape(-1)@A_inv_v.reshape(-1)
39
+ # vHv = inner(J, J)
40
+ # trace = jnp.trace(vHv)
41
+
42
+ # We can do better and compute the trace more efficiently.
43
+ A_inv_J = jnp.einsum("ij,jdk->idk", A_inv, J)
44
+ trace = -trace_of_product(
45
+ jnp.transpose(A_inv_J, (1, 0, 2)).reshape(-1, x0_dim), A_inv_J.reshape(-1, x0_dim)
46
+ )
47
+ return jnp.zeros(()), trace
48
+
49
+ A_inv = A_inv.reshape(-1, *A.shape[-2:])
50
+ J = J.reshape(-1, *J.shape[-3:])
51
+
52
+ # We can either use vmap or scan. Scan is slightly slower but uses less memory.
53
+ # Here we assume that we will in general encounter larger determinants rather than many.
54
+ # signs, flat_out = jax.vmap(elementwise)(A_inv, J)
55
+ def scan_wrapper(_, x):
56
+ return None, elementwise(*x)
57
+
58
+ signs, flat_out = jax.lax.scan(scan_wrapper, None, (A_inv, J))[1]
59
+ return signs.reshape(leading_dims), flat_out.reshape(leading_dims)