libsymtorch 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.
symtorch/main.py ADDED
@@ -0,0 +1,1363 @@
1
+ # -*- coding: utf-8 -*-
2
+ """main.py — SymPy-to-Torch conversion and numerical integration helpers.
3
+
4
+ Standalone library.
5
+
6
+ Two-stage design for performance:
7
+ 1. SymTorch().torchify() — expensive SymPy work done ONCE
8
+ (change-of-vars + ``lambdify``)
9
+ 2. TorchExpr.torchquad_integrate() — cheap torch-only integration,
10
+ called MANY times
11
+
12
+ USAGE
13
+ =====
14
+ import symtorch
15
+
16
+ # Step 1: build once (slow: SymPy subs + lambdify, ~200–500 ms)
17
+ lt = symtorch.SymTorch()
18
+ texpr = lt.torchify(
19
+ sp.Integral(integrand, (y_A, -sp.oo, sp.oo), (y_B, -sp.oo, sp.oo))
20
+ )
21
+
22
+ # Step 2: integrate many times (fast: pure torch, ~3–8 ms each)
23
+ re, im = texpr.torchquad_integrate(N=121)
24
+
25
+ # Batched integration over a parameter grid using Simpson rule
26
+ re_b, im_b = texpr.torch_integrate_batched_simpson(
27
+ params_values=params_grid,
28
+ N=21,
29
+ chunk_size_params=256,
30
+ chunk_size_points=10_000,
31
+ )
32
+ """
33
+ from dataclasses import dataclass
34
+ import sys
35
+ from typing import List, Callable, Any
36
+
37
+ import numpy as np
38
+ import torch
39
+ from scipy import special as scipy_special
40
+ from sympy import (
41
+ Eq,
42
+ Function,
43
+ Integral,
44
+ Integer,
45
+ Mul,
46
+ Symbol,
47
+ atan,
48
+ atanh,
49
+ cos,
50
+ cosh,
51
+ diff,
52
+ lambdify,
53
+ oo,
54
+ pi,
55
+ sinh,
56
+ tan,
57
+ tanh,
58
+ )
59
+
60
+ from loguru import logger
61
+
62
+ def setup_logging(enable: bool = False, level: str = "INFO"):
63
+ """
64
+ By default, we remove all handlers (silence).
65
+ If enable=True, we add a standard output handler.
66
+ ------
67
+ Example:
68
+ # Disable logging (default)
69
+ setup_logging()
70
+ # Enable logging to stderr at INFO level
71
+ setup_logging(enable=True, level="INFO")
72
+ # Enable logging to stderr at DEBUG level
73
+ setup_logging(enable=True, level="DEBUG")
74
+ """
75
+ logger.remove()
76
+ if enable:
77
+ logger.add(sys.stderr, level=level)
78
+
79
+ setup_logging(enable=False)
80
+
81
+
82
+ @dataclass
83
+ class TorchExpr:
84
+ """Pre-built torch function together with its finite integration domain.
85
+
86
+ This is the object returned by ``SymTorch.torchify`` when integration
87
+ limits are supplied (directly or via a SymPy ``Integral``). It stores
88
+ both the numerical callable and all metadata needed by the integration
89
+ helpers in this module.
90
+
91
+ Attributes
92
+ ----------
93
+ func : Callable
94
+ The torch-compatible function produced by ``lambdify``.
95
+ Its signature is::
96
+
97
+ func(new_var_0, ..., new_var_{dim-1}, param_0, ..., param_{n_params-1})
98
+
99
+ where the ``new_var_*`` correspond to the transformed integration
100
+ variables after any change-of-variables and the ``param_*`` are any
101
+ additional symbolic parameters.
102
+ domain : list[list[float]]
103
+ Finite box for the integration domain. Each entry is ``[a, b]``
104
+ for one dimension after change-of-variables. This is what the
105
+ integrators use as their integration range.
106
+ dim : int
107
+ Number of integration variables (i.e. the dimensionality of the
108
+ domain).
109
+ n_params : int
110
+ Number of additional symbolic parameters expected by ``func``.
111
+ sympy_expr : Any
112
+ The SymPy expression *after* all symbolic substitutions and
113
+ Jacobians have been applied. Kept for debugging / inspection.
114
+ variables : list[sympy.Symbol] | None
115
+ Integration variables used by the numerical object after any
116
+ change-of-variables has been applied. For infinite limits these
117
+ are the transformed symbols such as ``t_x`` rather than the
118
+ original symbolic variable ``x``.
119
+ """
120
+ func: Callable # f(new_var_0, …, new_var_n, param_0, …, param_m)
121
+ domain: List[List[float]] # finite box for torchquad
122
+ dim: int # number of integration variables
123
+ n_params: int # number of extra parameters
124
+ sympy_expr: Any # sympy expression reduced if
125
+ variables: List[Symbol] = None # sympy variables after change of variables
126
+
127
+ @staticmethod
128
+ def _rule_simpson(a: float, b: float, N: int, *, device=None, dtype=None):
129
+ if N < 3 or (N % 2) == 0:
130
+ raise ValueError(f"Simpson rule requires odd N>=3; got N={N}")
131
+ coords = torch.linspace(a, b, N, device=device, dtype=dtype)
132
+ dx = (b - a) / (N - 1)
133
+ w = torch.ones(N, device=device, dtype=dtype)
134
+ w[1:-1:2] = 4
135
+ w[2:-1:2] = 2
136
+ weights = w * (dx / 3.0)
137
+ return coords, weights
138
+
139
+ @staticmethod
140
+ def _rule_trapezoid(a: float, b: float, N: int, *, device=None, dtype=None):
141
+ if N < 2:
142
+ raise ValueError(f"Trapezoid rule requires N>=2; got N={N}")
143
+ coords = torch.linspace(a, b, N, device=device, dtype=dtype)
144
+ dx = (b - a) / (N - 1)
145
+ w = torch.ones(N, device=device, dtype=dtype)
146
+ w[0] = 0.5
147
+ w[-1] = 0.5
148
+ weights = w * dx
149
+ return coords, weights
150
+
151
+ @staticmethod
152
+ def _rule_gauss_legendre(a: float, b: float, N: int, *, device=None, dtype=None):
153
+ if N < 1:
154
+ raise ValueError(f"Gauss-Legendre rule requires N>=1; got N={N}")
155
+ nodes, weights = np.polynomial.legendre.leggauss(N)
156
+ nodes_t = torch.as_tensor(nodes, device=device, dtype=dtype)
157
+ weights_t = torch.as_tensor(weights, device=device, dtype=dtype)
158
+ # Affine map from [-1, 1] to [a, b]
159
+ coords = 0.5 * (b - a) * nodes_t + 0.5 * (b + a)
160
+ weights = 0.5 * (b - a) * weights_t
161
+ return coords, weights
162
+
163
+ @staticmethod
164
+ def _resolve_quadrature_rule(method):
165
+ if callable(method):
166
+ return method
167
+
168
+ if method is None:
169
+ method = "simpson"
170
+
171
+ method_name = str(method).strip().lower()
172
+ if method_name == "simpson":
173
+ return TorchExpr._rule_simpson
174
+ if method_name == "trapezoid":
175
+ return TorchExpr._rule_trapezoid
176
+ if method_name in {"gauss-legendre", "gauss_legendre", "legendre"}:
177
+ return TorchExpr._rule_gauss_legendre
178
+
179
+ raise ValueError(
180
+ "Unknown integration method. Expected one of {'simpson', 'trapezoid', 'gauss-legendre'} or a callable. "
181
+ f"Got: {method}"
182
+ )
183
+
184
+ @staticmethod
185
+ def _is_scalar_like(x) -> bool:
186
+ """Return ``True`` for scalar-like values.
187
+
188
+ This is intentionally permissive: besides builtin numeric types,
189
+ it treats SymPy/mpmath numeric scalars as scalar-like if they can
190
+ be coerced to ``float`` or ``complex``.
191
+ """
192
+ if torch.is_tensor(x):
193
+ return x.ndim == 0
194
+ if isinstance(x, (int, float, complex, np.number)):
195
+ return True
196
+ try:
197
+ float(x)
198
+ return True
199
+ except Exception:
200
+ pass
201
+ try:
202
+ complex(x)
203
+ return True
204
+ except Exception:
205
+ return False
206
+
207
+ @staticmethod
208
+ def _normalize_params_values(params_values, n_params: int, *, device, dtype):
209
+ """Normalize parameter inputs into a 2D tensor and batch shape.
210
+
211
+ Accepted input formats are:
212
+
213
+ - ``None`` when ``n_params == 0``,
214
+ - a tensor of shape ``(..., n_params)``, or
215
+ - a list/tuple of length ``n_params`` whose elements are scalars or
216
+ tensors with the same shape.
217
+ """
218
+ if params_values is None:
219
+ if n_params != 0:
220
+ raise ValueError(f"Expected {n_params} params; got None")
221
+ return torch.empty((1, 0), device=device, dtype=dtype), tuple()
222
+
223
+ if torch.is_tensor(params_values):
224
+ params_tensor = params_values
225
+ if params_tensor.shape[-1] != n_params:
226
+ raise ValueError(
227
+ f"params_values last dimension must be n_params={n_params}; got shape={tuple(params_tensor.shape)}"
228
+ )
229
+ batch_shape = tuple(params_tensor.shape[:-1])
230
+ batch_size = int(np.prod(batch_shape)) if batch_shape else 1
231
+ params_flat = params_tensor.reshape(batch_size, n_params).to(device=device, dtype=dtype)
232
+ return params_flat, batch_shape
233
+
234
+ if not isinstance(params_values, (list, tuple)):
235
+ params_values = [params_values]
236
+
237
+ if len(params_values) != n_params:
238
+ raise ValueError(f"Expected {n_params} params; got {len(params_values)}")
239
+
240
+ def _to_tensor(value):
241
+ """Best-effort conversion of scalars / numpy arrays (incl. object dtype) to torch tensors."""
242
+ if torch.is_tensor(value):
243
+ return value.to(device=device)
244
+
245
+ # First try the fast path.
246
+ try:
247
+ return torch.as_tensor(value, device=device)
248
+ except Exception:
249
+ pass
250
+
251
+ # Numpy arrays with dtype=object frequently come from SymPy objects.
252
+ if isinstance(value, np.ndarray):
253
+ arr = value
254
+ else:
255
+ try:
256
+ arr = np.asarray(value)
257
+ except Exception:
258
+ arr = None
259
+
260
+ if isinstance(arr, np.ndarray):
261
+ if arr.dtype == object:
262
+ # Try float first (most common), fall back to complex.
263
+ try:
264
+ arr = arr.astype(np.float64)
265
+ except Exception:
266
+ try:
267
+ arr = np.vectorize(float, otypes=[np.float64])(arr)
268
+ except Exception:
269
+ arr = np.vectorize(complex, otypes=[np.complex128])(arr)
270
+ return torch.as_tensor(arr, device=device)
271
+
272
+ # Scalar fallback: coerce to builtin numeric types.
273
+ try:
274
+ return torch.as_tensor(complex(value), device=device)
275
+ except Exception:
276
+ return torch.as_tensor(float(value), device=device)
277
+
278
+ batch_shape = None
279
+ for param_value in params_values:
280
+ if torch.is_tensor(param_value) and param_value.ndim > 0:
281
+ batch_shape = tuple(param_value.shape)
282
+ break
283
+ if not TorchExpr._is_scalar_like(param_value):
284
+ param_tensor = _to_tensor(param_value)
285
+ if param_tensor.ndim > 0:
286
+ batch_shape = tuple(param_tensor.shape)
287
+ break
288
+ if batch_shape is None:
289
+ batch_shape = tuple()
290
+
291
+ params_list = []
292
+ for param_value in params_values:
293
+ param_tensor = _to_tensor(param_value)
294
+ if param_tensor.ndim == 0:
295
+ if batch_shape:
296
+ param_tensor = param_tensor.expand(batch_shape)
297
+ else:
298
+ if tuple(param_tensor.shape) != batch_shape:
299
+ raise ValueError(
300
+ f"All parameter tensors must have the same shape; got {tuple(param_tensor.shape)} vs {batch_shape}"
301
+ )
302
+ params_list.append(param_tensor)
303
+
304
+ params_tensor = torch.stack(params_list, dim=-1)
305
+ if dtype is not None:
306
+ params_tensor = params_tensor.to(dtype=dtype)
307
+ batch_size = int(np.prod(batch_shape)) if batch_shape else 1
308
+ params_flat = params_tensor.reshape(batch_size, n_params)
309
+ return params_flat, batch_shape
310
+
311
+ # ------------------------------------------------------------------
312
+ # Convenience methods: numeric integration on this TorchExpr
313
+ # ------------------------------------------------------------------
314
+
315
+ def torchquad_integrate(self, params_values=None, method=None, N: int = 21, dtype=None):
316
+ """Integrate this ``TorchExpr`` using the torchquad-based integrator.
317
+
318
+ This is the simplest integration entry point. It is intended for
319
+ low-dimensional problems where a single torchquad integral is
320
+ sufficient.
321
+
322
+ Parameters
323
+ ----------
324
+ params_values : list | tuple | torch.Tensor | None, optional
325
+ Numerical values for the symbolic parameters. The accepted
326
+ formats match those of ``torchquad_integrate``:
327
+
328
+ - ``None`` if there are no parameters,
329
+ - a Python sequence (list/tuple) of numbers/tensors, or
330
+ - a tensor whose last dimension is ``n_params``.
331
+ method : torchquad integrator instance or None, optional
332
+ If ``None``, a default ``Simpson`` integrator from torchquad is
333
+ constructed internally.
334
+ N : int, optional
335
+ Resolution parameter passed through to torchquad. Larger values
336
+ increase accuracy but also the number of function evaluations.
337
+ dtype : torch.dtype or None, optional
338
+ Floating dtype used for internal computations. This controls
339
+ how ``domain_points`` and parameter values are coerced before
340
+ evaluation. If ``None``, torchquad's defaults are used.
341
+
342
+ Returns
343
+ -------
344
+ re, im : torch.Tensor
345
+ Real and imaginary parts of the integral. If the integrand is
346
+ real-valued, ``im`` will be a zero tensor.
347
+
348
+ USAGE
349
+ =====
350
+ 1. Simple 1D integral without parameters::
351
+
352
+ ````python
353
+ import symtorch
354
+ from sympy import symbols, Integral, exp, oo
355
+
356
+ x = symbols("x", real=True)
357
+ integral_expr = Integral(exp(-x**2), (x, -oo, oo))
358
+
359
+ lt = symtorch.SymTorch()
360
+ texpr = lt.torchify(integral_expr)
361
+ re, im = texpr.torchquad_integrate(N=121)
362
+ ````
363
+
364
+ 2. Integral with parameters (e.g. Fourier transform)::
365
+
366
+ ````python
367
+ import symtorch
368
+ from sympy import symbols, Integral, exp, I, oo
369
+ import torch
370
+
371
+ x, k = symbols("x k", real=True)
372
+ integral_expr = Integral(exp(-x**2) * exp(I * k * x), (x, -oo, oo))
373
+
374
+ lt = symtorch.SymTorch()
375
+ texpr = lt.torchify(integral_expr)
376
+
377
+ # Evaluate at k = 0.5
378
+ re, im = texpr.torchquad_integrate(params_values=[0.5], N=121)
379
+ ````
380
+ """
381
+ from torchquad import Simpson
382
+
383
+ if method is None:
384
+ method = Simpson()
385
+
386
+ param_vals = list(params_values) if params_values else []
387
+
388
+ def _as_torch_scalar(value, *, device, dtype):
389
+ """Best-effort conversion of Python/numpy/mpmath/sympy scalars to torch tensors.
390
+
391
+ torch.as_tensor() cannot infer dtype for some scalar-like types
392
+ (e.g. mpmath.mpf). We coerce those to built-in complex/float.
393
+ """
394
+ if torch.is_tensor(value):
395
+ return value.to(device=device, dtype=dtype) if dtype is not None else value.to(device=device)
396
+
397
+ try:
398
+ return torch.as_tensor(value, device=device, dtype=dtype) if dtype is not None else torch.as_tensor(value, device=device)
399
+ except Exception:
400
+ # Fallback for types like mpmath.mpf/mpc, sympy Float, etc.
401
+ try:
402
+ # Avoid forcing a real dtype on complex values.
403
+ return torch.as_tensor(complex(value), device=device)
404
+ except Exception:
405
+ return (
406
+ torch.as_tensor(float(value), device=device, dtype=dtype)
407
+ if dtype is not None
408
+ else torch.as_tensor(float(value), device=device)
409
+ )
410
+
411
+ def integrand(domain_points):
412
+ points = domain_points.to(dtype=dtype) if dtype is not None else domain_points
413
+ param_vals_local = [
414
+ _as_torch_scalar(param_value, device=points.device, dtype=dtype)
415
+ for param_value in param_vals
416
+ ]
417
+ values = self.func(*[points[:, i] for i in range(points.shape[1])], *param_vals_local)
418
+ out = torch.as_tensor(values, device=points.device)
419
+ if dtype is not None and out.is_floating_point():
420
+ out = out.to(dtype=dtype)
421
+ return out
422
+
423
+ result = method.integrate(
424
+ integrand,
425
+ dim=self.dim,
426
+ N=N,
427
+ integration_domain=self.domain,
428
+ )
429
+ return (
430
+ result.real if torch.is_complex(result) else result,
431
+ result.imag if torch.is_complex(result) else torch.zeros_like(result),
432
+ )
433
+
434
+ def torch_integrate_batched(
435
+ self,
436
+ *,
437
+ params_values = None,
438
+ method: str | Callable = "simpson",
439
+ N: int = 121,
440
+ chunk_size_params: int = 256,
441
+ chunk_size_points: int | None = None,
442
+ device=None,
443
+ dtype=None,
444
+ ):
445
+ """Batched tensor-product quadrature integration for this ``TorchExpr``.
446
+
447
+ This method is intended for situations where you want to evaluate
448
+ the same integral for many different parameter values (for example,
449
+ on a 2D or 3D mesh).
450
+
451
+ Parameters
452
+ ----------
453
+ params_values : tensor | list | tuple
454
+ Numerical parameters. See ``torch_integrate_batched``
455
+ for a full description of the accepted shapes and types.
456
+ method : str or callable, optional
457
+ Quadrature rule name (``"simpson"``, ``"trapezoid"``,
458
+ ``"gauss-legendre"``) or a callable returning
459
+ ``(coords_1d, weights_1d)`` for ``(a, b, N)``.
460
+ N : int, optional
461
+ Odd number of Simpson points per dimension.
462
+ chunk_size_params : int, optional
463
+ Number of parameter points processed in one chunk. Reducing
464
+ this lowers peak memory usage at the cost of more Python loops.
465
+ chunk_size_points : int or None, optional
466
+ Maximum number of grid points processed per chunk. ``None``
467
+ means "all at once".
468
+ device : torch.device or None, optional
469
+ Device used for all internal tensors. If ``None``, CUDA is
470
+ used when available, otherwise CPU.
471
+ dtype : torch.dtype or None, optional
472
+ Floating dtype for internal computations. Defaults to
473
+ ``float32`` on CUDA and ``float64`` on CPU.
474
+
475
+ Returns
476
+ -------
477
+ re, im : torch.Tensor
478
+ Tensors with the same batch shape as the input parameters,
479
+ containing the real and imaginary parts of the integral.
480
+
481
+ USAGE
482
+ =====
483
+ 1. 1D integral without parameters (single value)::
484
+
485
+ ````python
486
+ import symtorch
487
+ from sympy import symbols, Integral, exp, oo
488
+
489
+ x = symbols("x", real=True)
490
+ integral_expr = Integral(exp(-x**2), (x, -oo, oo))
491
+
492
+ lt = symtorch.SymTorch()
493
+ texpr = lt.torchify(integral_expr)
494
+
495
+ # No parameters → pass ``None`` for ``params_values``
496
+ re, im = texpr.torch_integrate_batched(
497
+ params_values=None,
498
+ N=121,
499
+ )
500
+ ````
501
+
502
+ 2. 1D integral evaluated on a grid of parameter values::
503
+
504
+ ````python
505
+ import symtorch
506
+ from sympy import symbols, Integral, exp, I, oo
507
+ import torch
508
+
509
+ x, k = symbols("x k", real=True)
510
+ integral_expr = Integral(exp(-x**2) * exp(I * k * x), (x, -oo, oo))
511
+
512
+ lt = symtorch.SymTorch()
513
+ texpr = lt.torchify(integral_expr)
514
+
515
+ # Parameter grid: 250 points between -5 and 5
516
+ k_grid = torch.linspace(-5.0, 5.0, 250).unsqueeze(-1) # shape (250, 1)
517
+
518
+ re, im = texpr.torch_integrate_batched(
519
+ params_values=k_grid,
520
+ N=51,
521
+ chunk_size_params=64,
522
+ chunk_size_points=10_000,
523
+ )
524
+ ````
525
+ """
526
+ if self.dim <= 0:
527
+ raise ValueError(f"self.dim must be positive; got dim={self.dim}")
528
+ if self.n_params < 0:
529
+ raise ValueError(f"self.n_params must be non-negative; got n_params={self.n_params}")
530
+ if len(self.domain) != self.dim:
531
+ raise ValueError(f"self.domain must have length dim={self.dim}; got {len(self.domain)}")
532
+
533
+ if device is None:
534
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
535
+ if dtype is None:
536
+ dtype = torch.float32 if device.type == "cuda" else torch.float64
537
+
538
+ if chunk_size_params <= 0:
539
+ raise ValueError(f"chunk_size_params must be positive; got {chunk_size_params}")
540
+ if chunk_size_points is not None and chunk_size_points <= 0:
541
+ raise ValueError(f"chunk_size_points must be positive; got {chunk_size_points}")
542
+
543
+ params_flat, batch_shape = self._normalize_params_values(
544
+ params_values,
545
+ self.n_params,
546
+ device=device,
547
+ dtype=dtype,
548
+ )
549
+ batch_size = int(params_flat.shape[0])
550
+
551
+ rule = self._resolve_quadrature_rule(method)
552
+
553
+ coords_1d = []
554
+ weights_1d = []
555
+ for (lower_bound, upper_bound) in self.domain:
556
+ lower_bound = float(lower_bound)
557
+ upper_bound = float(upper_bound)
558
+ coords, weights = rule(lower_bound, upper_bound, N, device=device, dtype=dtype)
559
+ coords = torch.as_tensor(coords, device=device, dtype=dtype).reshape(-1)
560
+ weights = torch.as_tensor(weights, device=device, dtype=dtype).reshape(-1)
561
+ if coords.numel() != weights.numel():
562
+ raise ValueError(
563
+ f"Quadrature rule returned mismatched coordinates/weights lengths: {coords.numel()} vs {weights.numel()}"
564
+ )
565
+ coords_1d.append(coords)
566
+ weights_1d.append(weights)
567
+
568
+ if self.dim == 1:
569
+ grid = coords_1d[0].unsqueeze(1)
570
+ weights = weights_1d[0]
571
+ else:
572
+ grid = torch.cartesian_prod(*coords_1d)
573
+ weights = weights_1d[0]
574
+ for weight_1d in weights_1d[1:]:
575
+ weights = torch.kron(weights, weight_1d)
576
+ n_points = int(grid.shape[0])
577
+
578
+ if chunk_size_points is None:
579
+ chunk_size_points = n_points
580
+
581
+ re_out = torch.empty(batch_size, device=device, dtype=dtype)
582
+ im_out = torch.empty(batch_size, device=device, dtype=dtype)
583
+
584
+ for start_param in range(0, batch_size, chunk_size_params):
585
+ stop_param = min(batch_size, start_param + chunk_size_params)
586
+ param_chunk = params_flat[start_param:stop_param, :]
587
+ param_args = [param_chunk[:, index].unsqueeze(0) for index in range(self.n_params)]
588
+
589
+ re_acc = torch.zeros(stop_param - start_param, device=device, dtype=dtype)
590
+ im_acc = torch.zeros(stop_param - start_param, device=device, dtype=dtype)
591
+
592
+ for start_point in range(0, n_points, chunk_size_points):
593
+ stop_point = min(n_points, start_point + chunk_size_points)
594
+ grid_chunk = grid[start_point:stop_point, :]
595
+ weight_chunk = weights[start_point:stop_point].unsqueeze(1)
596
+
597
+ var_args = [grid_chunk[:, index].unsqueeze(1) for index in range(self.dim)]
598
+ values = self.func(*var_args, *param_args)
599
+ values = torch.as_tensor(values, device=device)
600
+
601
+ if values.ndim == 1:
602
+ values = values.unsqueeze(1)
603
+ elif (
604
+ values.ndim == 2
605
+ and values.shape[0] == (stop_param - start_param)
606
+ and values.shape[1] == (stop_point - start_point)
607
+ ):
608
+ values = values.t().contiguous()
609
+
610
+ if values.shape[0] != (stop_point - start_point) or values.shape[1] != (stop_param - start_param):
611
+ raise RuntimeError(
612
+ f"Unexpected integrand output shape {tuple(values.shape)}; expected ({stop_point - start_point}, {stop_param - start_param})"
613
+ )
614
+
615
+ if torch.is_complex(values):
616
+ re_acc += (weight_chunk * values.real).sum(dim=0)
617
+ im_acc += (weight_chunk * values.imag).sum(dim=0)
618
+ else:
619
+ re_acc += (weight_chunk * values).sum(dim=0)
620
+
621
+ re_out[start_param:stop_param] = re_acc
622
+ im_out[start_param:stop_param] = im_acc
623
+
624
+ re_out = re_out.reshape(batch_shape)
625
+ im_out = im_out.reshape(batch_shape)
626
+ return re_out, im_out
627
+
628
+ def torch_integrate_batched_simpson(
629
+ self,
630
+ *,
631
+ params_values = None,
632
+ N: int = 121,
633
+ chunk_size_params: int = 256,
634
+ chunk_size_points: int | None = None,
635
+ device=None,
636
+ dtype=None,
637
+ ):
638
+ """Backward-compatible wrapper for ``torch_integrate_batched(method='simpson')``."""
639
+ return self.torch_integrate_batched(
640
+ params_values=params_values,
641
+ method="simpson",
642
+ N=N,
643
+ chunk_size_params=chunk_size_params,
644
+ chunk_size_points=chunk_size_points,
645
+ device=device,
646
+ dtype=dtype,
647
+ )
648
+
649
+
650
+
651
+ class SymTorch:
652
+ def __init__(self):
653
+ pass
654
+
655
+ @staticmethod
656
+ def _normalize_cov_method(change_of_variables_method: str) -> str:
657
+ method = str(change_of_variables_method).strip().lower()
658
+ aliases = {
659
+ "tangent": "tangent",
660
+ "tan": "tangent",
661
+ "algebraic": "algebraic",
662
+ "rational": "algebraic",
663
+ "tanh-sinh": "tanh-sinh",
664
+ "tanh_sinh": "tanh-sinh",
665
+ "tanhsinh": "tanh-sinh",
666
+ }
667
+ if method not in aliases:
668
+ raise ValueError(
669
+ "Unknown change_of_variables_method. Expected one of {'tangent', 'algebraic', 'tanh-sinh'}. "
670
+ f"Got: {change_of_variables_method}"
671
+ )
672
+ return aliases[method]
673
+
674
+ @staticmethod
675
+ def _inset_open_interval(lower: float, upper: float, eps: float) -> list[float]:
676
+ return [float(lower + eps), float(upper - eps)]
677
+
678
+ def _transform_limit_with_method(self, variable, lower, upper, *, change_of_variables_method: str, eps: float):
679
+ """Return transformed variable, substitution, jacobian and finite domain."""
680
+ method = self._normalize_cov_method(change_of_variables_method)
681
+
682
+ if lower != -oo and upper != oo:
683
+ return variable, variable, Integer(1), [float(lower), float(upper)]
684
+
685
+ t_v = Symbol(f"t_{variable.name}", real=True)
686
+ open_interval = None
687
+
688
+ if method == "tangent":
689
+ if lower == -oo and upper == oo:
690
+ mapped = tan(t_v)
691
+ open_interval = (float(-pi / 2), float(pi / 2))
692
+ elif lower != -oo and upper == oo:
693
+ mapped = lower + tan(t_v) ** 2
694
+ open_interval = (0.0, float(pi / 2))
695
+ elif lower == -oo and upper != oo:
696
+ mapped = upper - tan(t_v) ** 2
697
+ open_interval = (0.0, float(pi / 2))
698
+ else:
699
+ raise ValueError("Unexpected limit pattern in tangent transform")
700
+
701
+ elif method == "algebraic":
702
+ if lower == -oo and upper == oo:
703
+ mapped = t_v / (1 - t_v ** 2)
704
+ open_interval = (-1.0, 1.0)
705
+ elif lower != -oo and upper == oo:
706
+ mapped = lower + t_v / (1 - t_v)
707
+ open_interval = (0.0, 1.0)
708
+ elif lower == -oo and upper != oo:
709
+ mapped = upper - t_v / (1 - t_v)
710
+ open_interval = (0.0, 1.0)
711
+ else:
712
+ raise ValueError("Unexpected limit pattern in algebraic transform")
713
+
714
+ elif method == "tanh-sinh":
715
+ # Finite-interval parameter t in (-1,1) or (0,1) mapped through sinh(atanh(t)).
716
+ core = sinh(atanh(t_v))
717
+ if lower == -oo and upper == oo:
718
+ mapped = core
719
+ open_interval = (-1.0, 1.0)
720
+ elif lower != -oo and upper == oo:
721
+ mapped = lower + core ** 2
722
+ open_interval = (0.0, 1.0)
723
+ elif lower == -oo and upper != oo:
724
+ mapped = upper - core ** 2
725
+ open_interval = (0.0, 1.0)
726
+ else:
727
+ raise ValueError("Unexpected limit pattern in tanh-sinh transform")
728
+
729
+ else:
730
+ raise ValueError(f"Unsupported change_of_variables_method: {method}")
731
+
732
+ jacobian = diff(mapped, t_v)
733
+ domain = self._inset_open_interval(open_interval[0], open_interval[1], eps)
734
+ return t_v, mapped, jacobian, domain
735
+
736
+ def _merge_lambdify_modules(self, modules, extra_mapping):
737
+ """Merge lambdify modules with an extra mapping dict at highest priority."""
738
+ if not extra_mapping:
739
+ return modules
740
+
741
+ if modules is None:
742
+ modules = self._default_modules()
743
+
744
+ if isinstance(modules, (list, tuple)):
745
+ merged = list(modules)
746
+ merged.insert(0, dict(extra_mapping))
747
+ return merged
748
+
749
+ return [dict(extra_mapping), modules]
750
+
751
+ def _build_nested_definite_integral_callable(self, texpr: TorchExpr, n_params: int, *, inner_N: int = 61):
752
+ """Create a callable for lambdify that evaluates a definite nested integral numerically."""
753
+
754
+ def eval_nested(*args):
755
+ if len(args) != n_params:
756
+ raise ValueError(f"Expected {n_params} nested integral params; got {len(args)}")
757
+
758
+ if n_params == 0:
759
+ re_val, im_val = texpr.torchquad_integrate(N=inner_N)
760
+ re_tensor = torch.as_tensor(re_val)
761
+ im_tensor = torch.as_tensor(im_val, device=re_tensor.device)
762
+ return re_tensor + 1j * im_tensor
763
+
764
+ args_tensors = [torch.as_tensor(arg) for arg in args]
765
+ broadcasted = torch.broadcast_tensors(*args_tensors)
766
+ target_device = broadcasted[0].device
767
+ batch_shape = tuple(broadcasted[0].shape)
768
+ flat_params = [tensor.reshape(-1) for tensor in broadcasted]
769
+ batch_size = int(flat_params[0].shape[0])
770
+
771
+ out_re = []
772
+ out_im = []
773
+ for idx in range(batch_size):
774
+ params_i = [flat_params[param_index][idx] for param_index in range(n_params)]
775
+ re_val, im_val = texpr.torchquad_integrate(params_values=params_i, N=inner_N)
776
+ out_re.append(torch.as_tensor(re_val, device=target_device))
777
+ out_im.append(torch.as_tensor(im_val, device=target_device))
778
+
779
+ re_tensor = torch.stack(out_re).reshape(batch_shape)
780
+ im_tensor = torch.stack(out_im).reshape(batch_shape).to(device=re_tensor.device)
781
+ return re_tensor + 1j * im_tensor
782
+
783
+ return eval_nested
784
+
785
+ def _replace_nested_definite_integrals(
786
+ self,
787
+ expr,
788
+ *,
789
+ inner_N: int = 61,
790
+ change_of_variables_method: str = "tangent",
791
+ cov_eps: float = 1e-7,
792
+ ):
793
+ """Replace nested definite Integrals with numeric callables.
794
+
795
+ Any nested integral with non-definite limits triggers a ValueError.
796
+ """
797
+ if not hasattr(expr, "has") or not expr.has(Integral):
798
+ return expr, {}
799
+
800
+ expr_work = expr
801
+ nested_mapping = {}
802
+ counter = 0
803
+
804
+ while expr_work.has(Integral):
805
+ leaf_integrals = [itg for itg in expr_work.atoms(Integral) if not itg.function.has(Integral)]
806
+ if not leaf_integrals:
807
+ break
808
+
809
+ for inner_integral in leaf_integrals:
810
+ for lim in inner_integral.limits:
811
+ if len(lim) != 3:
812
+ raise ValueError(
813
+ "The integrand must not contain unevaluated indefinite integrals. "
814
+ f"Found nested integral with non-definite limit: {inner_integral}"
815
+ )
816
+
817
+ integration_vars = [lim[0] for lim in inner_integral.limits]
818
+ param_symbols = sorted(
819
+ list(inner_integral.free_symbols - set(integration_vars)),
820
+ key=lambda symbol: symbol.name,
821
+ )
822
+
823
+ nested_texpr = self.torchify(
824
+ inner_integral,
825
+ change_of_variables_method=change_of_variables_method,
826
+ cov_eps=cov_eps,
827
+ )
828
+ nested_name = f"_nested_definite_integral_{counter}"
829
+ counter += 1
830
+
831
+ nested_mapping[nested_name] = self._build_nested_definite_integral_callable(
832
+ nested_texpr,
833
+ len(param_symbols),
834
+ inner_N=inner_N,
835
+ )
836
+
837
+ placeholder = Function(nested_name)(*param_symbols)
838
+ expr_work = expr_work.xreplace({inner_integral: placeholder})
839
+
840
+ if expr_work.has(Integral):
841
+ raise ValueError(
842
+ "The integrand must not contain unevaluated integrals. "
843
+ "Only nested definite integrals are supported."
844
+ )
845
+
846
+ return expr_work, nested_mapping
847
+
848
+ def _default_modules(self):
849
+ """Internal helper: default SymPy → torch mapping for ``lambdify``.
850
+
851
+ The returned list is passed directly to SymPy's ``lambdify`` as the
852
+ ``modules`` argument. It exposes a minimal subset of functions
853
+ implemented with torch operations so that the resulting numerical
854
+ function is differentiable and GPU-friendly where possible.
855
+
856
+ Keeping this mapping in a separate method avoids cluttering
857
+ :meth:`torchify` and makes it easy to customize or extend in user
858
+ code by subclassing ``SymTorch``.
859
+ """
860
+
861
+ def safe_sqrt(x):
862
+ return torch.sqrt(torch.as_tensor(x, dtype=torch.float64))
863
+
864
+ def safe_erf(x):
865
+ """Error function that supports real torch tensors and complex inputs."""
866
+ if torch.is_tensor(x):
867
+ if torch.is_complex(x):
868
+ # torch.erf is not implemented for complex tensors on CPU.
869
+ out = scipy_special.erf(x.detach().cpu().numpy())
870
+ return torch.as_tensor(out, device=x.device, dtype=x.dtype)
871
+ return torch.erf(x)
872
+ return scipy_special.erf(x)
873
+
874
+ def safe_erfc(x):
875
+ """Complementary error function with complex-input fallback."""
876
+ if torch.is_tensor(x):
877
+ if torch.is_complex(x):
878
+ out = scipy_special.erfc(x.detach().cpu().numpy())
879
+ return torch.as_tensor(out, device=x.device, dtype=x.dtype)
880
+ return torch.erfc(x)
881
+ return scipy_special.erfc(x)
882
+
883
+ return [{
884
+ # Trigonometric
885
+ "sin": torch.sin, "cos": torch.cos, "tan": torch.tan,
886
+ "asin": torch.asin, "acos": torch.acos, "atan": torch.atan,
887
+ "atan2": torch.atan2,
888
+ # Hyperbolic
889
+ "sinh": torch.sinh, "cosh": torch.cosh, "tanh": torch.tanh,
890
+ "asinh": torch.asinh, "acosh": torch.acosh, "atanh": torch.atanh,
891
+ # Exponentials / logs
892
+ "exp": torch.exp, "log": torch.log, "ln": torch.log,
893
+ "log10": torch.log10, "log2": torch.log2,
894
+ # Special functions used by symbolic integral evaluation
895
+ "erf": safe_erf, "erfc": safe_erfc,
896
+ # Roots / powers
897
+ "sqrt": safe_sqrt, "Pow": torch.pow,
898
+ # Misc
899
+ "Abs": torch.abs, "sign": torch.sign,
900
+ "conjugate": torch.conj, "conj": torch.conj,
901
+ "floor": torch.floor, "ceiling": torch.ceil,
902
+ "Min": torch.minimum, "Max": torch.maximum,
903
+ # Piecewise / heaviside
904
+ "Heaviside": lambda x: torch.heaviside(x, torch.zeros_like(x)),
905
+ # Constants
906
+ "pi": getattr(torch, "pi", float(np.pi)),
907
+ "E": float(np.e),
908
+ }]
909
+
910
+ def torchify(
911
+ self,
912
+ expr,
913
+ *,
914
+ variables=None,
915
+ limits=None,
916
+ params=None,
917
+ modules=None,
918
+ change_of_variables_method: str = "tangent",
919
+ cov_eps: float = 1e-7,
920
+ ):
921
+ """Convert a SymPy object into a torch-compatible callable or TorchExpr.
922
+
923
+ This mirrors the original functional ``torchify`` but lives as a
924
+ method on ``SymTorch``. It performs change-of-variables for
925
+ infinite / semi-infinite limits at the SymPy level (once), then
926
+ ``lambdify``'s the transformed expression.
927
+
928
+ ``expr`` can be:
929
+
930
+ - a plain SymPy expression (e.g. ``exp(-x**2)``),
931
+ - a SymPy ``Integral`` (``Integral(f(x), (x, -oo, oo))``), or
932
+ - a SymPy ``Eq`` whose right-hand side is an ``Integral``.
933
+
934
+ If an ``Integral`` (or equation with an integral) is passed, the
935
+ integrand and limits are extracted internally, so you no longer
936
+ need to manually use ``.function`` / ``.limits``.
937
+
938
+ Parameters
939
+ ----------
940
+ expr : sympy.Expr | sympy.Integral | sympy.Eq
941
+ Symbolic expression (may be complex-valued) or an integral.
942
+ variables : list[sympy.Symbol] or None
943
+ Integration variables (order matters). If ``expr`` is an
944
+ ``Integral`` and ``variables`` is ``None``, they are inferred
945
+ from the integral limits.
946
+ limits : list[tuple] or None
947
+ ``(var, lower, upper)`` for each variable. ``oo`` / ``-oo``
948
+ trigger automatic change of variables. If ``expr`` is an
949
+ ``Integral`` and ``limits`` is ``None``, they are taken from
950
+ ``expr.limits``.
951
+ params : list[sympy.Symbol] or None
952
+ Extra symbolic parameters that appear in the transformed
953
+ expression but are **not** integrated over. If ``None``, they
954
+ are inferred automatically from the free symbols.
955
+ modules : list[dict] or None
956
+ Custom ``lambdify`` module list. ``None`` → default torch
957
+ mapping from ``_default_modules``.
958
+ change_of_variables_method : str, optional
959
+ Method used to transform infinite/semi-infinite domains to finite
960
+ ones. Supported values: ``"tangent"``, ``"algebraic"``,
961
+ ``"tanh-sinh"``.
962
+ cov_eps : float, optional
963
+ Small inward shift for transformed open intervals to avoid
964
+ endpoint singular evaluations.
965
+
966
+ Returns
967
+ -------
968
+ TorchExpr
969
+ If limits are provided (directly or via an ``Integral``), ready
970
+ for numerical integration.
971
+ callable
972
+ If ``limits`` is ``None``, a plain torch-ready function
973
+ (no integration).
974
+
975
+ USAGE
976
+ =====
977
+ 1. Plain function (no integration)::
978
+
979
+ ````python
980
+ import symtorch
981
+ from sympy import symbols, exp
982
+
983
+ x = symbols("x", real=True)
984
+
985
+ lt = symtorch.SymTorch()
986
+ f = lt.torchify(exp(-x**2), variables=[x])
987
+ ````
988
+
989
+ 2. Integral with finite limits::
990
+
991
+ ````python
992
+ import symtorch
993
+ from sympy import symbols, Integral, exp
994
+
995
+ x = symbols("x", real=True)
996
+ integral = Integral(exp(-x**2), (x, 0, 1))
997
+
998
+ lt = symtorch.SymTorch()
999
+ texpr = lt.torchify(integral)
1000
+ ````
1001
+
1002
+ 3. Integral with infinite limits (automatic change of variables)::
1003
+
1004
+ ````python
1005
+ import symtorch
1006
+ from sympy import symbols, Integral, exp, oo
1007
+
1008
+ x = symbols("x", real=True)
1009
+ integral = Integral(exp(-x**2), (x, -oo, oo))
1010
+
1011
+ lt = symtorch.SymTorch()
1012
+ texpr = lt.torchify(integral)
1013
+ ````
1014
+
1015
+ 4. Equation with integral on the right-hand side::
1016
+
1017
+ ````python
1018
+ import symtorch
1019
+ from sympy import symbols, Eq, Integral, exp, oo
1020
+
1021
+ x, y = symbols("x y", real=True)
1022
+ eq = Eq(y, Integral(exp(-x**2), (x, -oo, oo)))
1023
+
1024
+ lt = symtorch.SymTorch()
1025
+ texpr = lt.torchify(eq) # integral auto-extracted
1026
+ ````
1027
+ """
1028
+
1029
+ def _absorb_scalar_mul_into_integral(expr_candidate):
1030
+ """Rewrite `prefactor * Integral(...)` as `Integral(prefactor*integrand, ...)`.
1031
+
1032
+ This is a convenience for common patterns like `Integral(f, ...)/(2*pi)`
1033
+ which SymPy stores as `Mul(Integral(...), 1/(2*pi))`.
1034
+
1035
+ The rewrite is conservative: the prefactor is only absorbed when
1036
+ it is independent of the integral's dummy variables.
1037
+ """
1038
+
1039
+ if not isinstance(expr_candidate, Mul):
1040
+ return expr_candidate
1041
+
1042
+ integral_factors = [arg for arg in expr_candidate.args if isinstance(arg, Integral)]
1043
+ if len(integral_factors) != 1:
1044
+ return expr_candidate
1045
+
1046
+ integral_factor = integral_factors[0]
1047
+ prefactor = Mul(*[arg for arg in expr_candidate.args if arg is not integral_factor])
1048
+
1049
+ dummy_vars = [lim[0] for lim in integral_factor.limits]
1050
+ if not prefactor.free_symbols.isdisjoint(set(dummy_vars)):
1051
+ return expr_candidate
1052
+
1053
+ return Integral(prefactor * integral_factor.function, *integral_factor.limits)
1054
+
1055
+ # Detect integral / equation-with-integral and extract base expr/limits.
1056
+ integral = None
1057
+ if isinstance(expr, Integral):
1058
+ integral = expr
1059
+ elif isinstance(expr, Eq):
1060
+ if isinstance(expr.rhs, Integral):
1061
+ integral = expr.rhs
1062
+ else:
1063
+ rhs2 = _absorb_scalar_mul_into_integral(expr.rhs)
1064
+ if isinstance(rhs2, Integral):
1065
+ integral = rhs2
1066
+ else:
1067
+ expr2 = _absorb_scalar_mul_into_integral(expr)
1068
+ if isinstance(expr2, Integral):
1069
+ integral = expr2
1070
+
1071
+ if integral is not None:
1072
+ base_expr = integral.function
1073
+ nested_modules = {}
1074
+ if base_expr.has(Integral):
1075
+ # Evaluate nested definite integrals numerically using torchquad.
1076
+ base_expr, nested_modules = self._replace_nested_definite_integrals(
1077
+ base_expr,
1078
+ change_of_variables_method=change_of_variables_method,
1079
+ cov_eps=cov_eps,
1080
+ )
1081
+ if limits is None:
1082
+ limits = list(integral.limits)
1083
+ if variables is None and limits is not None:
1084
+ variables = [lim[0] for lim in limits]
1085
+ else:
1086
+ base_expr = expr
1087
+ nested_modules = {}
1088
+
1089
+ if variables is None:
1090
+ raise ValueError("'variables' must be provided when expr is not an Integral")
1091
+
1092
+ if modules is None:
1093
+ modules = self._default_modules()
1094
+ modules = self._merge_lambdify_modules(modules, nested_modules)
1095
+
1096
+ variables = list(variables)
1097
+
1098
+ # ------------------------------------------------------------------
1099
+ # Simple case: no limits → plain lambdify
1100
+ # ------------------------------------------------------------------
1101
+ if limits is None:
1102
+ return lambdify(tuple(variables), base_expr, modules=modules)
1103
+
1104
+ # ------------------------------------------------------------------
1105
+ # With limits: symbolic change of variables (done once, fast forever)
1106
+ # ------------------------------------------------------------------
1107
+ limit_map = {lim[0]: (lim[1], lim[2]) for lim in limits}
1108
+
1109
+ new_vars = []
1110
+ domain = []
1111
+ subs_map = {}
1112
+ jacobian = Integer(1)
1113
+
1114
+ for v in variables:
1115
+ lower, upper = limit_map[v]
1116
+ t_var, mapped_expr, jac_expr, domain_entry = self._transform_limit_with_method(
1117
+ v,
1118
+ lower,
1119
+ upper,
1120
+ change_of_variables_method=change_of_variables_method,
1121
+ eps=cov_eps,
1122
+ )
1123
+ new_vars.append(t_var)
1124
+ domain.append(domain_entry)
1125
+ if t_var != v:
1126
+ subs_map[v] = mapped_expr
1127
+ jacobian *= jac_expr
1128
+
1129
+ expr_work = base_expr.subs(subs_map) * jacobian
1130
+
1131
+ if params is None:
1132
+ params = sorted(
1133
+ list(expr_work.free_symbols - set(new_vars)),
1134
+ key=lambda s: s.name,
1135
+ )
1136
+ else:
1137
+ params = list(params)
1138
+
1139
+ # Lambdify: new_vars first, then params
1140
+ arglist = tuple(new_vars + params)
1141
+ func = lambdify(arglist, expr_work, modules=modules)
1142
+
1143
+ return TorchExpr(
1144
+ func=func,
1145
+ domain=domain,
1146
+ dim=len(new_vars),
1147
+ n_params=len(params),
1148
+ sympy_expr=expr_work,
1149
+ variables=new_vars,
1150
+ )
1151
+
1152
+
1153
+ # ---------------------------------------------------------------------------
1154
+ # Functional compatibility helpers
1155
+ # ---------------------------------------------------------------------------
1156
+ def torchify(
1157
+ expr,
1158
+ variables=None,
1159
+ limits=None,
1160
+ params=None,
1161
+ modules=None,
1162
+ change_of_variables_method: str = "tangent",
1163
+ cov_eps: float = 1e-7,
1164
+ ):
1165
+ """Convert a SymPy object into a torch-compatible callable or ``TorchExpr``.
1166
+
1167
+ This functional wrapper is kept for backward compatibility with the
1168
+ original ``SymTorch`` API. It delegates to ``SymTorch().torchify(...)``
1169
+ so the functional and object-oriented styles share the same core logic.
1170
+
1171
+ Parameters
1172
+ ----------
1173
+ expr : sympy.Expr | sympy.Integral | sympy.Eq
1174
+ Symbolic expression or integral-like SymPy object.
1175
+ variables : list[sympy.Symbol] or None, optional
1176
+ Variables passed through to ``SymTorch.torchify``.
1177
+ limits : list[tuple] or None, optional
1178
+ Integration limits passed through to ``SymTorch.torchify``.
1179
+ params : list[sympy.Symbol] or None, optional
1180
+ Additional symbolic parameters.
1181
+ modules : list[dict] or None, optional
1182
+ Custom ``lambdify`` modules mapping.
1183
+
1184
+ Returns
1185
+ -------
1186
+ TorchExpr | callable
1187
+ Same return contract as ``SymTorch().torchify(...)``.
1188
+
1189
+ USAGE
1190
+ =====
1191
+ 1. Functional style kept in parallel with the OOP API::
1192
+
1193
+ ````python
1194
+ from symtorch import torchify
1195
+ from sympy import symbols, Integral, exp, oo
1196
+
1197
+ x = symbols("x", real=True)
1198
+ integral_expr = Integral(exp(-x**2), (x, -oo, oo))
1199
+
1200
+ texpr = torchify(integral_expr)
1201
+ ````
1202
+ """
1203
+ return SymTorch().torchify(
1204
+ expr,
1205
+ variables=variables,
1206
+ limits=limits,
1207
+ params=params,
1208
+ modules=modules,
1209
+ change_of_variables_method=change_of_variables_method,
1210
+ cov_eps=cov_eps,
1211
+ )
1212
+
1213
+
1214
+ def torchquad_integrate(texpr: TorchExpr, params_values=None, method=None, N: int = 21, dtype=None):
1215
+ """Numerically integrate a ``TorchExpr`` using torchquad.
1216
+
1217
+ This functional wrapper is kept for backward compatibility with the
1218
+ original ``SymTorch`` API. New code can call
1219
+ :meth:`TorchExpr.torchquad_integrate` directly.
1220
+
1221
+ Parameters
1222
+ ----------
1223
+ texpr : TorchExpr
1224
+ Object returned by ``SymTorch().torchify(..., limits=...)``.
1225
+ params_values : list | tuple | torch.Tensor | None, optional
1226
+ Numerical parameter values in the same order expected by
1227
+ ``texpr.func``.
1228
+ method : torchquad integrator instance or None, optional
1229
+ If ``None``, a default ``Simpson`` integrator is created.
1230
+ N : int, optional
1231
+ Resolution parameter passed to torchquad.
1232
+ dtype : torch.dtype or None, optional
1233
+ Floating dtype used for internal computations. Passed through to
1234
+ :meth:`TorchExpr.torchquad_integrate`.
1235
+
1236
+ Returns
1237
+ -------
1238
+ re, im : torch.Tensor
1239
+ Real and imaginary parts of the integral.
1240
+
1241
+ USAGE
1242
+ =====
1243
+ 1. Functional style kept in parallel with the OOP API::
1244
+
1245
+ ````python
1246
+ import symtorch
1247
+ from symtorch import torchquad_integrate
1248
+ from sympy import symbols, Integral, exp, oo
1249
+
1250
+ x = symbols("x", real=True)
1251
+ integral_expr = Integral(exp(-x**2), (x, -oo, oo))
1252
+
1253
+ lt = symtorch.SymTorch()
1254
+ texpr = lt.torchify(integral_expr)
1255
+ re, im = torchquad_integrate(texpr, N=121)
1256
+ ````
1257
+ """
1258
+ return texpr.torchquad_integrate(params_values=params_values, method=method, N=N, dtype=dtype)
1259
+
1260
+
1261
+ def _simpson_weights_1d(a: float, b: float, N: int, *, device=None, dtype=None) -> torch.Tensor:
1262
+ """Backward-compatible Simpson weights wrapper."""
1263
+ _coords, weights = TorchExpr._rule_simpson(a, b, N, device=device, dtype=dtype)
1264
+ return weights
1265
+
1266
+
1267
+ def _is_scalar_like(x) -> bool:
1268
+ """Functional wrapper around ``TorchExpr._is_scalar_like``."""
1269
+ return TorchExpr._is_scalar_like(x)
1270
+
1271
+
1272
+ def _normalize_params_values(params_values, n_params: int, *, device, dtype):
1273
+ """Functional wrapper around ``TorchExpr._normalize_params_values``."""
1274
+ return TorchExpr._normalize_params_values(params_values, n_params, device=device, dtype=dtype)
1275
+
1276
+
1277
+ def torch_integrate_batched(
1278
+ texpr: TorchExpr,
1279
+ params_values=None,
1280
+ *,
1281
+ method: str | Callable = "simpson",
1282
+ N: int = 121,
1283
+ chunk_size_params: int = 256,
1284
+ chunk_size_points: int | None = None,
1285
+ device=None,
1286
+ dtype=None,
1287
+ ):
1288
+ """Batched tensor-product quadrature integration for any dimension.
1289
+
1290
+ This functional wrapper is kept so existing code that used the
1291
+ original function-based API continues to work. New code can call
1292
+ :meth:`TorchExpr.torch_integrate_batched` directly.
1293
+
1294
+ Parameters
1295
+ ----------
1296
+ texpr : TorchExpr
1297
+ Object returned by ``SymTorch().torchify(..., limits=...)``.
1298
+ params_values : tensor | list | tuple | None
1299
+ Numerical parameter values.
1300
+ N : int, optional
1301
+ Odd number of Simpson points per dimension.
1302
+ chunk_size_params : int, optional
1303
+ Number of parameter points processed per chunk.
1304
+ chunk_size_points : int or None, optional
1305
+ Number of sample points processed per chunk.
1306
+ device : torch.device or None, optional
1307
+ Device used for internal tensors.
1308
+ dtype : torch.dtype or None, optional
1309
+ Floating dtype used for internal tensors.
1310
+
1311
+ Returns
1312
+ -------
1313
+ re, im : torch.Tensor
1314
+ Real and imaginary parts of the integral.
1315
+
1316
+ USAGE
1317
+ =====
1318
+ 1. Functional style kept in parallel with the OOP API::
1319
+
1320
+ ````python
1321
+ import symtorch
1322
+ from symtorch import torch_integrate_batched_simpson
1323
+ from sympy import symbols, Integral, exp, oo
1324
+
1325
+ x = symbols("x", real=True)
1326
+ integral_expr = Integral(exp(-x**2), (x, -oo, oo))
1327
+
1328
+ lt = symtorch.SymTorch()
1329
+ texpr = lt.torchify(integral_expr)
1330
+ re, im = torch_integrate_batched_simpson(texpr, params_values=None, N=121)
1331
+ ````
1332
+ """
1333
+ return texpr.torch_integrate_batched(
1334
+ params_values=params_values,
1335
+ method=method,
1336
+ N=N,
1337
+ chunk_size_params=chunk_size_params,
1338
+ chunk_size_points=chunk_size_points,
1339
+ device=device,
1340
+ dtype=dtype,
1341
+ )
1342
+
1343
+
1344
+ def torch_integrate_batched_simpson(
1345
+ texpr: TorchExpr,
1346
+ params_values=None,
1347
+ *,
1348
+ N: int = 121,
1349
+ chunk_size_params: int = 256,
1350
+ chunk_size_points: int | None = None,
1351
+ device=None,
1352
+ dtype=None,
1353
+ ):
1354
+ """Backward-compatible wrapper for ``torch_integrate_batched(method='simpson')``."""
1355
+ return texpr.torch_integrate_batched(
1356
+ params_values=params_values,
1357
+ method="simpson",
1358
+ N=N,
1359
+ chunk_size_params=chunk_size_params,
1360
+ chunk_size_points=chunk_size_points,
1361
+ device=device,
1362
+ dtype=dtype,
1363
+ )