Skip to content

Commit 4fc86e0

Browse files
Switched from reverse mode to forward mode where possible.
This commit switches some functions that unnecessarily use reverse-mode autodiff to using forward-mode autodiff. In particular this is to fix #51 (comment). Whilst I"m here, I noticed what looks like some incorrect handling of complex numbers. I've tried fixing those up, but at least as of this commit the test I've added fails. I've poked at this a bit but not yet been able to resolve this. It seems something is still awry!
1 parent dcfcbc9 commit 4fc86e0

11 files changed

Lines changed: 203 additions & 38 deletions

optimistix/_iterate.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import abc
2+
import warnings
23
from collections.abc import Callable
34
from typing import Any, Generic, Optional, TYPE_CHECKING
45

@@ -323,6 +324,13 @@ def iterative_solve(
323324
An [`optimistix.Solution`][] object.
324325
"""
325326

327+
if any(jnp.iscomplexobj(x) for x in jtu.tree_leaves((y0, f_struct))):
328+
warnings.warn(
329+
"Complex support in Optimistix is a work in progress, and may still return "
330+
"incorrect results. You may prefer to split your problem into real and "
331+
"imaginary parts, so that Optimistix sees only real numbers."
332+
)
333+
326334
f_struct = jtu.tree_map(eqxi.Static, f_struct)
327335
aux_struct = jtu.tree_map(eqxi.Static, aux_struct)
328336
inputs = fn, solver, y0, args, options, max_steps, f_struct, aux_struct, tags

optimistix/_search.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from typing import ClassVar, Generic, Type, TypeVar
2727

2828
import equinox as eqx
29+
import jax.numpy as jnp
30+
import jax.tree_util as jtu
2931
import lineax as lx
3032
from jaxtyping import Array, Bool, Scalar
3133

@@ -35,7 +37,7 @@
3537
SearchState,
3638
Y,
3739
)
38-
from ._misc import sum_squares
40+
from ._misc import sum_squares, tree_dot
3941
from ._solution import RESULTS
4042

4143

@@ -89,6 +91,9 @@ class EvalGrad(FunctionInfo, Generic[Y], strict=True):
8991
def as_min(self):
9092
return self.f
9193

94+
def compute_grad_dot(self, y: Y):
95+
return tree_dot(self.grad, y)
96+
9297

9398
# NOT PUBLIC, despite lacking an underscore. This is so pyright gets the name right.
9499
class EvalGradHessian(FunctionInfo, Generic[Y], strict=True):
@@ -104,6 +109,9 @@ class EvalGradHessian(FunctionInfo, Generic[Y], strict=True):
104109
def as_min(self):
105110
return self.f
106111

112+
def compute_grad_dot(self, y: Y):
113+
return tree_dot(self.grad, y)
114+
107115

108116
# NOT PUBLIC, despite lacking an underscore. This is so pyright gets the name right.
109117
class EvalGradHessianInv(FunctionInfo, Generic[Y], strict=True):
@@ -118,6 +126,9 @@ class EvalGradHessianInv(FunctionInfo, Generic[Y], strict=True):
118126
def as_min(self):
119127
return self.f
120128

129+
def compute_grad_dot(self, y: Y):
130+
return tree_dot(self.grad, y)
131+
121132

122133
# NOT PUBLIC, despite lacking an underscore. This is so pyright gets the name right.
123134
class Residual(FunctionInfo, Generic[Out], strict=True):
@@ -134,28 +145,43 @@ def as_min(self):
134145
# NOT PUBLIC, despite lacking an underscore. This is so pyright gets the name right.
135146
class ResidualJac(FunctionInfo, Generic[Y, Out], strict=True):
136147
"""Records the Jacobian `d(fn)/dy` as a linear operator. Used for least squares
137-
problems, for which `fn` returns residuals. Has `.residual` and `.jac` and `.grad`
138-
attributes, where `residual = fn(y)`, `jac = d(fn)/dy` and
139-
`grad = jac^T residual`.
140-
141-
Takes just `residual` and `jac` as `__init__`-time arguments, from which `grad` is
142-
computed.
148+
problems, for which `fn` returns residuals. Has `.residual` and `.jac` attributes,
149+
where `residual = fn(y)`, `jac = d(fn)/dy`.
143150
"""
144151

145152
residual: Out
146153
jac: lx.AbstractLinearOperator
147-
grad: Y
148-
149-
def __init__(self, residual: Out, jac: lx.AbstractLinearOperator):
150-
self.residual = residual
151-
self.jac = jac
152-
# The gradient is used ubiquitously, so compute it once here, so that it can be
153-
# used without recomputation in both the descent and search.
154-
self.grad = jac.transpose().mv(residual)
155154

156155
def as_min(self):
157156
return 0.5 * sum_squares(self.residual)
158157

158+
def compute_grad(self):
159+
conj_residual = jtu.tree_map(jnp.conj, self.residual)
160+
return self.jac.transpose().mv(conj_residual)
161+
162+
def compute_grad_dot(self, y: Y):
163+
# If `self.jac` is a `lx.JacobianLinearOperator` (or a
164+
# `lx.FunctionLinearOperator` wrapping the result of `jax.linearize`), then
165+
# `min = 0.5 * residual^2`, so `grad = jac^T residual`, i.e. the gradient of
166+
# this. So that what we want to compute is `residual^T jac y`. Doing the
167+
# reduction in this order means we hit forward-mode rather than reverse-mode
168+
# autodiff.
169+
#
170+
# For the complex case: in this case then actually
171+
# `min = 0.5 * residual residual^bar`
172+
# which implies
173+
# `grad = jac^T residual^bar`
174+
# and thus that we want
175+
# `grad^T^bar y = residual^T jac^bar y = (jac y^bar)^T^bar residual`.
176+
# Notes:
177+
# (a) the `grad` derivation is not super obvious. Note that
178+
# `grad(z -> 0.5 z z^bar)` is `z^bar` in JAX (yes, twice the Wirtinger
179+
# derivative!) It uses a non-Wirtinger derivative for nonholomorphic functions:
180+
# https://jax.readthedocs.io/en/latest/notebooks/autodiff_cookbook.html#complex-numbers-and-differentiation
181+
# (b) our convention is that the first term of a dot product gets the conjugate:
182+
# https://github.com/patrick-kidger/diffrax/pull/454#issuecomment-2210296643
183+
return tree_dot(self.jac.mv(jtu.tree_map(jnp.conj, y)), self.residual)
184+
159185

160186
Eval.__qualname__ = "FunctionInfo.Eval"
161187
EvalGrad.__qualname__ = "FunctionInfo.EvalGrad"

optimistix/_solver/backtracking.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@
77
from jaxtyping import Array, Bool, Scalar, ScalarLike
88

99
from .._custom_types import Y
10-
from .._misc import (
11-
tree_dot,
12-
)
1310
from .._search import AbstractSearch, FunctionInfo
1411
from .._solution import RESULTS
1512

@@ -55,7 +52,7 @@ def __post_init__(self):
5552
)
5653

5754
def init(self, y: Y, f_info_struct: _FnInfo) -> _BacktrackingState:
58-
del f_info_struct
55+
del y, f_info_struct
5956
return _BacktrackingState(step_size=jnp.array(self.step_init))
6057

6158
def step(
@@ -67,7 +64,7 @@ def step(
6764
f_eval_info: _FnEvalInfo,
6865
state: _BacktrackingState,
6966
) -> tuple[Scalar, Bool[Array, ""], RESULTS, _BacktrackingState]:
70-
if isinstance(
67+
if not isinstance(
7168
f_info,
7269
(
7370
FunctionInfo.EvalGrad,
@@ -76,16 +73,14 @@ def step(
7673
FunctionInfo.ResidualJac,
7774
),
7875
):
79-
grad = f_info.grad
80-
else:
8176
raise ValueError(
8277
"Cannot use `BacktrackingArmijo` with this solver. This is because "
8378
"`BacktrackingArmijo` requires gradients of the target function, but "
8479
"this solver does not evaluate such gradients."
8580
)
8681

8782
y_diff = (y_eval**ω - y**ω).ω
88-
predicted_reduction = tree_dot(grad, y_diff)
83+
predicted_reduction = f_info.compute_grad_dot(y_diff)
8984
# Terminate when the Armijo condition is satisfied. That is, `fn(y_eval)`
9085
# must do better than its linear approximation:
9186
# `fn(y_eval) < fn(y) + grad•y_diff`

optimistix/_solver/dogleg.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,15 @@ def query(
7272
f_info: Union[FunctionInfo.EvalGradHessian, FunctionInfo.ResidualJac],
7373
state: _DoglegDescentState,
7474
) -> _DoglegDescentState:
75-
del state
75+
del y, state
7676
# Compute `denom = grad^T Hess grad.`
7777
if isinstance(f_info, FunctionInfo.EvalGradHessian):
78-
denom = tree_dot(f_info.grad, f_info.hessian.mv(f_info.grad))
78+
grad = f_info.grad
79+
denom = tree_dot(f_info.grad, f_info.hessian.mv(grad))
7980
elif isinstance(f_info, FunctionInfo.ResidualJac):
8081
# Use Gauss--Newton approximation `Hess ~ J^T J`
81-
denom = sum_squares(f_info.jac.mv(f_info.grad))
82+
grad = f_info.compute_grad()
83+
denom = sum_squares(f_info.jac.mv(grad))
8284
else:
8385
raise ValueError(
8486
"`DoglegDescent` can only be used with least-squares solvers, or "
@@ -88,7 +90,7 @@ def query(
8890
denom_nonzero = denom > jnp.finfo(denom.dtype).eps
8991
safe_denom = jnp.where(denom_nonzero, denom, 1)
9092
# Compute `grad^T grad / (grad^T Hess grad)`
91-
scaling = jnp.where(denom_nonzero, sum_squares(f_info.grad) / safe_denom, 0.0)
93+
scaling = jnp.where(denom_nonzero, sum_squares(grad) / safe_denom, 0.0)
9294
scaling = cast(Array, scaling)
9395

9496
# Downhill towards the bottom of the quadratic basin.
@@ -97,7 +99,7 @@ def query(
9799
newton_norm = self.trust_region_norm(newton_sol)
98100

99101
# Downhill steepest descent.
100-
cauchy = (-scaling * f_info.grad**ω).ω
102+
cauchy = (-scaling * grad**ω).ω
101103
cauchy_norm = self.trust_region_norm(cauchy)
102104

103105
return _DoglegDescentState(

optimistix/_solver/gradient_methods.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ def query(
5858
FunctionInfo.EvalGrad,
5959
FunctionInfo.EvalGradHessian,
6060
FunctionInfo.EvalGradHessianInv,
61-
FunctionInfo.ResidualJac,
6261
),
6362
):
6463
grad = f_info.grad
64+
elif isinstance(f_info, FunctionInfo.ResidualJac):
65+
grad = f_info.compute_grad()
6566
else:
6667
raise ValueError(
6768
"Cannot use `SteepestDescent` with this solver. This is because "

optimistix/_solver/levenberg_marquardt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def damped_newton_step(
5454

5555
pred = step_size > jnp.finfo(step_size.dtype).eps
5656
safe_step_size = jnp.where(pred, step_size, 1)
57-
lm_param = jnp.where(pred, 1 / safe_step_size, jnp.finfo(step_size).max)
57+
lm_param = jnp.where(pred, 1 / safe_step_size, jnp.finfo(step_size.dtype).max)
5858
lm_param = cast(Array, lm_param)
5959
if isinstance(f_info, FunctionInfo.EvalGradHessian):
6060
operator = f_info.hessian + lm_param * lx.IdentityLinearOperator(

optimistix/_solver/nonlinear_cg.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,19 @@ def query(
119119
],
120120
state: _NonlinearCGDescentState,
121121
) -> _NonlinearCGDescentState:
122-
if not isinstance(
122+
del y
123+
if isinstance(
123124
f_info,
124125
(
125126
FunctionInfo.EvalGrad,
126127
FunctionInfo.EvalGradHessian,
127128
FunctionInfo.EvalGradHessianInv,
128-
FunctionInfo.ResidualJac,
129129
),
130130
):
131+
grad = f_info.grad
132+
elif isinstance(f_info, FunctionInfo.ResidualJac):
133+
grad = f_info.compute_grad()
134+
else:
131135
raise ValueError(
132136
"Cannot use `NonlinearCGDescent` with this solver. This is because "
133137
"`NonlinearCGDescent` requires gradients of the target function, but "
@@ -140,16 +144,16 @@ def query(
140144
# Furthermore, the same mechanism handles convergence: once
141145
# `state.{grad, y_diff} = 0`, i.e. our previous step hit a local minima, then
142146
# on this next step we'll again just use gradient descent, and stop.
143-
beta = self.method(f_info.grad, state.grad, state.y_diff)
144-
neg_grad = (-(f_info.grad**ω)).ω
147+
beta = self.method(grad, state.grad, state.y_diff)
148+
neg_grad = (-(grad**ω)).ω
145149
nonlinear_cg_direction = (neg_grad**ω + beta * state.y_diff**ω).ω
146150
# Check if this is a descent direction. Use gradient descent if it isn't.
147151
y_diff = tree_where(
148-
tree_dot(f_info.grad, nonlinear_cg_direction) < 0,
152+
tree_dot(grad, nonlinear_cg_direction) < 0,
149153
nonlinear_cg_direction,
150154
neg_grad,
151155
)
152-
return _NonlinearCGDescentState(y_diff=y_diff, grad=f_info.grad)
156+
return _NonlinearCGDescentState(y_diff=y_diff, grad=grad)
153157

154158
def step(
155159
self, step_size: Scalar, state: _NonlinearCGDescentState

optimistix/_solver/trust_region.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ def predict_reduction(
273273
FunctionInfo.ResidualJac,
274274
),
275275
):
276-
return tree_dot(f_info.grad, y_diff)
276+
return f_info.compute_grad_dot(y_diff)
277277
else:
278278
raise ValueError(
279279
"Cannot use `LinearTrustRegion` with this solver. This is because "

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ classifiers = [
3333
"Topic :: Scientific/Engineering :: Mathematics",
3434
]
3535
urls = {repository = "https://github.com/patrick-kidger/optimistix" }
36-
dependencies = ["jax>=0.4.28", "jaxtyping>=0.2.23", "lineax>=0.0.4", "equinox>=0.11.1", "typing_extensions>=4.5.0"]
36+
dependencies = ["jax>=0.4.28", "jaxtyping>=0.2.23", "lineax>=0.0.5", "equinox>=0.11.1", "typing_extensions>=4.5.0"]
3737

3838
[build-system]
3939
requires = ["hatchling"]

0 commit comments

Comments
 (0)