Skip to content

Add implicitly-differentiable Davidson eigensolvers (eigh + eig) - #119

Open
claude[bot] wants to merge 4 commits into
mainfrom
claude/issue-116-20260602-0351
Open

Add implicitly-differentiable Davidson eigensolvers (eigh + eig)#119
claude[bot] wants to merge 4 commits into
mainfrom
claude/issue-116-20260602-0351

Conversation

@claude

@claude claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds pyscfad/lib/davidson_lite.py with fully jax-jittable Davidson eigensolvers that are differentiable to arbitrary order (forward and reverse mode), implementing the request in #116.

Instead of differentiating through the Davidson iterations, the converged solution is differentiated via the implicit function theorem using jax.lax.custom_root, mirroring the SCFLite pattern in pyscfad/scf/hf_lite.py.

  • eigh — Hermitian / real-symmetric (and complex-Hermitian) operators. Fully implicitly differentiable. The (near-)degenerate response is projected out via deg_thresh, following the masking strategy in pyscfad/backend/_jax/lax/linalg.py::_eigh_gen_jvp_rule.
  • eig — general non-Hermitian operators. The adjoint uses an explicit transposed operator aopT (Aᴴ) to recover the biorthonormal left eigenvectors and build the oblique projectors.

Key properties (per the issue discussion):

  • Converged states are not closed over — they are recovered from the linearization (X = -g((0, 1))), which is what makes higher-order derivatives correct.
  • Fixed nroots so the solver stays jittable (callers request enough roots to cover any boundary degeneracy).
  • Forward solve is a fixed-max-space, GPU-friendly Davidson iteration: lax.while_loop, static shapes, batched aop(V), thick restart via dynamic_update_slice.
  • Operator working dtype is inferred from aop (so a complex operator applied to a real guess works).

The existing davidson1 and its tdscf/fci call sites are left untouched — this lands as a new module with no migration.

Validation

tests/test_davidson_lite.py (10 tests, all passing locally on jax 0.10.1, float64):

  • eigh: forward vs jnp.linalg.eigvalsh; residual + orthonormality; jit; Hellmann–Feynman gradient; check_grads(order=2, modes=['fwd','rev']); eigenvector-gradient vs dense eigh; degenerate spectrum; complex-Hermitian forward + gradient.
  • eig: forward + biorthonormal left vectors (Yᴴ X = I); jit; check_grads(order=2, fwd+rev) for the eigenvalue.

Note on non-Hermitian targeting

Lowest-N selection for eig relies on the operator being diagonally dominant (the CI/TDDFT regime), so the diagonal preconditioner and unit-vector guess steer toward the lowest real-part roots. For strongly non-normal operators the converged pairs are still exact eigenpairs but may not be the lowest ones; supply a good x0/adiag in that case. This is documented in the eig docstring.

Closes #116.

Test plan

  • CI runs tests/test_davidson_lite.py
  • Confirm gradient checks pass on CI hardware/jax version

Generated with Claude Code

Add pyscfad/lib/davidson_lite.py providing fully jittable Davidson
eigensolvers differentiated via jax.lax.custom_root (implicit function
theorem) instead of unrolling the iterations:

- eigh: Hermitian/real-symmetric, supports complex Hermitian; degenerate
  response projected out (deg_thresh), mirroring _eigh_gen_jvp_rule.
- eig: non-Hermitian, adjoint via explicit aopT to recover biorthonormal
  left vectors and oblique projectors.

Converged states are recovered from the linearization rather than closed
over, enabling correct higher-order derivatives. Forward solve is a
fixed-max-space, GPU-friendly Davidson (while_loop, static shapes,
batched aop, thick restart). Fixed nroots keeps it jittable.

Add tests/test_davidson_lite.py: forward accuracy, jit, Hellmann-Feynman,
check_grads(order=2, fwd+rev), eigenvector gradient vs dense eigh,
degenerate spectrum, complex Hermitian, and non-Hermitian eig forward +
biorthonormality + eigenvalue gradients.

Co-authored-by: Xing Zhang <fishjojo@users.noreply.github.com>
@fishjojo

fishjojo commented Jun 2, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71d6a6c728

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pyscfad/lib/davidson_lite.py Outdated
restart = (nv + k) > m
Vb, off, valid = lax.cond(
restart,
lambda: (jnp.zeros_like(V).at[:, :k].set(X), jnp.int32(k), jnp.int32(k)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Orthonormalize non-Hermitian Ritz vectors on restart

When eig needs a thick restart (nv + k > max_space) with nroots > 1, this seeds the next basis with X directly, but the right Ritz vectors returned by jnp.linalg.eig are not mutually orthogonal. The later projected solve still assumes V.conj().T @ AV is built from an orthonormal basis, so after a restart it can select spurious near-zero Ritz pairs; for example, an 80x80 diagonally dominant matrix with nroots=2 and the default max_space returns eigenvalues around 0 instead of the lowest values near 1, 2, with tiny returned vectors satisfying the absolute residual test. Re-orthonormalizing X before storing it in V (and keeping the projected quantities consistent) avoids corrupting restarted non-Hermitian solves.

Useful? React with 👍 / 👎.

@fishjojo

fishjojo commented Jun 2, 2026

Copy link
Copy Markdown
Owner

@claude address codex's review.

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Claude finished @fishjojo's task in 2m 57s —— View job


Addressed Codex's review

  • Read pyscfad/lib/davidson_lite.py and understand the restart path
  • Fix: orthonormalize non-Hermitian Ritz vectors on thick restart (eig)
  • Add a regression test reproducing the spurious-near-zero-Ritz case
  • Run tests (11 passed, including the new one)
  • Commit & push (374131e)

The fix. Codex was right. On a thick restart (davidson_lite.py:162), the new basis was seeded with the Ritz vectors X directly. For eig, X = V @ C where C comes from jnp.linalg.eig and is not orthonormal, so the restarted basis lost orthonormality and the next projected solve V.conj().T @ AV (which assumes an orthonormal V) was corrupted — selecting spurious near-zero Ritz pairs with tiny vectors that still passed the absolute residual test.

I now re-orthonormalize the Ritz vectors via QR before storing them on restart:

Xr = jnp.linalg.qr(X)[0]
Vb, off, valid = lax.cond(
    restart,
    lambda: (jnp.zeros_like(V).at[:, :k].set(Xr), jnp.int32(k), jnp.int32(k)),
    lambda: (V, nv, nv))

For the Hermitian (eigh) path X is already orthonormal (eigenvectors of a Hermitian projected matrix), so QR is a no-op up to column phase — no behavior change there.

Regression test. Added test_eig_restart_orthonormal: an 80×80 diagonally dominant matrix with nroots=2 and the default (small) max_space, forcing multiple thick restarts. It asserts the converged eigenvalues match the lowest reference roots (near 1, 2), the right vectors are O(1) (not near-zero), and Yᴴ X = I.

I verified the test fails without the fix (eigenvalue error ≈ 2.22, i.e. it returned roots near 0) and passes with it. Full file: 11/11 tests pass on jax 0.10.1, float64.
· branch claude/issue-116-20260602-0351

On a thick restart in `eig`, the right Ritz vectors X = V @ C (with C from
jnp.linalg.eig) are not mutually orthonormal, so seeding the next basis with
them corrupted the projected solve V^H A V (which assumes an orthonormal
basis) and could select spurious near-zero Ritz pairs. Re-orthonormalize X
via QR before storing it in V. For the Hermitian case X is already
orthonormal, so QR is a no-op up to column phase.

Adds a regression test (80x80 diagonally dominant, nroots=2, default
max_space) that forces multiple restarts.

Co-authored-by: Xing Zhang <fishjojo@users.noreply.github.com>
@fishjojo

fishjojo commented Jun 2, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 374131e911

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

X, Y, w = state
R1 = aop(X) - X * w[None, :]
R2 = aopT(Y) - Y * jnp.conj(w)[None, :]
s = jnp.sum(jnp.conj(Y) * X, axis=0) - 1.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix non-Hermitian eigenvector gauge for differentiation

When differentiating any result that depends on the non-Hermitian eigenvectors, this constraint leaves the scaling gauge underdetermined: X -> a X, Y -> Y/conj(a) still satisfies the two eigen-equations and Y^H X = 1, while the forward solver returns unit-norm Ritz vectors from jnp.linalg.eig. The tangent solve therefore chooses a different gauge than the returned X; for example, for a small real diagonally dominant matrix, jax.grad(lambda p: sum((c*eig(...)[1][:,0].real)**2))(p) disagrees in sign/magnitude with finite differences/dense eigvec derivatives even though eigenvalue gradients pass. Please add constraints matching the forward normalization/phase (or avoid exposing differentiable X/Y for eig).

Useful? React with 👍 / 👎.

@fishjojo

fishjojo commented Jun 2, 2026

Copy link
Copy Markdown
Owner

@claude address codex's latest review.

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Claude finished @fishjojo's task in 7m 38s —— View job


Addressed Codex's latest review (P2: non-Hermitian eigenvector gauge)

  • Understand the P2 gauge issue for eig
  • Implement fix
  • Update docstrings
  • Add regression test
  • Run tests (12 passed)
  • Commit & push (91cde4d)

The issue. Codex was right. For eig, the optimality constraint Yᴴ X = 1 plus the two eigen-equations leave a complex scaling gauge undetermined: X → a X, Y → Y/conj(a) satisfies all three. The forward solve pins it with jnp.linalg.eig's unit-norm normalization, whose phase is not a smooth function of the operator, so eigenvector derivatives can't correspond to the returned vectors — jax.grad of a function of X/Y disagreed with finite differences even though eigenvalue gradients passed.

The fix. Took Codex's recommended alternative — avoid exposing differentiable X/Y for eig. Only the gauge-invariant eigenvalues w are exposed as differentiable; the eigenvectors are returned with lax.stop_gradient so a downstream X/Y dependence can't silently produce gauge-dependent (wrong) gradients. Documented in the eig docstring.

X, Y, w = custom_root(root_fn, state0, solve, tangent_solve, has_aux=False)
# ... gauge note ...
X = lax.stop_gradient(X)
Y = lax.stop_gradient(Y)

Important subtlety. I first tried short-circuiting tangent_solve to skip the eigenvector tangent solves entirely — that broke the 2nd-order eigenvalue derivative (check_grads(order=2) failed: 0.2499 vs 0.2448). The eigenvector tangents must still be computed internally (with the symmetric biorthonormality gauge split) for the implicit-function-theorem solve to keep the eigenvalue's higher-order derivatives correct. So tangent_solve is unchanged; the gauge fixing only happens on the public outputs.

Regression test. Added test_eig_eigvec_grad_stopped: asserts jax.grad of a scalar of X/Y is exactly 0.0, while the eigenvalue gradient is finite and matches central finite differences. eigh is untouched — its eigenvectors remain fully differentiable.

12/12 tests pass on jax 0.10.1, float64.
· branch claude/issue-116-20260602-0351

The right/left eigenvectors of a general operator have an undetermined
complex scaling gauge that the forward jnp.linalg.eig normalization pins
non-smoothly, so their derivatives cannot correspond to the returned
vectors. Expose only the gauge-invariant eigenvalues as differentiable
and stop gradients on the eigenvectors. The eigenvector tangents are
still computed internally so the eigenvalue's higher-order derivatives
remain correct.

Co-authored-by: Xing Zhang <fishjojo@users.noreply.github.com>
@fishjojo

fishjojo commented Jun 2, 2026

Copy link
Copy Markdown
Owner

@codex review 91cde4d

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91cde4db9f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +416 to +417
X = lax.stop_gradient(X)
Y = lax.stop_gradient(Y)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve projector gradients from returned left/right vectors

When callers use return_left=True to differentiate a gauge-invariant quantity built from the biorthonormal pair, such as a spectral projector X @ Y.conj().T, this stop_gradient makes the entire eigenspace contribution differentiate as exactly zero even though the projector changes with the operator. I checked this with a simple diagonally dominant matrix: a projector scalar had finite-difference slope about -0.716 while jax.grad through eig(..., return_left=True) returned 0.0. The docstring explicitly points users at spectral projectors as the safe eigenvector-dependent quantity, so stopping both vectors here silently corrupts those gradients; stop only the non-differentiable gauge/phase or document that only eigenvalues may be differentiated.

Useful? React with 👍 / 👎.

@fishjojo

fishjojo commented Jun 2, 2026

Copy link
Copy Markdown
Owner

@claude address codex's latest review.

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Claude finished @fishjojo's task in 4m 5s —— View job


Addressed Codex's latest review (P2: projector gradients vs. stopped eigenvectors)

  • Read davidson_lite.py and understand the inconsistency
  • Decide on fix
  • Implement
  • Run tests (12 passed)
  • Commit & push (ef71e06)

The issue. Codex correctly spotted a contradiction left by the previous round. The last fix wrapped both X and Y in lax.stop_gradient, but the docstring still pointed users at "spectral projectors built from biorthonormal pairs" as a safe gauge-invariant quantity to differentiate. Since the projector X @ Y^H is built from the stopped vectors, it differentiates to exactly 0 — so the docstring advertised a capability the code does not provide.

The fix — chose the conservative resolution (docstring + lock). Codex offered two mutually-exclusive options: (1) stop only the gauge/phase so projectors stay differentiable, or (2) document that only eigenvalues may be differentiated. I took option 2 because option 1 re-exposes differentiable X/Y and brings back the exact silent-wrong-gradient risk that the previous round (and the maintainer) deliberately eliminated by stopping the vectors — a gauge-dependent scalar would then differentiate to a plausible-but-wrong value with no error. There is no way to expose a correct projector derivative without also re-opening that footgun, so I kept the safe stop_gradient and removed the misleading advice.

  • Rewrote the eig Note (davidson_lite.py:279): only w is differentiable; any quantity built from the returned X/Y, including a gauge-invariant projector X @ Y^H, differentiates to zero — use a dense decomposition (jnp.linalg.eig) if non-Hermitian eigenvector/projector derivatives are needed.
  • Added an inline comment at the stop_gradient site (davidson_lite.py:416) recording the zeroed-projector behavior as an accepted limitation.

Regression test. Extended test_eig_eigvec_grad_stopped: in addition to the existing X/Y scalar assertions, it now asserts jax.grad of a spectral-projector scalar X @ Y^H is exactly 0.0, locking the documented limitation against silent regressions.

12/12 tests pass on jax 0.10.1, float64. eigh is untouched — its eigenvectors remain fully differentiable.
· branch claude/issue-116-20260602-0351

The non-Hermitian eigenvectors are returned with stop_gradient, which also
zeroes the gradient of an otherwise gauge-invariant spectral projector
X @ Y^H. Correct the docstring (which previously advertised projectors as
differentiable) to state only eigenvalues are differentiable, and lock the
documented limitation with a projector-gradient regression test.

Co-authored-by: Xing Zhang <fishjojo@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[New Feature] Implicit differentiation for Davidson solver

1 participant