Fix #3173: bound the objective perturbation when treating a near-fixed column as fixed#3174
Open
EamonHetherton wants to merge 2 commits into
Open
Fix #3173: bound the objective perturbation when treating a near-fixed column as fixed#3174EamonHetherton wants to merge 2 commits into
EamonHetherton wants to merge 2 commits into
Conversation
…mn ignoring its cost
HPresolve::checkColBounds() treats a column as fixed when its bounds are equal
only within the feasibility tolerance. The condition bounds the resulting
perturbation of the row activities:
getMaxAbsColVal(col) * boundDiff <= primal_feastol
but not the perturbation of the objective. A column can be nearly fixed in the
constraints while still being worth a great deal in the objective, and
colPresolve() then pins it at its lower bound, losing up to
|cost| * (ub - lb).
On the model added here that column has
lb 1.3502174835205079
ub 1.3502175177853328
boundDiff 3.426482e-08 <= mip_feasibility_tolerance, so "fixed"
maxAbsColVal 0 <- empty column, the guard above is vacuous
cost -72859132125.478302
so |cost| * boundDiff = 2496.5050 against an observed objective error of
2496.5054. Presolve reduces the model to empty, reports Presolve: Optimal and
returns -37333.0762329 where the optimum is -39829.5816585168 - 6.3% out, with
default options and no branch-and-bound nodes.
The optimum was verified independently of the MIP search by enumerating all
2^8 binary assignments and solving the resulting LP for each.
This commit adds the test and the instance only, so the failure is visible on
its own. The fix follows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…turbation is negligible checkColBounds() declares a column fixed when its bounds are equal to within the feasibility tolerance. The condition bounded the perturbation of the row activities that fixing it causes, but not the perturbation of the objective, and colPresolve() then pins the column at its lower bound. Require the objective term to be negligible as well. For an empty column the row activity term is zero whatever boundDiff is, so without this the column was always treated as fixed here, pre-empting emptyCol(), which is what fixes an empty column at the bound that is best for the objective. With the change such a column falls through to emptyCol() and is fixed at the correct bound. On the model added in the previous commit the objective error drops from 2496.5054 to 2.4e-5 (6e-10 relative), and the issue-3173 test passes. The full unit test suite passes, and all 35 MIP models in check/instances return an identical status, objective, node count and LP iteration count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collaborator
|
It's worth noting that HiGHS differs from Gurobi here. julia> optimize!(model)
Running HiGHS 1.15.1 (git hash: 04024d701f): Copyright (c) 2026 under MIT licence terms
Includes third-party software components, see THIRD_PARTY_NOTICES.md for full details
Using BLAS: Apple Accelerate
MIP has 40 rows; 33 cols; 138 nonzeros; 8 integer variables (8 binary)
Coefficient ranges:
Matrix [5e-01, 3e+02]
Cost [1e-06, 1e+06]
Bound [1e+00, 1e+03]
RHS [1e+00, 4e+01]
WARNING: Problem has some excessively small costs
Presolving model
38 rows, 30 cols, 114 nonzeros 0s
28 rows, 25 cols, 98 nonzeros 0s
23 rows, 20 cols, 82 nonzeros 0s
17 rows, 14 cols, 50 nonzeros 0s
13 rows, 11 cols, 41 nonzeros 0s
11 rows, 8 cols, 24 nonzeros 0s
0 rows, 0 cols, 0 nonzeros 0s
Presolve reductions: rows 0(-40); columns 0(-33); nonzeros 0(-138) - Reduced to empty
Presolve: Optimal
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% -37333.076244 -37333.076244 0.00% 0 0 0 0 0.0s
Solving report
Status Optimal
Primal bound -37333.0762443
Dual bound -37333.0762443
Gap 0% (tolerance: 0.01%)
P-D integral 0
Solution status feasible
-37333.0762504 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.00
0.00 (Presolve)
0.00 (Solve)
0.00 (Postsolve)
Max sub-MIP depth 0
Nodes 0
Repair LPs 0
LP iterations 0
julia> exit()
(base) odow@Mac SDDP % gurobi_cl ~/Downloads/3173-1.mps
Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 722777
Set parameter LogFile to value "gurobi.log"
Using license file /Users/odow/gurobi.lic
WLS license 722777 - registered to JuMP Development
Gurobi Optimizer version 12.0.3 build v12.0.3rc0 (mac64[arm] - Darwin 25.5.0 25F80)
Copyright (c) 2025, Gurobi Optimization, LLC
Read MPS format model from file /Users/odow/Downloads/3173-1.mps
Reading time = 0.00 seconds
HIGHSBUG: 40 rows, 33 columns, 138 nonzeros
Using Gurobi shared library /Library/gurobi1203/macos_universal2/lib/libgurobi120.dylib
CPU model: Apple M4
Thread count: 10 physical cores, 10 logical processors, using up to 10 threads
WLS license 722777 - registered to JuMP Development
Optimize a model with 40 rows, 33 columns and 138 nonzeros
Model fingerprint: 0x5b1968c2
Variable types: 25 continuous, 8 integer (0 binary)
Coefficient statistics:
Matrix range [5e-01, 3e+02]
Objective range [1e-06, 1e+06]
Bounds range [1e+00, 1e+03]
RHS range [1e+00, 4e+01]
Found heuristic solution: objective -13807.35189
Found heuristic solution: objective -14074.01856
Presolve removed 40 rows and 33 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 1 (of 10 available processors)
Solution count 4: -39829.6 -39829.6 -14074 -13807.4
No other solutions better than -39829.6
Optimal solution found (tolerance 1.00e-04)
Best objective -3.982958165852e+04, best bound -3.982958165852e+04, gap 0.0000% |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses #3173.
The first commit adds the reproducer as a failing regression test, on its own, so the defect is
visible without any fix applied. The second commit contains a candidate change. They are separate
deliberately: the test is the part I am confident about, and the threshold in the fix is a
judgement call I would rather you made — see below. Taking only the first commit is a perfectly
reasonable outcome.
Provenance
This was not encountered in a real model. It turned up in a randomly generated one, from a
generator written to produce minimal reproducers for #3170 and #3171, and the condition is reached
zero times on the two real models that motivated those. So this is a defect found by construction
rather than one observed in practice, and that is worth weighing when deciding how much churn it
justifies.
The counterweight: the column being fixed has a cost of
-7.29e10, but the model's own cost rangeis
[1e-06, 1e+06]. The coefficient is about 73,000x larger than anything in the input, built upby presolve's own substitutions before the column reaches
checkColBounds(). Reaching this statedoes not require an extreme cost in the MPS file, only an awkward one plus a few substitutions.
What the defect is
HPresolve::checkColBounds()treats a column as fixed when its bounds are equal only within thefeasibility tolerance. The condition bounds the resulting perturbation of the row activities:
getMaxAbsColVal(col) * boundDiff <= primal_feastolbut not the perturbation of the objective, and
colPresolve()then pins the column at its lowerbound. On the model added here:
|cost| * boundDiff = 2496.5050against an observed objective error of2496.5054. Presolvereduces the model to empty, reports
Presolve: Optimal, and returns a value 6.3% away from theoptimum with default options and no branch-and-bound nodes.
The optimum was verified independently of the MIP search, by enumerating all 2^8 binary assignments
and solving the resulting LP for each.
The candidate change
Require the objective perturbation to be negligible as well:
The column then falls through to
emptyCol(), which fixes it at the cost-optimal bound. The errordrops from 2496.5 to 2.4e-5 (6e-10 relative) and the regression test passes.
Why the threshold is an open question
primal_feastolis a feasibility tolerance being used to bound an objective quantity. It isconvenient and symmetric with the row term, not principled. Alternatives, none obviously right:
mip_abs_gapis dimensionally correct for a MIP, but meaningless for an LP, and userslegitimately set it to 0, which would disable the reduction unconditionally;
reliable objective scale available at that point in presolve;
emptyCol()whenevercolsize[col] == 0, before the tolerance check, fixes theempty-column case specifically but not a non-empty column with a large cost and a small
boundDiff, where the row-activity term is satisfied and|cost| * boundDiffis still large.Detection is insensitive to the choice: the failing case exceeds any candidate threshold by about
nine orders of magnitude. The risk is in the other direction, suppressing reductions that were
doing useful work.
How often the condition is reached, and what suppressing it costs
I instrumented the condition and counted every column reaching it with
boundDiff > 0and therow-activity term already satisfied.
Small models — 35 MIP models from
check/instances, 400 generated MIPs, and this reproducer,435 in total. Only 11 columns reach it at all. Their
|cost| * boundDiff:primal_feastolblocks 10 of those 11. Across all 435 models, status, objective, node count and LPiteration count are identical with and without the extra condition.
Larger models — two MIPs of 13724 x 24810 and 10404 x 18899. The condition is reached zero
times on both, and presolve reduction counts, node counts and LP iteration counts are identical
with and without the change:
What that does and does not establish. On the larger models the change is inert — the
condition never fires, so those runs say nothing about the cost of suppressing the reduction, only
that the change does not perturb models where the situation does not arise. The evidence that
suppression is free comes only from the small models, where it is free but the models are small.
So I cannot rule out a performance cost on a large model where this reduction does fire and is
load-bearing. I have not benchmarked against MIPLIB or anything at production scale. What the
counts do suggest is that the situation is rare: 11 occurrences in 435 small models and none in two
models of ~10^4 rows. That bounds how often any cost could be incurred, not how large it might be
when it is.
If that matters for the decision, a run over your own benchmark set would settle it, and I am happy
to adjust the threshold to whatever you would prefer.
A caveat on the change itself
Bounding
|cost| * boundDiffbounds only the direct objective error. Fixing a column alsoperturbs row activities, and downstream that can flip an integer decision worth far more than the
bound suggests. The condition is necessary; I would not claim it is sufficient.
An approach I would advise against
Pinning at the cost-optimal bound instead of always the lower one. It fixes this model, but only
converts a pessimistic error into an optimistic one of the same size, i.e. a dual bound better than
anything actually achievable, which is worse.
Testing
issue-3173test fails onlatestat the first commit and passes at the second;LP iteration count.
🤖 Generated with Claude Code