FastLSQ 0.2.2__tar.gz → 0.2.4__tar.gz

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.
Files changed (109) hide show
  1. {fastlsq-0.2.2 → fastlsq-0.2.4}/CHANGELOG.md +54 -0
  2. {fastlsq-0.2.2 → fastlsq-0.2.4}/FastLSQ.egg-info/PKG-INFO +1 -1
  3. {fastlsq-0.2.2 → fastlsq-0.2.4}/FastLSQ.egg-info/SOURCES.txt +1 -0
  4. {fastlsq-0.2.2 → fastlsq-0.2.4}/PKG-INFO +1 -1
  5. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/__init__.py +1 -1
  6. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/api.py +29 -9
  7. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/linalg.py +46 -7
  8. {fastlsq-0.2.2 → fastlsq-0.2.4}/pyproject.toml +1 -1
  9. fastlsq-0.2.4/tests/test_benchmarks_inverse.py +144 -0
  10. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_vector_basis.py +1 -1
  11. {fastlsq-0.2.2 → fastlsq-0.2.4}/FastLSQ.egg-info/dependency_links.txt +0 -0
  12. {fastlsq-0.2.2 → fastlsq-0.2.4}/FastLSQ.egg-info/requires.txt +0 -0
  13. {fastlsq-0.2.2 → fastlsq-0.2.4}/FastLSQ.egg-info/top_level.txt +0 -0
  14. {fastlsq-0.2.2 → fastlsq-0.2.4}/LICENSE +0 -0
  15. {fastlsq-0.2.2 → fastlsq-0.2.4}/MANIFEST.in +0 -0
  16. {fastlsq-0.2.2 → fastlsq-0.2.4}/README.md +0 -0
  17. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/add_your_own_pde.py +0 -0
  18. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/benchmark_comparison.py +0 -0
  19. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/custom_features.py +0 -0
  20. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/fred_sde.py +0 -0
  21. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/fred_sde_fastlsq.py +0 -0
  22. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/gaia_potential.py +0 -0
  23. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/gaia_potential_fastlsq.py +0 -0
  24. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/horizons_ephemeris.py +0 -0
  25. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/numerai_alpha.py +0 -0
  26. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/numerai_alpha_fastlsq.py +0 -0
  27. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/run_all_fastlsq.py +0 -0
  28. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/__init__.py +0 -0
  29. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/_alsu_lattice.py +0 -0
  30. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/_common.py +0 -0
  31. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/run_all.py +0 -0
  32. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
  33. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
  34. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_green_fff.py +0 -0
  35. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
  36. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
  37. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
  38. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_passive_loco.py +0 -0
  39. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
  40. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
  41. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
  42. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
  43. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_tides_3months.py +0 -0
  44. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
  45. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s01_visualize.py +0 -0
  46. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
  47. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
  48. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s04_sunspots.py +0 -0
  49. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s05_helioseismology.py +0 -0
  50. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s06_tides.py +0 -0
  51. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
  52. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
  53. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
  54. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
  55. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
  56. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
  57. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
  58. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s14_eeg.py +0 -0
  59. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/scenarios/s15_circadian.py +0 -0
  60. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/extras/spectral_expansion.py +0 -0
  61. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/grad_shafranov.py +0 -0
  62. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/grid_inverse.py +0 -0
  63. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/grid_rl_control.py +0 -0
  64. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/grid_swing.py +0 -0
  65. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/gs_inverse.py +0 -0
  66. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/gs_rl_control.py +0 -0
  67. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/inverse_heat_source.py +0 -0
  68. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/inverse_magnetostatics.py +0 -0
  69. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/inverse_source_position.py +0 -0
  70. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/learnable_helmholtz.py +0 -0
  71. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/orbit_hill.py +0 -0
  72. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/orbit_inverse.py +0 -0
  73. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/orbit_rl.py +0 -0
  74. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/pde_discovery.py +0 -0
  75. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/run_all_extensions.py +0 -0
  76. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/run_linear.py +0 -0
  77. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/run_nonlinear.py +0 -0
  78. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/tutorial_basic.py +0 -0
  79. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/tutorial_nonlinear.py +0 -0
  80. {fastlsq-0.2.2 → fastlsq-0.2.4}/examples/vector_basis_stream_vorticity.py +0 -0
  81. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/basis.py +0 -0
  82. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/block.py +0 -0
  83. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/device.py +0 -0
  84. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/diagnostics.py +0 -0
  85. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/export.py +0 -0
  86. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/geometry.py +0 -0
  87. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/learnable.py +0 -0
  88. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/lightning.py +0 -0
  89. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/newton.py +0 -0
  90. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/plotting.py +0 -0
  91. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/problems/__init__.py +0 -0
  92. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/problems/linear.py +0 -0
  93. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/problems/nonlinear.py +0 -0
  94. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/problems/regression.py +0 -0
  95. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/solvers.py +0 -0
  96. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/tuning.py +0 -0
  97. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/utils.py +0 -0
  98. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/vector.py +0 -0
  99. {fastlsq-0.2.2 → fastlsq-0.2.4}/fastlsq/viz.py +0 -0
  100. {fastlsq-0.2.2 → fastlsq-0.2.4}/requirements.txt +0 -0
  101. {fastlsq-0.2.2 → fastlsq-0.2.4}/setup.cfg +0 -0
  102. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_basic.py +0 -0
  103. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_block.py +0 -0
  104. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_derivatives.py +0 -0
  105. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_device.py +0 -0
  106. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_grad_shafranov.py +0 -0
  107. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_grid_swing.py +0 -0
  108. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_learnable.py +0 -0
  109. {fastlsq-0.2.2 → fastlsq-0.2.4}/tests/test_orbit_hill.py +0 -0
@@ -2,6 +2,60 @@
2
2
 
3
3
  All notable changes to FastLSQ will be documented in this file.
4
4
 
5
+ ## [0.2.4] - 2026-06-04
6
+
7
+ ### Added
8
+
9
+ - **Benchmark + inverse-problem test suite** (`tests/test_benchmarks_inverse.py`):
10
+ 12 deterministic smoke tests (~11 s) that solve the linear (`PoissonND`,
11
+ `HeatND`, `Wave1D`, `Helmholtz2D`, `Maxwell2D_TM`) and nonlinear
12
+ (`NLPoisson2D`, `Bratu2D`, `SteadyBurgers1D`, `NLHelmholtz2D`, `AllenCahn1D`)
13
+ benchmark equations through the public `solve_linear` / `solve_nonlinear` API,
14
+ plus two inverse pipelines -- Gaussian source-position recovery (forward solve
15
+ + L-BFGS) and SINDy-style PDE discovery via analytical derivatives --
16
+ exercising the 0.2.3 QR / N-scaled-collocation solver path end to end.
17
+
18
+ ### Known issues
19
+
20
+ - `Wave2D_MS` does not solve via `solve_linear` (relative error 1.0 in every
21
+ configuration tested), and `ElasticWave2D` -- a 2-output vector problem whose
22
+ `exact()` returns `(N, 2)` -- never sets `n_outputs`, so the scalar API cannot
23
+ unpack it. Both are pre-existing problem-definition gaps, independent of the
24
+ solver work, and are excluded from the new smoke test pending a fix.
25
+
26
+ ## [0.2.3] - 2026-06-04
27
+
28
+ ### Added
29
+
30
+ - **Householder-QR least-squares back-end** `solve_lstsq(..., method="qr")`:
31
+ backward-stable at `cond(A)` (ridge applied via the `[A; sqrt(mu) I]`
32
+ augmentation, not the normal equations), giving SVD-grade accuracy (~1e-14 on
33
+ the Helmholtz random-feature benchmark) at QR cost -- and, on the
34
+ rank-deficient CPU/no-ridge path, faster than the `gelsd` `"svd"` driver too,
35
+ while far more accurate than the normal-equations `"cholesky"` (no `cond(A)`
36
+ squaring, no required ridge). Assumes the system is numerically full column
37
+ rank; `"svd"` remains the rank-deficient-safe reference.
38
+ - **`solve_linear(..., method=...)`**: the linear solve back-end is now
39
+ selectable from the high-level API (`"auto"`, `"qr"`, `"svd"`, `"cholesky"`,
40
+ `"rsvd"`; defaults to `"auto"`).
41
+
42
+ ### Changed
43
+
44
+ - **`method="auto"` now tries QR before SVD.** After the Cholesky conditioning
45
+ probe rejects the fast path, `auto` uses the faster, more accurate QR solve and
46
+ falls back to the rank-revealing SVD only when QR's solution blows up
47
+ (`||x|| / (1 + ||b||)` above a generous guard). Real PDE systems measure
48
+ `<= 0.3` and keep QR; genuinely rank-deficient *inconsistent* systems (e.g. a
49
+ random RHS) measure ~3e14 and route to SVD. Net: the default solve is faster
50
+ and at least as accurate on real problems, with minimum-norm SVD preserved
51
+ exactly where it is needed.
52
+ - **N-scaled collocation defaults.** `solve_linear` and `solve_nonlinear` now
53
+ default `n_pde`/`n_bc` to `None` and derive them from the feature count
54
+ (`n_pde = max(3000, 3 * n_blocks * hidden_size)`, `n_bc = max(800, n_pde // 5)`),
55
+ replacing the fixed `10000`/`2000` (and `5000`/`1000`) over-sampling that was
56
+ ~6x the default feature count. Faster for the default configuration; passing
57
+ explicit `n_pde`/`n_bc` still overrides.
58
+
5
59
  ## [0.2.2] - 2026-06-03
6
60
 
7
61
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
@@ -96,6 +96,7 @@ fastlsq/problems/linear.py
96
96
  fastlsq/problems/nonlinear.py
97
97
  fastlsq/problems/regression.py
98
98
  tests/test_basic.py
99
+ tests/test_benchmarks_inverse.py
99
100
  tests/test_block.py
100
101
  tests/test_derivatives.py
101
102
  tests/test_device.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
@@ -44,7 +44,7 @@ from fastlsq.export import (
44
44
  )
45
45
  from fastlsq import viz
46
46
 
47
- __version__ = "0.2.2"
47
+ __version__ = "0.2.4"
48
48
  __all__ = [
49
49
  # Device selection (CPU / CUDA / Apple-MPS, dtype-aware)
50
50
  "resolve_device",
@@ -35,10 +35,11 @@ def solve_linear(
35
35
  scale: Optional[float] = None,
36
36
  n_blocks: int = 3,
37
37
  hidden_size: int = 500,
38
- n_pde: int = 10000,
39
- n_bc: int = 2000,
38
+ n_pde: Optional[int] = None,
39
+ n_bc: Optional[int] = None,
40
40
  n_test: int = 5000,
41
41
  mu: float = 0.0,
42
+ method: str = "auto",
42
43
  auto_scale: bool = True,
43
44
  auto_scale_trials: int = 5,
44
45
  return_solver: bool = False,
@@ -65,12 +66,17 @@ def solve_linear(
65
66
  Number of feature blocks.
66
67
  hidden_size : int
67
68
  Features per block.
68
- n_pde, n_bc : int
69
- Number of collocation and boundary points.
69
+ n_pde, n_bc : int, optional
70
+ Number of collocation and boundary points. If None, scaled with the
71
+ feature count: n_pde = max(3000, 3 * n_blocks * hidden_size),
72
+ n_bc = max(800, n_pde // 5).
70
73
  n_test : int
71
74
  Number of test points for error evaluation.
72
75
  mu : float
73
76
  Tikhonov regularisation parameter (0 = no regularisation).
77
+ method : str
78
+ Linear solve back-end passed to ``solve_lstsq`` ("auto", "qr", "svd",
79
+ "cholesky", "rsvd"). Default "auto".
74
80
  auto_scale : bool
75
81
  If True and scale=None, automatically select scale via grid search.
76
82
  auto_scale_trials : int
@@ -93,6 +99,12 @@ def solve_linear(
93
99
  """
94
100
  t0 = time.time()
95
101
 
102
+ n_feat = n_blocks * hidden_size
103
+ if n_pde is None:
104
+ n_pde = max(3000, 3 * n_feat) # ~3x oversampling; fixed 10000 was 6x for default N
105
+ if n_bc is None:
106
+ n_bc = max(800, n_pde // 5)
107
+
96
108
  # Auto-select scale if needed
97
109
  if scale is None and auto_scale:
98
110
  if verbose:
@@ -127,7 +139,7 @@ def solve_linear(
127
139
 
128
140
  # Assemble and solve
129
141
  A, b = problem.build(solver, x_pde, *build_args)
130
- beta_raw = solve_lstsq(A, b, mu=mu)
142
+ beta_raw = solve_lstsq(A, b, mu=mu, method=method)
131
143
  n_outputs = getattr(problem, "n_outputs", 1)
132
144
  solver.beta = unpack_beta(beta_raw, solver.n_features, n_outputs)
133
145
 
@@ -170,8 +182,8 @@ def solve_nonlinear(
170
182
  scale: Optional[float] = None,
171
183
  n_blocks: int = 3,
172
184
  hidden_size: int = 500,
173
- n_pde: int = 5000,
174
- n_bc: int = 1000,
185
+ n_pde: Optional[int] = None,
186
+ n_bc: Optional[int] = None,
175
187
  n_test: int = 5000,
176
188
  max_iter: int = 30,
177
189
  tol_res: float = 1e-8,
@@ -202,8 +214,10 @@ def solve_nonlinear(
202
214
  Number of feature blocks.
203
215
  hidden_size : int
204
216
  Features per block.
205
- n_pde, n_bc : int
206
- Number of collocation and boundary points.
217
+ n_pde, n_bc : int, optional
218
+ Number of collocation and boundary points. If None, scaled with the
219
+ feature count: n_pde = max(3000, 3 * n_blocks * hidden_size),
220
+ n_bc = max(800, n_pde // 5).
207
221
  n_test : int
208
222
  Number of test points for error evaluation.
209
223
  max_iter : int
@@ -239,6 +253,12 @@ def solve_nonlinear(
239
253
  """
240
254
  t0 = time.time()
241
255
 
256
+ n_feat = n_blocks * hidden_size
257
+ if n_pde is None:
258
+ n_pde = max(3000, 3 * n_feat) # ~3x oversampling; fixed 10000 was 6x for default N
259
+ if n_bc is None:
260
+ n_bc = max(800, n_pde // 5)
261
+
242
262
  # Auto-select scale if needed
243
263
  if scale is None and auto_scale:
244
264
  if verbose:
@@ -11,17 +11,26 @@ condition number -- leaving several orders of magnitude of accuracy on the floor
11
11
 
12
12
  ``solve_lstsq`` therefore exposes several back-ends via ``method=``:
13
13
 
14
+ * ``"qr"`` -- Householder-QR least squares (ridge via ``[A; sqrt(mu) I]``
15
+ augmentation). Backward-stable at ``cond(A)`` -- SVD-grade
16
+ accuracy with no normal-equations squaring and no required
17
+ ridge, at ~QR cost (cheaper than SVD). Assumes (numerically)
18
+ full column rank; ``"svd"`` is the rank-deficient-safe choice
19
+ (and ``"auto"``'s ultimate fallback if QR blows up).
14
20
  * ``"svd"`` -- rank-revealing truncated SVD of ``A`` (LAPACK ``gelsd`` fast
15
- path on CPU; explicit SVD elsewhere). The accuracy reference.
21
+ path on CPU; explicit SVD elsewhere). The accuracy reference;
22
+ use for a genuinely rank-deficient ``A``.
16
23
  * ``"cholesky"`` -- normal-equations ``(A^T A + mu I)`` Cholesky. Fast, but only
17
24
  safe when ``A`` is well-conditioned.
18
25
  * ``"rsvd"`` -- randomized SVD (range-finder + power iterations). ``O(MNk)``
19
26
  for a target ``rank`` k << N -- the cheap option for strongly
20
27
  low-rank systems.
21
28
  * ``"auto"`` (default) -- try Cholesky; if the system is ill-conditioned (a
22
- cheap pivot-ratio test) fall back to ``"svd"``. Recovers the
23
- fast path on well-conditioned problems **without** sacrificing
24
- accuracy on the rest.
29
+ cheap pivot-ratio test) use the faster ``"qr"``, and fall back
30
+ to rank-revealing ``"svd"`` only if QR's solution blows up (the
31
+ feature matrices can be rank-deficient). Fast path when
32
+ well-conditioned, QR speed/accuracy on the rest, SVD as the
33
+ safety net.
25
34
 
26
35
  All back-ends are device/dtype-aware. Apple-MPS lacks a robust ``svd``/``lstsq``,
27
36
  so the factorization is run on CPU and the result moved back (one-time warning).
@@ -33,6 +42,13 @@ import torch
33
42
 
34
43
  _MPS_WARNED = False
35
44
 
45
+ # In ``method="auto"``: above this ``||x|| / (1 + ||b||)`` ratio the unpivoted-QR
46
+ # solve is treated as a rank-deficiency blow-up and handed to the rank-revealing
47
+ # SVD instead. Real PDE systems measure <= 0.3 here; the degenerate inconsistent
48
+ # (random-RHS) rank-deficient case measures ~3e14 -- so the guard is generous and
49
+ # a false positive only costs speed, never correctness.
50
+ _QR_AUTO_NORM_GUARD = 1e6
51
+
36
52
 
37
53
  def _maybe_cpu(A, b):
38
54
  """MPS has no robust svd/lstsq -- factorize on CPU, remember to move back."""
@@ -86,9 +102,22 @@ def _rsvd_solve(A, b, mu, rcond, rank, oversample, n_iter):
86
102
  return Vh.transpose(-2, -1) @ (filt.unsqueeze(-1) * (U.transpose(-2, -1) @ b))
87
103
 
88
104
 
105
+ def _qr_solve(A, b, mu):
106
+ """Householder-QR least squares (ridge via [A; sqrt(mu) I] augmentation).
107
+ Backward-stable at cond(A): SVD-grade accuracy with NO normal-equations
108
+ squaring and no required ridge, at ~QR cost (cheaper than SVD). Assumes
109
+ (numerically) full column rank; use method='svd' for a rank-deficient A."""
110
+ if mu:
111
+ n = A.shape[-1]
112
+ A = torch.cat([A, (mu ** 0.5) * torch.eye(n, dtype=A.dtype, device=A.device)], dim=-2)
113
+ b = torch.cat([b, torch.zeros(n, b.shape[-1], dtype=b.dtype, device=b.device)], dim=-2)
114
+ Q, R = torch.linalg.qr(A, mode="reduced")
115
+ return torch.linalg.solve_triangular(R, Q.transpose(-2, -1) @ b, upper=True)
116
+
117
+
89
118
  def _auto_solve(A, b, mu, rcond):
90
119
  # Cheap conditioning probe: cond(A) ~ max/min Cholesky pivot. If well within
91
- # float64's reach use the fast Cholesky; otherwise fall back to the SVD.
120
+ # float64's reach use the fast Cholesky.
92
121
  try:
93
122
  x, L = _cholesky_solve(A, b, mu)
94
123
  d = torch.diagonal(L).abs()
@@ -96,6 +125,14 @@ def _auto_solve(A, b, mu, rcond):
96
125
  return x
97
126
  except torch.linalg.LinAlgError:
98
127
  pass
128
+ # Ill-conditioned: try the faster, backward-stable QR. On a genuinely
129
+ # rank-deficient *inconsistent* A unpivoted QR can return a wildly
130
+ # non-minimum-norm solution, so fall back to the rank-revealing SVD when the
131
+ # QR solution blows up (or is non-finite). See _QR_AUTO_NORM_GUARD.
132
+ x = _qr_solve(A, b, mu)
133
+ nx = torch.linalg.vector_norm(x)
134
+ if torch.isfinite(nx) and nx <= _QR_AUTO_NORM_GUARD * (1.0 + torch.linalg.vector_norm(b)):
135
+ return x
99
136
  return _svd_solve(A, b, mu, rcond)
100
137
 
101
138
 
@@ -112,7 +149,7 @@ def solve_lstsq(A, b, mu=0.0, rcond=1e-12, method="auto",
112
149
  an unstable add-on).
113
150
  rcond : float
114
151
  Relative singular-value / pivot threshold for rank determination.
115
- method : {"auto", "svd", "cholesky", "rsvd"}
152
+ method : {"auto", "qr", "svd", "cholesky", "rsvd"}
116
153
  Solve back-end (see module docstring). Default "auto".
117
154
  rank, oversample, n_iter : int
118
155
  Randomized-SVD parameters (``method="rsvd"`` only). Set ``rank`` << N for
@@ -127,11 +164,13 @@ def solve_lstsq(A, b, mu=0.0, rcond=1e-12, method="auto",
127
164
  x = _auto_solve(A2, b2, mu, rcond)
128
165
  elif method == "svd":
129
166
  x = _svd_solve(A2, b2, mu, rcond)
167
+ elif method == "qr":
168
+ x = _qr_solve(A2, b2, mu)
130
169
  elif method == "cholesky":
131
170
  x = _cholesky_solve(A2, b2, mu)[0]
132
171
  elif method == "rsvd":
133
172
  x = _rsvd_solve(A2, b2, mu, rcond, rank, oversample, n_iter)
134
173
  else:
135
174
  raise ValueError(f"Unknown method {method!r}; "
136
- "choose 'auto', 'svd', 'cholesky', or 'rsvd'.")
175
+ "choose 'auto', 'qr', 'svd', 'cholesky', or 'rsvd'.")
137
176
  return x.to(mps_dev) if mps_dev is not None else x
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "FastLSQ"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,144 @@
1
+ # Copyright (c) 2026 Antonin Sulc -- MIT.
2
+ """Smoke tests for the benchmark PDE equations and the inverse-problem workflows.
3
+
4
+ Exercises the forward benchmark problems through the public ``solve_linear`` /
5
+ ``solve_nonlinear`` API and two inverse pipelines -- parameter recovery via an
6
+ outer optimiser, and SINDy-style PDE discovery via analytical derivatives -- so
7
+ the v0.2.3 QR / N-scaled-collocation solver path is covered end-to-end, not just
8
+ on the single Poisson problem in ``test_basic``.
9
+
10
+ Scales are fixed (not auto-selected) and the RNG is seeded so the smoke test is
11
+ fast and deterministic; tolerances carry ~10x headroom over measured errors.
12
+
13
+ Excluded (pre-existing, unrelated to the solver work):
14
+ * ``Wave2D_MS`` -- rel-err == 1.0 via ``solve_linear`` in every config
15
+ (old 10000/2000 defaults included), i.e. does not solve.
16
+ * ``ElasticWave2D``-- a 2-output vector problem (``exact()`` returns (N, 2))
17
+ that never sets ``n_outputs``, so the scalar API can't
18
+ unpack it. Needs the vector solver path.
19
+ """
20
+ import numpy as np
21
+ import pytest
22
+ import torch
23
+
24
+ from fastlsq import (
25
+ solve_linear, solve_nonlinear, solve_lstsq, Op, SinusoidalBasis,
26
+ sample_box, sample_boundary_box,
27
+ )
28
+ from fastlsq.problems import linear as L
29
+ from fastlsq.problems import nonlinear as NL
30
+
31
+
32
+ # (class, fixed scale, val_err tolerance)
33
+ LINEAR_CASES = [
34
+ (L.PoissonND, 0.5, 5e-3),
35
+ (L.HeatND, 0.5, 1e-1),
36
+ (L.Wave1D, 15.0, 5e-3),
37
+ (L.Helmholtz2D, 10.0, 1e-5),
38
+ (L.Maxwell2D_TM, 2.0, 5e-3),
39
+ ]
40
+
41
+ NONLINEAR_CASES = [
42
+ (NL.NLPoisson2D, 8.0, 1e-4),
43
+ (NL.Bratu2D, 15.0, 1e-4),
44
+ (NL.SteadyBurgers1D,10.0, 1e-4),
45
+ (NL.NLHelmholtz2D, 5.0, 1e-4),
46
+ (NL.AllenCahn1D, 15.0, 2e-1),
47
+ ]
48
+
49
+
50
+ @pytest.mark.parametrize(
51
+ "cls,scale,tol", LINEAR_CASES, ids=[c[0].__name__ for c in LINEAR_CASES]
52
+ )
53
+ def test_linear_benchmark_solves(cls, scale, tol):
54
+ """Each linear benchmark equation solves end-to-end via the public API."""
55
+ torch.set_default_dtype(torch.float64)
56
+ torch.manual_seed(0)
57
+ r = solve_linear(cls(), scale=scale, n_blocks=2, hidden_size=300,
58
+ n_test=1500, auto_scale=False, verbose=False)
59
+ ve = r["metrics"]["val_err"]
60
+ assert np.isfinite(ve), f"{cls.__name__}: non-finite val_err"
61
+ assert ve < tol, f"{cls.__name__}: val_err={ve:.2e} exceeds tol {tol:.0e}"
62
+
63
+
64
+ @pytest.mark.parametrize(
65
+ "cls,scale,tol", NONLINEAR_CASES, ids=[c[0].__name__ for c in NONLINEAR_CASES]
66
+ )
67
+ def test_nonlinear_benchmark_solves(cls, scale, tol):
68
+ """Each nonlinear benchmark equation converges via Newton + the public API."""
69
+ torch.set_default_dtype(torch.float64)
70
+ torch.manual_seed(0)
71
+ r = solve_nonlinear(cls(), scale=scale, n_blocks=2, hidden_size=300,
72
+ n_test=1500, max_iter=15, auto_scale=False, verbose=False)
73
+ ve = r["metrics"]["val_err"]
74
+ assert r["n_iters"] > 0, f"{cls.__name__}: no Newton iterations ran"
75
+ assert np.isfinite(ve), f"{cls.__name__}: non-finite val_err"
76
+ assert ve < tol, f"{cls.__name__}: val_err={ve:.2e} exceeds tol {tol:.0e}"
77
+
78
+
79
+ def test_inverse_source_position():
80
+ """Recover a Gaussian source position from sensor data (forward solve + L-BFGS)."""
81
+ opt = pytest.importorskip("scipy.optimize")
82
+ torch.set_default_dtype(torch.float64)
83
+ torch.manual_seed(0)
84
+
85
+ pde_op = -Op.laplacian(d=2)
86
+ basis = SinusoidalBasis.random(input_dim=2, n_features=700, sigma=5.0,
87
+ normalize=True)
88
+ x_pde = sample_box(3000, 2)
89
+ x_bc = sample_boundary_box(400, 2)
90
+ n_bc = x_bc.shape[0]
91
+ cache = basis.cache(x_pde)
92
+ A = torch.cat([pde_op.apply(basis, x_pde, cache=cache),
93
+ 100.0 * basis.evaluate(x_bc)])
94
+ x_sens = torch.tensor([[0.3, 0.3], [0.7, 0.7], [0.3, 0.7], [0.7, 0.3]])
95
+
96
+ def forward(xs, ys):
97
+ b = torch.exp(-((x_pde[:, 0] - xs) ** 2
98
+ + (x_pde[:, 1] - ys) ** 2) / 0.1).unsqueeze(1)
99
+ b = torch.cat([b, torch.zeros(n_bc, 1, dtype=b.dtype)])
100
+ beta = solve_lstsq(A, b)
101
+ return (basis.evaluate(x_sens) @ beta).detach().cpu().numpy().ravel()
102
+
103
+ true = np.array([0.4, 0.6])
104
+ rng = np.random.default_rng(0)
105
+ u_obs = forward(*true) + 0.005 * rng.standard_normal(4)
106
+
107
+ res = opt.minimize(
108
+ lambda p: float(np.sum((forward(float(p[0]), float(p[1])) - u_obs) ** 2)),
109
+ x0=[0.5, 0.5], method="L-BFGS-B", bounds=[(0.1, 0.9)] * 2,
110
+ )
111
+ assert np.linalg.norm(res.x - true) < 0.06, f"recovered {res.x} vs {true}"
112
+
113
+
114
+ def test_pde_discovery_recovers_governing_equation():
115
+ """SINDy-style discovery via analytical derivatives recovers u_xx = a*u + b*u_x.
116
+
117
+ Synthetic damped oscillator u = exp(-x/2) sin(2x) -> u_xx = -4.25 u - 1.0 u_x.
118
+ The dominant restoring term is recovered tightly; the damping term is harder
119
+ from 2% noise, so it is only bounded in sign/magnitude.
120
+ """
121
+ torch.set_default_dtype(torch.float64)
122
+ torch.manual_seed(42)
123
+
124
+ M = 500
125
+ x = torch.linspace(0, 2 * np.pi, M).reshape(-1, 1)
126
+ u_true = torch.exp(-0.5 * x) * torch.sin(2 * x)
127
+ u_noisy = u_true + 0.02 * torch.randn_like(u_true)
128
+
129
+ basis = SinusoidalBasis.random(input_dim=1, n_features=400, sigma=4.0,
130
+ normalize=True)
131
+ beta = solve_lstsq(basis.evaluate(x), u_noisy, mu=1e-3)
132
+ cache = basis.cache(x)
133
+ u = basis.evaluate(x, cache=cache) @ beta
134
+ u_x = basis.derivative(x, alpha=(1,), cache=cache) @ beta
135
+ u_xx = basis.derivative(x, alpha=(2,), cache=cache) @ beta
136
+
137
+ coef = solve_lstsq(torch.cat([u, u_x], dim=1), u_xx) # u_xx = a*u + b*u_x
138
+ a, b = float(coef[0]), float(coef[1])
139
+ assert abs(a - (-4.25)) < 0.3, f"restoring coeff a={a:.3f} (want -4.25)"
140
+ assert b < 0 and abs(b - (-1.0)) < 0.5, f"damping coeff b={b:.3f} (want -1.0)"
141
+
142
+
143
+ if __name__ == "__main__":
144
+ pytest.main([__file__, "-v"])
@@ -20,7 +20,7 @@ from fastlsq.utils import device
20
20
  # ----------------------------------------------------------------------
21
21
 
22
22
  def test_version():
23
- assert fastlsq.__version__ == "0.2.1"
23
+ assert fastlsq.__version__ == "0.2.4"
24
24
 
25
25
 
26
26
  def test_imports():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes