Skip to content

Commit 4ebfaad

Browse files
Transurgeonclaude
andcommitted
Move DerivativeChecker from nlp_solver.py to test helper module
Separates test-only utility from production code by moving it to cvxpy/tests/nlp_tests/derivative_checker.py and updating all 19 test file imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ef4cd1 commit 4ebfaad

21 files changed

Lines changed: 300 additions & 257 deletions

CLAUDE.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ The diff engine supports CVXPY `Parameter` objects: `C_problem` registers parame
161161

162162
`DerivativeChecker` in `nlp_solver.py` provides finite-difference verification of gradients, Jacobians, and Hessians during development.
163163

164+
Key files in `cvxpy/reductions/solvers/nlp_solvers/diff_engine/`:
165+
- `converters.py` - Converts CVXPY expression AST to C diff engine trees. Contains `ATOM_CONVERTERS` dict mapping ~40 atom types to C constructors. Includes optimizations like sparse parameter matmul fusion.
166+
- `c_problem.py` - `C_problem` wrapper around the C diff engine capsule, providing `objective_forward()`, `gradient()`, `jacobian()`, `hessian()` methods.
167+
168+
Convention: all arrays are flattened in **Fortran order** ('F') for column-major compatibility with the C library.
169+
164170
## Implementing New Atoms
165171

166172
### For DCP Atoms
@@ -213,7 +219,28 @@ class TestMyFeature(BaseTest):
213219
self.assertEqual(prob.status, cp.OPTIMAL)
214220
```
215221

216-
NLP tests are in `cvxpy/tests/nlp_tests/` with Jacobian and Hessian verification tests.
222+
NLP tests are in `cvxpy/tests/nlp_tests/`. Use `DerivativeChecker` from `derivative_checker.py` to verify derivatives:
223+
224+
```python
225+
import pytest
226+
from cvxpy.reductions.solvers.defines import INSTALLED_SOLVERS
227+
from cvxpy.tests.nlp_tests.derivative_checker import DerivativeChecker
228+
229+
@pytest.mark.skipif('IPOPT' not in INSTALLED_SOLVERS, reason='IPOPT is not installed.')
230+
class TestMyNLPFeature:
231+
def test_derivatives(self) -> None:
232+
x = cp.Variable(2)
233+
x.value = np.ones(2)
234+
prob = cp.Problem(cp.Minimize(cp.sum_squares(x)), [x >= 0.1])
235+
prob.solve(solver=cp.IPOPT, nlp=True)
236+
assert prob.status == cp.OPTIMAL
237+
238+
# Verify gradients, Jacobian, and Hessian against finite differences
239+
checker = DerivativeChecker(prob)
240+
checker.run_and_assert()
241+
```
242+
243+
A common pattern is to solve with both a DCP solver (CLARABEL) and an NLP solver (IPOPT) and verify the results match.
217244

218245
## Benchmarks
219246

cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py

Lines changed: 0 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -255,241 +255,3 @@ def intermediate(self, alg_mod, iter_count, obj_value, inf_pr, inf_du, mu,
255255
self.iterations = iter_count
256256
self.objective_forward_passed = False
257257
self.constraints_forward_passed = False
258-
259-
260-
# TODO: maybe add a cchecker like this to the diff-engine? Or rather do a checker that
261-
# uses cvxpy expressions to evaluate values. It will be slower, but will better test
262-
# consistency with cvxpy.
263-
class DerivativeChecker:
264-
"""
265-
A utility class to verify derivative computations by comparing
266-
C-based diff engine results against Python-based evaluations.
267-
"""
268-
269-
def __init__(self, problem):
270-
"""
271-
Initialize the derivative checker with a CVXPY problem.
272-
273-
Parameters
274-
----------
275-
problem : cvxpy.Problem
276-
The CVXPY problem to check derivatives for.
277-
"""
278-
from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth
279-
from cvxpy.reductions.solvers.nlp_solvers.diff_engine import C_problem
280-
281-
self.original_problem = problem
282-
283-
# Apply Dnlp2Smooth to get canonicalized problem
284-
canon = Dnlp2Smooth().apply(problem)
285-
self.canonicalized_problem = canon[0]
286-
287-
# Construct the C version
288-
print("Constructing C diff engine problem for derivative checking...")
289-
self.c_problem = C_problem(self.canonicalized_problem)
290-
print("Done constructing C diff engine problem.")
291-
292-
# Construct initial point using Bounds functionality
293-
self.bounds = Bounds(self.canonicalized_problem)
294-
self.x0 = self.bounds.x0
295-
296-
# Initialize constraint bounds for checking
297-
self.cl = self.bounds.cl
298-
self.cu = self.bounds.cu
299-
300-
def check_constraint_values(self, x=None):
301-
if x is None:
302-
x = self.x0
303-
304-
# Evaluate constraints using C implementation
305-
c_values = self.c_problem.constraint_forward(x)
306-
307-
# Evaluate constraints using Python implementation
308-
# First, set variable values
309-
x_offset = 0
310-
for var in self.canonicalized_problem.variables():
311-
var_size = var.size
312-
var.value = x[x_offset:x_offset + var_size].reshape(var.shape, order='F')
313-
x_offset += var_size
314-
315-
# Now evaluate each constraint
316-
python_values = []
317-
for constr in self.canonicalized_problem.constraints:
318-
constr_val = constr.expr.value.flatten(order='F')
319-
python_values.append(constr_val)
320-
321-
python_values = np.hstack(python_values) if python_values else np.array([])
322-
323-
match = np.allclose(c_values, python_values, rtol=1e-10, atol=1e-10)
324-
return match
325-
326-
def check_jacobian(self, x=None, epsilon=1e-8):
327-
if x is None:
328-
x = self.x0
329-
330-
# Get Jacobian from C implementation
331-
self.c_problem.init_jacobian()
332-
self.c_problem.init_hessian()
333-
self.c_problem.constraint_forward(x)
334-
c_jac_csr = self.c_problem.jacobian()
335-
c_jac_dense = c_jac_csr.toarray()
336-
337-
# Compute numerical Jacobian using central differences
338-
n_vars = len(x)
339-
n_constraints = len(self.cl)
340-
numerical_jac = np.zeros((n_constraints, n_vars))
341-
342-
# Define constraint function for finite differences
343-
def constraint_func(x_eval):
344-
return self.c_problem.constraint_forward(x_eval)
345-
346-
# Compute each column using central differences
347-
for j in range(n_vars):
348-
x_plus = x.copy()
349-
x_minus = x.copy()
350-
x_plus[j] += epsilon
351-
x_minus[j] -= epsilon
352-
353-
c_plus = constraint_func(x_plus)
354-
c_minus = constraint_func(x_minus)
355-
356-
numerical_jac[:, j] = (c_plus - c_minus) / (2 * epsilon)
357-
358-
match = np.allclose(c_jac_dense, numerical_jac, rtol=1e-4, atol=1e-5)
359-
return match
360-
361-
def check_hessian(self, x=None, duals=None, obj_factor=1.0, epsilon=1e-8):
362-
if x is None:
363-
x = self.x0
364-
365-
if duals is None:
366-
duals = np.random.rand(len(self.cl))
367-
368-
# Get Hessian from C implementation
369-
self.c_problem.objective_forward(x)
370-
self.c_problem.constraint_forward(x)
371-
#jac = self.c_problem.jacobian()
372-
373-
# must run gradient because for logistic it fills some values
374-
self.c_problem.gradient()
375-
c_hess_csr = self.c_problem.hessian(obj_factor, duals)
376-
377-
# Convert to full dense matrix (C returns lower triangular)
378-
c_hess_coo = c_hess_csr.tocoo()
379-
n_vars = len(x)
380-
c_hess_dense = np.zeros((n_vars, n_vars))
381-
382-
# Fill in the full symmetric matrix from lower triangular
383-
for i, j, v in zip(c_hess_coo.row, c_hess_coo.col, c_hess_coo.data):
384-
c_hess_dense[i, j] = v
385-
if i != j:
386-
c_hess_dense[j, i] = v
387-
388-
# Compute numerical Hessian using finite differences of the Lagrangian gradient
389-
# Lagrangian gradient: ∇L = obj_factor * ∇f + J^T * duals
390-
def lagrangian_gradient(x_eval):
391-
self.c_problem.objective_forward(x_eval)
392-
grad_f = self.c_problem.gradient()
393-
394-
self.c_problem.constraint_forward(x_eval)
395-
jac = self.c_problem.jacobian()
396-
397-
# Lagrangian gradient = obj_factor * grad_f + J^T * duals
398-
return obj_factor * grad_f + jac.T @ duals
399-
400-
# Compute Hessian via central differences of gradient
401-
numerical_hess = np.zeros((n_vars, n_vars))
402-
for j in range(n_vars):
403-
x_plus = x.copy()
404-
x_minus = x.copy()
405-
x_plus[j] += epsilon
406-
x_minus[j] -= epsilon
407-
408-
grad_plus = lagrangian_gradient(x_plus)
409-
grad_minus = lagrangian_gradient(x_minus)
410-
411-
numerical_hess[:, j] = (grad_plus - grad_minus) / (2 * epsilon)
412-
413-
# Symmetrize the numerical Hessian (average with transpose to reduce numerical errors)
414-
numerical_hess = (numerical_hess + numerical_hess.T) / 2
415-
416-
match = np.allclose(c_hess_dense, numerical_hess, rtol=1e-4, atol=1e-6)
417-
return match
418-
419-
def check_objective_value(self, x=None):
420-
""" Compare objective value from C implementation with Python implementation. """
421-
if x is None:
422-
x = self.x0
423-
424-
# Evaluate objective using C implementation
425-
c_obj_value = self.c_problem.objective_forward(x)
426-
427-
# Evaluate objective using Python implementation
428-
x_offset = 0
429-
for var in self.canonicalized_problem.variables():
430-
var_size = var.size
431-
var.value = x[x_offset:x_offset + var_size].reshape(var.shape, order='F')
432-
x_offset += var_size
433-
434-
python_obj_value = self.canonicalized_problem.objective.expr.value
435-
436-
# Compare results
437-
match = np.allclose(c_obj_value, python_obj_value, rtol=1e-10, atol=1e-10)
438-
439-
return match
440-
441-
def check_gradient(self, x=None, epsilon=1e-8):
442-
""" Compare C-based gradient with numerical approximation using finite differences. """
443-
if x is None:
444-
x = self.x0
445-
# Get gradient from C implementation
446-
self.c_problem.objective_forward(x)
447-
c_grad = self.c_problem.gradient()
448-
449-
# Compute numerical gradient using central differences
450-
n_vars = len(x)
451-
numerical_grad = np.zeros(n_vars)
452-
453-
def objective_func(x_eval):
454-
return self.c_problem.objective_forward(x_eval)
455-
456-
# Compute each component using central differences
457-
for j in range(n_vars):
458-
x_plus = x.copy()
459-
x_minus = x.copy()
460-
x_plus[j] += epsilon
461-
x_minus[j] -= epsilon
462-
463-
f_plus = objective_func(x_plus)
464-
f_minus = objective_func(x_minus)
465-
466-
numerical_grad[j] = (f_plus - f_minus) / (2 * epsilon)
467-
468-
match = np.allclose(c_grad, numerical_grad, rtol= 5 * 1e-3, atol=1e-5)
469-
assert(match)
470-
return match
471-
472-
def run(self, x=None):
473-
""" Run all derivative checks (constraints, Jacobian, and Hessian). """
474-
475-
self.c_problem.init_jacobian()
476-
self.c_problem.init_hessian()
477-
objective_result = self.check_objective_value(x)
478-
gradient_result = self.check_gradient(x)
479-
constraints_result = self.check_constraint_values()
480-
jacobian_result = self.check_jacobian(x)
481-
hessian_result = self.check_hessian(x)
482-
483-
result = {'objective': objective_result,
484-
'gradient': gradient_result,
485-
'constraints': constraints_result,
486-
'jacobian': jacobian_result,
487-
'hessian': hessian_result}
488-
489-
return result
490-
491-
def run_and_assert(self, x=None):
492-
""" Run all derivative checks and assert correctness. """
493-
results = self.run(x)
494-
for key, passed in results.items():
495-
assert passed, f"Derivative check failed for {key}."

0 commit comments

Comments
 (0)