Skip to content

Add determinant support#6

Open
jpbrodrick89 wants to merge 34 commits into
jpb/hevdfrom
claude/lineax-determinant-design-X52cv
Open

Add determinant support#6
jpbrodrick89 wants to merge 34 commits into
jpb/hevdfrom
claude/lineax-determinant-design-X52cv

Conversation

@jpbrodrick89

Copy link
Copy Markdown
Owner

Summary

This PR refactors the monolithic _operator.py module into a structured package with multiple submodules, while adding support for Hermitian operators and rank constraints via MaxRankTag.

Key Changes

Module Restructuring

  • Split lineax/_operator.py (2408 lines) into a package lineax/_operator/:
    • base.py: Abstract base class and utility functions
    • core.py: Core operators (MatrixLinearOperator, PyTreeLinearOperator, FunctionLinearOperator)
    • structured.py: Structured operators (IdentityLinearOperator, DiagonalLinearOperator, TridiagonalLinearOperator)
    • wrapper.py: Wrapper operators (TaggedLinearOperator, TangentLinearOperator, etc.)
    • binary.py: Binary operators (AddLinearOperator, ComposedLinearOperator, etc.)
    • __init__.py: Public API exports

New Features

  • Hermitian operator support: Added hermitian_tag and is_hermitian() function for complex matrices where A = A^H (conjugate transpose)
  • Rank constraints: Introduced MaxRankTag dataclass to mark operators with bounded rank, queryable via max_rank() function
  • Conjugate transpose property: Added .H property to AbstractLinearOperator as shorthand for conjugate transpose

Solver Module Reorganization

  • Extracted solver base classes into lineax/_solver/base.py
  • Created lineax/_solver/auto.py for AutoLinearSolver
  • Updated all solver imports to use new module structure

New Determinant Module

  • Added lineax/_determinant.py with determinant computation functions
  • Added corresponding API documentation in docs/api/determinant.md

Test Coverage

  • Added tests/test_max_rank.py with comprehensive MaxRankTag semantics tests
  • Added tests/test_determinant.py with determinant correctness tests
  • Updated existing tests to support Hermitian operators

Documentation

  • Updated API docs to include is_hermitian, max_rank, and determinant functions
  • Added .H property to operator API documentation

Implementation Details

  • MaxRankTag is a frozen dataclass with equality/hashing for use in frozensets
  • Rank composition rules: @ operator uses min(rank_A, rank_B), + uses min(rank_A + rank_B, in_size, out_size)
  • Hermitian check validates matching input/output structures (like symmetric operators)
  • Refactoring maintains backward compatibility through __init__.py re-exports
  • Solver base classes moved to enable circular import resolution in _solve.py

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ

claude added 27 commits June 10, 2026 15:09
…determinant functions

Introduces AbstractDirectLinearSolver as an intermediate abstract class between
AbstractLinearSolver and the concrete direct solvers (LU, Cholesky, QR, SVD,
Triangular, Diagonal, Tridiagonal). Direct solvers materialise the operator and
can therefore expose the (log absolute) determinant from their factored state.

Abstract methods:
- logabsdet(state, options) -> log|det(A)|
- det_sign(state, options) -> sign(det(A))

Concrete implementations:
- LU: sum(log|diag(U)|), (-1)^swaps * prod(sign(diag(U)))
- Cholesky: 2*sum(log|diag(factor)|), +/-1 based on PSD/NSD and matrix size
- QR: raises NotImplementedError (geqrf has no JAX AD rules; deferred follow-up)
- SVD: sum(log(s)) for square operators; det_sign raises (O(n³) to compute)
- Triangular: sum(log|diag|), prod(sign(diag)); short-circuits for unit diagonal
- Diagonal: sum(log|diag|), prod(sign(diag)); short-circuits for unit diagonal
- Tridiagonal: Thomas algorithm pivot recurrence (O(n), AD-friendly via lax.scan)

Normal is refactored with a __new__ factory that returns _NormalDirect when the
inner solver is an AbstractDirectLinearSolver. _NormalDirect inherits all Normal
logic and adds logabsdet via: 0.5 * inner_solver.logabsdet(inner_state, ...).
det_sign is not supported (squaring destroys sign information).

The Normal.init materialise check is generalised from isinstance(_, Cholesky) to
isinstance(_, AbstractDirectLinearSolver).

Top-level convenience functions added to lx namespace:
- lx.logabsdet(operator, solver) -> log|det|
- lx.slogdet(operator, solver) -> (sign, log|det|)  [numpy convention]
- lx.determinant(operator, solver) -> det

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Reverts the _NormalDirect subclass approach which violated equinox's
abstract-or-final pattern (Normal was both instantiatable and subclassable).

Instead, Normal.logabsdet and Normal.det_sign are added as plain concrete
methods directly on Normal (no inheritance from AbstractDirectLinearSolver).
logabsdet delegates to inner_solver.logabsdet when the inner solver is direct,
and raises TypeError otherwise. det_sign always raises NotImplementedError.

The is_direct(solver) helper is added to normal.py and exported at the top
level. It returns True for AbstractDirectLinearSolver instances and for
Normal instances whose inner_solver is direct. Normal.init uses is_direct
instead of the previous isinstance(_, AbstractDirectLinearSolver) inline check.

The top-level lx.logabsdet/slogdet/determinant functions accept
AbstractDirectLinearSolver as their type hint; Normal works via duck typing
at runtime when wrapping a direct inner solver.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Normal(Normal(LU())) is contrived but is_direct should handle it correctly.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
…ints

Replaces the AbstractDirectLinearSolver type hint on lx.logabsdet, lx.slogdet,
and lx.determinant with a SupportsLogdet Protocol. This avoids the circular
import that would prevent a union type (AbstractDirectLinearSolver | Normal)
from living in _solve.py, and is forward-compatible with any third-party solver
that implements the three required methods (init, logabsdet, det_sign).

SupportsLogdet is exported at the top level.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
…irectLinearSolver interface

Protocol cannot inherit from AbstractLinearSolver due to equinox/typing metaclass
conflict, so all seven methods are listed explicitly to stay consistent with
AbstractDirectLinearSolver's interface.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
…ctions to _determinant.py

Uses union type AbstractDirectLinearSolver | Normal instead of a Protocol,
avoiding metaclass conflicts and making accepted types explicit in docs.
Circular import avoidance: _solve.py cannot import Normal (which imports
from _solve.py), so _determinant.py imports from both.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
…ndefined

Sign is returned as nan (not raised) for Normal (gram matrix destroys sign
information) and SVD (recovering sign(det(U)) and sign(det(V^T)) requires
O(n^3) extra work). QR still raises NotImplementedError since geqrf has no
JAX AD rules, which is a categorically different limitation.

lx.determinant gains a throw=True kwarg (matching linear_solve convention):
eqx.error_if fires on isnan(sign) with a branching message by solver type,
operator shape (non-square), and assume_full_rank.

Bonus: Tridiagonal.slogdet computes the pivot scan once instead of twice.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
…rove SVD sign

API changes:
- Remove lx.logabsdet (users can inline `_, lad = lx.slogdet(...)`)
- Remove MaybeDirectLinearSolver type alias; inline AbstractDirectLinearSolver | Normal
  in function signatures for clearer documentation

slogdet/determinant: accept optional state= kwarg (no stop_gradient, unlike
linear_solve); docstring warns callers not to stop_gradient the state since
determinants are computed directly from the factorisation.

SVD.slogdet: detect rank deficiency via rcond threshold and return (0, -inf)
for rank-deficient matrices (det = 0 by definition, sign = 0 trivially). For
full-rank square matrices sign is still nan (requires O(n^3) extra work).
This avoids a spurious throw=True error when the determinant is simply zero.

Error messages: dispatch assume_full_rank first (same message whether or not
Normal is involved), then Normal-specific message ending with "use lx.QR()
for the pseudodeterminant of a full-rank rectangular matrix". All messages
written assuming QR sign support will be available before merge.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
SVD operates in the pseudo paradigm throughout:
- lad = sum(log(non-zero singular values)) for all shapes/ranks, including
  rank-deficient operators. The zero singular values are excluded via the
  rcond threshold (replaced with 1 so log = 0, they don't contribute).
- sign = nan always — SVD cannot recover the sign in any case:
    full-rank square: needs sign(det(U)) * sign(det(V^T)), O(n^3)
    full-rank rectangular: future work via QR Householder vectors
    rank-deficient: needs an eigensolver for the pseudodeterminant sign

Previously returned sign=0 for rank-deficient matrices (treating it as
a true determinant rather than a pseudodeterminant), which is wrong under
the pseudodeterminant convention: the pseudodeterminant of a non-zero
rank-deficient matrix is non-zero. Also removed the ValueError for
non-square operators — rectangular is a perfectly valid input.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Mirrors linear_solve's design: stop_gradient the state and provide an
analytic derivative rather than differentiating through the factorisation.

JVP rule: d(log|det(A)|)/dA = trace(A† dA), computed by vmapping
linear_solve over the columns of dA (the tangent operator materialised
via TangentLinearOperator.as_matrix()). Each column solve reuses the
stopped-gradient state from the primal for efficiency.

For complex operators: the imaginary part of trace(A^{-1} dA) is
transferred to sign_dot (matching JAX's own slogdet convention);
the real part becomes lad_dot.

determinant inherits the correct JVP automatically:
  d(det(A))/dA = det(A) * trace(A^{-1} dA) * A^{-T}
which equals det(A) * A^{-T} as expected.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
QR assumes full rank, so sign(det) = sign(det(Q)) * sign(det(R)) is
well-defined for tall, wide, and square operators alike. Remove the
is_square guard that was returning nan for non-square inputs.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
…tion

slogdet implementations in qr/svd/cholesky/normal used jnp.result_type
to compute float_dtype, which raises TypePromotionError under strict
dtype promotion (used in tests). Switch to numpy.result_type, which
ignores JAX's promotion config.

Tests cover: square determinant correctness, sign=nan for SVD/Normal,
throw kwarg, QR rectangular (lad vs SVD, sign=±1, explicit QR reference),
state= kwarg reuse, and JVP of slogdet/determinant.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
- Drop unnecessary float32 floor in slogdet dtype computation across
  qr/svd/cholesky/normal; solvers always operate on float types so
  just use .real.dtype directly
- Add Normal(Cholesky) lad correctness test against jnp.linalg.slogdet
- JVP tests now compare against jax.jvp of jnp.linalg.slogdet (not
  just the analytical formula), and cover use_state=True
- Add grad (VJP) test comparing against jax.grad of jnp.linalg.slogdet
- Drop trivial state=kwarg standalone test; state is exercised in JVP

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Normal is designed for non-square operators. Test with tall (5×3) and
wide (3×5) matrices; validate lad against sum(log(singular values)).

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
- Rename _slogdet_p -> _slogdet (not a JAX primitive)
- Move sentinel dance and stop_gradient into the public slogdet/determinant
  functions, matching linear_solve's structure
- Default state=sentinel instead of state=None
- Mark state as nondifferentiable inside _slogdet via eqxi.nondifferentiable
- Factor out _prep_state helper shared by slogdet and determinant

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
_prep_state was only called in one place after determinant was changed
to call slogdet directly. Inline the sentinel/stop_gradient logic into
slogdet and remove the helper.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
- eqxi.nondifferentiable(state) now lives in the public slogdet function
  (after stop_gradient), not inside _slogdet, matching linear_solve's
  pattern of preparing all inputs before the inner call
- Add IdentityLinearOperator fast path before the sentinel dance:
  det(I) = 1, so return (sign=1, lad=0) without any factorisation

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
- Parametrize square correctness and AD tests over make_matrix_operator
  and make_jac_operator, matching test_jvp.py's pattern; JacobianLinearOperator
  exercises the TangentLinearOperator.as_matrix() path for JVP/grad
- Add test_slogdet_jvp_jvp: second-order JVP compared against
  jnp.linalg.slogdet for LU and QR

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
When well_posed=False, zero (or near-zero) diagonal entries are excluded
from the determinant, using the same rcond threshold as compute(). This
mirrors SVD's pseudodeterminant convention and avoids returning sign=0
or lad=-inf for rank-deficient diagonal operators.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
operator is the only differentiable argument to _slogdet (solver/options/state
are all nondifferentiable), so t_operator is always present when the JVP is
called. The has_t_op guard and _is_none helper were dead code inherited from
linear_solve_p_jvp where both operator and vector can independently vary.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
- Drop the stop_gradient warning from slogdet/determinant state= docs;
  it's an implementation detail users don't need to know about
- Normal.slogdet error message recommends lx.Cholesky() only, not LU —
  Normal wraps positive (semi)definite gram matrices so Cholesky is
  the natural pairing

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
AutoLinearSolver always delegates to direct solvers (LU, Cholesky, QR,
SVD, Diagonal, Triangular, Tridiagonal), so it should satisfy the
AbstractDirectLinearSolver interface. This lets it be used with
lx.slogdet and lx.determinant directly.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Tests well_posed=True and well_posed=None; well_posed=False is excluded
for the same reason SVD is excluded — sign is always nan from SVD.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
- New determinant.md page (mirrors linear_solve.md) documenting
  lx.slogdet and lx.determinant, including a note on XLA CSE
- AbstractDirectLinearSolver added to solvers.md after
  AbstractLinearSolver, documenting its slogdet method
- determinant.md added to mkdocs.yml nav after linear_solve.md

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Merge origin/jpb/hevd into the determinant branch, resolving conflicts:

- AbstractLinearSolver and new AbstractDirectLinearSolver now live in
  _solver/base.py (moved from _solve.py during the operator refactor)
- AutoLinearSolver moved to _solver/auto.py, promoted to
  AbstractDirectLinearSolver with slogdet delegation
- HEVD promoted to AbstractDirectLinearSolver with slogdet:
  sign = prod(sign(w_i)) for non-masked eigenvalues (same rcond as
  compute()), lad = sum(log|w_i|) — full pseudodeterminant support
  including rank-deficient Hermitian matrices
- Error message in determinant() updated to recommend HEVD for
  Hermitian/rank-deficient systems
- Restore _check_rank_compat dropped during conflict resolution
- HEVD added to SQUARE_DET_CASES test parametrisation

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
LU and QR assume full rank and won't work for rank-deficient systems.
The message now recommends HEVD for Hermitian rank-deficient matrices
and directs users to jnp.linalg.eig for non-Hermitian rank-deficient
systems where lineax has no sign-capable solver.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
@jpbrodrick89
jpbrodrick89 changed the base branch from main to jpb/hevd June 22, 2026 09:48
@jpbrodrick89 jpbrodrick89 changed the title Refactor operator module into submodules and add Hermitian/max_rank support Add determinant support Jun 22, 2026
For complex matrices jnp.sign returns a unit complex number (the phase
of the determinant), matching numpy.linalg.slogdet convention. The
.real / .astype(real_dtype) casts were silently discarding that phase.

Cholesky is unaffected (sign is always ±1 from a static Python int).
HEVD is unaffected (Hermitian eigenvalues are always real).
All other solvers (LU, QR, Diagonal, Triangular, Tridiagonal) now return
the natural dtype of jnp.sign, preserving the complex phase.

https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
claude added 6 commits June 22, 2026 10:13
The previous formula used prod(where(taus != 0, -1, 1)), which gives ±1
parity but is only correct for real Householder reflectors. For complex
matrices, det(I - τ·v·vᴴ) = 1 - τ·‖v‖² (matrix determinant lemma), which
yields a general unit complex number rather than always ±1.

The new formula computes col_norms_sq from the subdiagonal of a (where the
Householder vectors are stored in raw QR layout), then accumulates
sign_Q = prod(1 - τ·‖v‖²). For real matrices, ‖v‖² = 1 + s² and
τ = 2/(1+s²), so 1 - τ·‖v‖² = -1, matching the old behaviour exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
AbstractDirectLinearSolver is the public API for this concept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TywaHs2ctbDsGhrchbyXzJ
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.

2 participants