Skip to content

Add MIP start support to the SCIP interface - #3483

Merged
Transurgeon merged 2 commits into
cvxpy:masterfrom
nicoloangileri:feat/scip-mip-start
Aug 19, 2026
Merged

Transurgeon merged 2 commits into
cvxpy:masterfrom
nicoloangileri:feat/scip-mip-start

Conversation

@nicoloangileri

Copy link
Copy Markdown
Contributor

Description

SCIP.solve_via_data accepts a warm_start flag and ignores it, so there is currently no way to hand SCIP an incumbent from Python. GUROBI already supports this through data['init_value']; this brings SCIP in line.

apply() collects Variable.value through utilities.stack_vals — the same helper GUROBI uses — and solve_via_data registers the values before the constraints are added.

Two things differ from the GUROBI path, both forced by SCIP's API:

  • The start is registered with createPartialSol() rather than createSol(). Users typically set values on a subset of their variables, and SCIP's completesol heuristic is built to fill in the rest. GUROBI uses GRB.UNDEFINED as its "not provided" sentinel; SCIP has no equivalent, so NaN is what lets the interface tell an unset entry apart from a genuine zero.
  • addSol() rather than trySol(). At the point where CVXPY builds the model SCIP is still in the problem creation stage, and trySol raises method cannot be called at this time in solution process there.

Verification that the values reach the solver, rather than being silently discarded — from SCIP's own statistics output:

without a start:  completesol : 0  0  0
with a start:     completesol : 1  1  1
                  Primal Bound: ... found by <completesol>

The heuristic is never called without a start, is called once with one, and the resulting incumbent is attributed to it.

Caveat, stated plainly: I could not measure a speedup on the instances I tried — SCIP's own heuristics found better incumbents than the greedy ones I supplied. This exposes a capability that is currently not expressible at all; it is not a performance improvement by default.

Compatibility: behaviour changes only when warm_start=True and variable values are set, which previously did nothing for SCIP. The optimum is unaffected (covered by tests); the search path may differ.

Scope note: this covers the Variable.value path from #849. Re-using a previous solve via solver_cache, which GUROBI also implements, is not addressed here — the SCIP interface does not populate solver_cache at all.

Partial fix for #849

Type of change

  • New feature (backwards compatible)
  • New feature (breaking API changes)
  • Bug fix
  • Other (Documentation, CI, ...)

Contribution checklist

  • Add our license to new files. — N/A, no new files
  • Check that your code adheres to our coding style. — ruff 0.8.1 (the pre-commit pin) reports no findings
  • Write unittests. — four, in TestSCIP: init_value contents, that a start does not change the optimum, warm_start with no values set, and an unusable start
  • Run the unittests and check that they're passing. — 45/45 SCIP, 142/142 in test_conic_solvers.py, no regressions
  • Run the benchmarks to make sure your change doesn't introduce a regression. — not run locally: the benchmark suite exercises the default solvers and does not touch the SCIP interface. The CI benchmark job will cover it.

SCIP's solve_via_data accepted a warm_start flag and ignored it, so there
was no way to hand SCIP an incumbent from Python. GUROBI already supports
this via data['init_value']; this brings SCIP in line.

apply() now collects Variable.value through utilities.stack_vals with NaN
marking entries the user did not set, and solve_via_data registers them
before the constraints are added.

Two design points:

- The start is registered with createPartialSol() rather than createSol().
  Users typically set values on a subset of their variables, and SCIP's
  completesol heuristic is built to fill in the rest; NaN is what lets the
  interface tell 'unset' apart from a genuine zero.
- addSol() is used rather than trySol(). At the point where CVXPY builds
  the model SCIP is still in the problem creation stage, and trySol raises
  'method cannot be called at this time in solution process' there.

Verified through SCIP's own statistics that the values reach the solver:
the completesol heuristic is never called without a start and is called
once with one, and the resulting incumbent is attributed to it.

Partial fix for cvxpy#849
Comment on lines +2609 to +2615
def test_scip_mip_start(self) -> None:
"""A MIP start does not change the optimum."""
prob, x, y = self.get_mip_problem()
expected = prob.solve(solver=cp.SCIP, warm_start=False)

x.value = np.array([2.0, 3.0])
assert prob.solve(solver=cp.SCIP, warm_start=True) == expected

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

maybe you could test that the number of iterations decreases? I am not sure if there is an easy way to get the number of iterations for SCIP though.. it differs from solver to solver.

Comment on lines +2624 to +2628
def test_scip_mip_start_infeasible_values(self) -> None:
"""An unusable start is discarded by SCIP, not propagated."""
prob, x, y = self.get_mip_problem()
x.value = np.array([99.0, 99.0])
assert prob.solve(solver=cp.SCIP, warm_start=True) == 9.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this test is not really showing that the warm-start is being used or disregarded. Could we maybe try to show a log output? I guess this is not trivial, but could be worth giving it a try?

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Benchmarks that have stayed the same:

Change Before [da13491] After [2120baa] Ratio Benchmark (Parameter)
21.0±0.02s 21.5±0.1s 1.03 sdp_segfault_1132_benchmark.SDPSegfault1132Benchmark.time_compile_problem
1.85±0.04s 1.90±0.03s 1.03 simple_LP_benchmarks.SimpleScalarParametrizedLPBenchmark.time_compile_problem
1.72±0.01s 1.75±0.01s 1.02 semidefinite_programming.SemidefiniteProgramming.time_compile_problem
2.87±0.01s 2.91±0.01s 1.02 simple_QP_benchmarks.ParametrizedQPBenchmark.time_compile_problem
2.47±0.01s 2.52±0.02s 1.02 slow_pruning_1668_benchmark.SlowPruningBenchmark.time_compile_problem
4.53±0.02s 4.61±0.03s 1.02 svm_l1_regularization.SVMWithL1Regularization.time_compile_problem
1.48±0.01s 1.52±0.02s 1.02 tv_inpainting.TvInpainting.time_compile_problem
160±2ms 161±1ms 1.01 high_dim_convex_plasticity.ConvexPlasticity.time_compile_problem
3.40±0.02s 3.42±0.02s 1.01 matrix_stuffing.ConeMatrixStuffingBench.time_compile_problem
4.31±0.02s 4.35±0s 1.01 optimal_advertising.OptimalAdvertising.time_compile_problem
720±4ms 724±1ms 1.01 simple_LP_benchmarks.SimpleFullyParametrizedLPBenchmark.time_compile_problem
10.6±0.01s 10.7±0.01s 1.01 simple_LP_benchmarks.SimpleLPBenchmark.time_compile_problem
2.85±0.05s 2.84±0.02s 1 gini_portfolio.Cajas.time_compile_problem
3.99±0.04s 4.00±0.02s 1 huber_regression.HuberRegression.time_compile_problem
2.37±0.01s 2.37±0.01s 1 matrix_stuffing.SmallMatrixStuffing.time_compile_problem
3.09±0.01s 3.07±0.03s 1 quantum_hilbert_matrix.QuantumHilbertMatrix.time_compile_problem
4.30±0.04s 4.32±0.08s 1 simple_QP_benchmarks.SimpleQPBenchmark.time_compile_problem
3.71±0.02s 3.72±0s 1 simple_QP_benchmarks.UnconstrainedQP.time_compile_problem
12.7±0.01s 12.6±0.03s 0.99 finance.CVaRBenchmark.time_compile_problem
1.22±0.02s 1.21±0.02s 0.99 gini_portfolio.Murray.time_compile_problem
1.35±0.02s 1.33±0.03s 0.99 gini_portfolio.Yitzhaki.time_compile_problem
1.37±0s 1.36±0.01s 0.99 matrix_stuffing.ParamConeMatrixStuffing.time_compile_problem
3.64±0.04s 3.56±0.04s 0.98 matrix_stuffing.ParamSmallMatrixStuffing.time_compile_problem
2.15±0.05s 2.12±0.03s 0.98 simple_QP_benchmarks.LeastSquares.time_compile_problem
1.96±0.04s 1.91±0.02s 0.97 finance.FactorCovarianceModel.time_compile_problem

Review feedback: the tests showed that the optimum was unchanged, but not
that the start was used rather than discarded.

SCIP's model is reachable from Python through solver_stats.extra_stats,
so its statistics can be read back. The completesol heuristic exists to
complete a partial start, and its call count separates the two cases:
both solves pass warm_start=True and differ only in whether the user set
any variable values, so it runs in one and not the other.

The two robustness tests now assert the same counter, which also pins
down what happens to an unusable start: SCIP is offered it, tries to
complete it, and the result is unaffected.
@nicoloangileri

Copy link
Copy Markdown
Contributor Author

I tried this and I don't think it can carry a test. getNLPIterations is available — CVXPY already stores it as NUM_ITERS — but on a problem small enough for the suite the count is identical with and without a start, and on larger instances it moves in both directions: on a 250-variable multi-knapsack I measured 288 nodes without a start vs 271 with one under a 1s limit, and 1126 vs 1171 under 3s. SCIP's own heuristics usually find a better incumbent than the one supplied, so any threshold would be flaky.

I went with the completesol call count instead — see the other thread.

@nicoloangileri

Copy link
Copy Markdown
Contributor Author

Agreed, and it turned out to be doable without parsing logs. The SCIP model is reachable from Python via prob.solver_stats.extra_stats["model"], so its statistics can be read back with writeStatistics. The completesol heuristic exists specifically to complete a partial start, so its call count separates the two cases:

without values set:  completesol : 0  0  0
with values set:     completesol : 1  1  1

test_scip_mip_start_reaches_solver asserts exactly that. Both solves pass warm_start=True and differ only in whether any variable values were set, so the counter isolates the start itself rather than anything else in the pipeline.

I added the same assertion to the two robustness tests, which also pins down what happens to an unusable start: SCIP is offered it, tries to complete it, and the result is unaffected.

@Transurgeon Transurgeon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

@Transurgeon
Transurgeon merged commit ae0fbff into cvxpy:master Aug 19, 2026
52 of 54 checks passed
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