From b33f8379951f4167731000572b38b70e7e19e0c9 Mon Sep 17 00:00:00 2001 From: Matthew Glover Date: Mon, 22 Dec 2025 09:06:26 -0500 Subject: [PATCH] Add ignore_nan option to solver and implement validation skipping for NaN/Inf --- cvxpy/problems/problem.py | 70 +++++++----- .../dcp2cone/cone_matrix_stuffing.py | 16 ++- cvxpy/tests/test_solver_data_validation.py | 107 ++++++++++++++++++ cvxpy/utilities/scopes.py | 28 +++++ 4 files changed, 189 insertions(+), 32 deletions(-) diff --git a/cvxpy/problems/problem.py b/cvxpy/problems/problem.py index c598aedb7d..49fc0f9c74 100644 --- a/cvxpy/problems/problem.py +++ b/cvxpy/problems/problem.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import contextlib import time import warnings from collections import namedtuple @@ -56,6 +57,7 @@ from cvxpy.utilities import debug_tools from cvxpy.utilities.citations import CITATION_DICT from cvxpy.utilities.deterministic import unique_list +from cvxpy.utilities.scopes import ignore_nan_scope SolveResult = namedtuple( 'SolveResult', @@ -794,6 +796,12 @@ def get_problem_data( for reduction in solving_chain.reductions: reduction.update_parameters(self) + # Set ignore_nan flag on param_prog if specified in solver_opts + if solver_opts and solver_opts.get('ignore_nan', False): + self._cache.param_prog.ignore_nan = True + else: + self._cache.param_prog.ignore_nan = False + data, solver_inverse_data = solving_chain.solver.apply( self._cache.param_prog) inverse_data = self._cache.inverse_data + [solver_inverse_data] @@ -828,6 +836,9 @@ def get_problem_data( '(Subsequent compilations of this problem, using the ' 'same arguments, should ' 'take less time.)') self._cache.param_prog = data[s.PARAM_PROB] + # Set ignore_nan flag on param_prog if specified in solver_opts + if solver_opts and solver_opts.get('ignore_nan', False): + self._cache.param_prog.ignore_nan = True # the last datum in inverse_data corresponds to the solver, # so we shouldn't cache it self._cache.inverse_data = inverse_data[:-1] @@ -1198,35 +1209,42 @@ def _solve(self, self.unpack(chain.retrieve(soln)) return self.value - data, solving_chain, inverse_data = self.get_problem_data( - solver, gp, enforce_dpp, ignore_dpp, verbose, canon_backend, kwargs + # Use ignore_nan_scope if ignore_nan is specified in solver options + nan_scope = ( + ignore_nan_scope() if kwargs.get('ignore_nan', False) + else contextlib.nullcontext() ) - if verbose: - print(_NUM_SOLVER_STR) - s.LOGGER.info( - 'Invoking solver %s to obtain a solution.', - solving_chain.reductions[-1].name()) - start = time.time() - solver_verbose = kwargs.pop('solver_verbose', verbose) - if solver_verbose and (not verbose): - print(_NUM_SOLVER_STR) - if verbose and bibtex: - print(_CITATION_STR) - - # Cite CVXPY papers. - print(CITATION_DICT["CVXPY"]) + with nan_scope: + data, solving_chain, inverse_data = self.get_problem_data( + solver, gp, enforce_dpp, ignore_dpp, verbose, canon_backend, kwargs + ) - # Cite problem grammar. - if self.is_dcp(): - print(CITATION_DICT["DCP"]) - if gp: - print(CITATION_DICT["DGP"]) - - # Cite solver. - print(solving_chain.reductions[-1].cite(data)) - solution = solving_chain.solve_via_data( - self, data, warm_start, solver_verbose, kwargs) + if verbose: + print(_NUM_SOLVER_STR) + s.LOGGER.info( + 'Invoking solver %s to obtain a solution.', + solving_chain.reductions[-1].name()) + start = time.time() + solver_verbose = kwargs.pop('solver_verbose', verbose) + if solver_verbose and (not verbose): + print(_NUM_SOLVER_STR) + if verbose and bibtex: + print(_CITATION_STR) + + # Cite CVXPY papers. + print(CITATION_DICT["CVXPY"]) + + # Cite problem grammar. + if self.is_dcp(): + print(CITATION_DICT["DCP"]) + if gp: + print(CITATION_DICT["DGP"]) + + # Cite solver. + print(solving_chain.reductions[-1].cite(data)) + solution = solving_chain.solve_via_data( + self, data, warm_start, solver_verbose, kwargs) end = time.time() self._solve_time = end - start self.unpack_results(solution, solving_chain, inverse_data) diff --git a/cvxpy/reductions/dcp2cone/cone_matrix_stuffing.py b/cvxpy/reductions/dcp2cone/cone_matrix_stuffing.py index c305fa0573..cbf19548f8 100644 --- a/cvxpy/reductions/dcp2cone/cone_matrix_stuffing.py +++ b/cvxpy/reductions/dcp2cone/cone_matrix_stuffing.py @@ -52,6 +52,7 @@ nonpos2nonneg, ) from cvxpy.utilities.coeff_extractor import CoeffExtractor +from cvxpy.utilities.scopes import ignore_nan_scope_active class ConeDims: @@ -192,14 +193,16 @@ def __init__(self, q, x, A, # whether this param cone prog has been formatted for a solver self.formatted = formatted + # Flag to skip NaN/Inf validation (set via solver_opts['ignore_nan']) + self.ignore_nan = False + def is_mixed_integer(self) -> bool: """Is the problem mixed-integer?""" return self.x.attributes['boolean'] or \ self.x.attributes['integer'] def apply_parameters(self, id_to_param_value=None, zero_offset: bool = False, - keep_zeros: bool = False, quad_obj: bool = False, - ignore_unknown_params: bool = False): + keep_zeros: bool = False, quad_obj: bool = False): """Returns A, b after applying parameters (and reshaping). Args: @@ -209,7 +212,6 @@ def apply_parameters(self, id_to_param_value=None, zero_offset: bool = False, keep_zeros: (optional) if True, store explicit zeros in A where parameters are affected. quad_obj: (optional) if True, include quadratic objective term. - ignore_unknown_params: (optional) if True, skip NaN/Inf validation. """ self.reduced_A.cache(keep_zeros) @@ -230,10 +232,12 @@ def param_value(idx): b = np.atleast_1d(b) # Validate for NaN/Inf unless: - # 1. explicitly ignored via ignore_unknown_params, or - # 2. any parameter values are None (DPP compilation without values) + # 1. self.ignore_nan is True (set via solver_opts), or + # 2. ignore_nan_scope is active (context manager), or + # 3. any parameter values are None (DPP compilation without values) should_validate = ( - not ignore_unknown_params + not self.ignore_nan + and not ignore_nan_scope_active() and all(p.value is not None for p in self.parameters) ) if should_validate: diff --git a/cvxpy/tests/test_solver_data_validation.py b/cvxpy/tests/test_solver_data_validation.py index 7cf5742d5f..fe5d2cf144 100644 --- a/cvxpy/tests/test_solver_data_validation.py +++ b/cvxpy/tests/test_solver_data_validation.py @@ -75,3 +75,110 @@ def test_qp_solver_with_inf(self): if cp.OSQP in cp.installed_solvers(): with pytest.raises(ValueError, match="contains NaN or Inf"): prob.solve(solver=cp.OSQP) + + def test_ignore_nan_option(self): + """Test that ignore_nan=True skips validation.""" + x = cp.Variable() + # Create a problem with inf in constraint + prob = cp.Problem(cp.Minimize(x), [x >= cp.Constant(np.inf)]) + + if cp.SCS in cp.installed_solvers(): + # With ignore_nan=True, no ValueError should be raised during + # apply_parameters. The solver may still fail or return + # a non-optimal status, but that's expected behavior. + try: + prob.solve(solver=cp.SCS, ignore_nan=True) + # The solver may return inf, nan, or fail gracefully + # We just verify no ValueError was raised by our validation + except ValueError as e: + if "contains NaN or Inf" in str(e): + pytest.fail("ignore_nan=True should skip validation") + # Other ValueErrors from the solver itself are acceptable + except Exception: + # Solver-level exceptions are acceptable when data contains inf + pass + + def test_neg_inf_in_constraint(self): + """Negative infinity in constraint should raise ValueError.""" + x = cp.Variable() + prob = cp.Problem(cp.Minimize(x), [x <= cp.Constant(-np.inf)]) + + if cp.SCS in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.SCS) + + def test_nan_in_matrix_constraint(self): + """NaN in matrix constraint data should raise ValueError.""" + X = cp.Variable((2, 2)) + A = np.array([[1, np.nan], [0, 1]]) + prob = cp.Problem(cp.Minimize(cp.sum(X)), [X == A]) + + if cp.SCS in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.SCS) + + def test_inf_in_quadratic_objective(self): + """Inf coefficient in quadratic objective should raise ValueError.""" + x = cp.Variable() + # Create quadratic form with inf coefficient + prob = cp.Problem(cp.Minimize(cp.Constant(np.inf) * x**2), [x >= 0]) + + if cp.OSQP in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.OSQP) + + def test_inf_from_expression_overflow(self): + """Inf resulting from expression overflow should raise ValueError.""" + x = cp.Variable() + # Very large values that will overflow to inf when combined + large_val = 1e308 + prob = cp.Problem(cp.Minimize(x), [x >= large_val * 2]) + + if cp.SCS in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.SCS) + + def test_nan_in_socp_constraint(self): + """NaN in second-order cone constraint should raise ValueError.""" + x = cp.Variable(2) + t = cp.Variable() + A = np.array([[1, np.nan], [0, 1]]) + prob = cp.Problem(cp.Minimize(t), [cp.norm(A @ x) <= t]) + + if cp.SCS in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.SCS) + + def test_inf_in_equality_constraint(self): + """Inf in equality constraint should raise ValueError.""" + x = cp.Variable() + prob = cp.Problem(cp.Minimize(x**2), [x == cp.Constant(np.inf)]) + + if cp.SCS in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.SCS) + + def test_multiple_solves_with_and_without_ignore_nan(self): + """Test that ignore_nan doesn't persist across solves.""" + x = cp.Variable() + prob = cp.Problem(cp.Minimize(x), [x >= cp.Constant(np.inf)]) + + if cp.SCS in cp.installed_solvers(): + # First solve with ignore_nan=True - should not raise our error + try: + prob.solve(solver=cp.SCS, ignore_nan=True) + except Exception: + pass # Solver errors are expected + + # Second solve without ignore_nan - SHOULD raise our error + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.SCS) + + def test_clarabel_with_inf(self): + """Test that Clarabel also catches Inf in problem data.""" + x = cp.Variable() + prob = cp.Problem(cp.Minimize(x), [x >= cp.Constant(np.inf)]) + + if cp.CLARABEL in cp.installed_solvers(): + with pytest.raises(ValueError, match="contains NaN or Inf"): + prob.solve(solver=cp.CLARABEL) diff --git a/cvxpy/utilities/scopes.py b/cvxpy/utilities/scopes.py index 63f4197ffd..eed422b8b0 100644 --- a/cvxpy/utilities/scopes.py +++ b/cvxpy/utilities/scopes.py @@ -17,6 +17,7 @@ from typing import Generator _dpp_scope_active = False +_ignore_nan_scope_active = False @contextlib.contextmanager @@ -47,3 +48,30 @@ def dpp_scope() -> Generator[None, None, None]: def dpp_scope_active() -> bool: """Returns True if a `dpp_scope` is active. """ return _dpp_scope_active + + +@contextlib.contextmanager +def ignore_nan_scope() -> Generator[None, None, None]: + """Context manager to disable NaN/Inf validation during problem solving. + + When this scope is active, `ParamConeProg.apply_parameters()` will not + raise an error if problem data contains NaN or Inf values. This is useful + when solving problems where NaN/Inf values are intentional (e.g., for + testing solver robustness). + + Example: + with ignore_nan_scope(): + problem.solve() # No error even if data contains NaN/Inf + """ + global _ignore_nan_scope_active + prev_state = _ignore_nan_scope_active + _ignore_nan_scope_active = True + try: + yield + finally: + _ignore_nan_scope_active = prev_state + + +def ignore_nan_scope_active() -> bool: + """Returns True if an `ignore_nan_scope` is active.""" + return _ignore_nan_scope_active