Skip to content

Commit ddff3b0

Browse files
committed
Add pre-solve scaling support and docs
1 parent b3a4bfd commit ddff3b0

8 files changed

Lines changed: 547 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
* Use **lazy operations** for large linear programs with [dask](https://dask.org/)
3939
* Choose from **different commercial and non-commercial solvers**
4040
* Fast **import and export** a linear model using xarray's netcdf IO
41+
* Optional **pre-solve scaling** of constraints (and continuous variables) to improve numerical robustness
4142

4243

4344
## Installation

doc/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ This package is published under MIT license.
110110
creating-expressions
111111
creating-constraints
112112
manipulating-models
113+
scaling
113114
testing-framework
114115
transport-tutorial
115116
infeasible-model

doc/scaling.rst

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
.. _scaling:
2+
3+
Pre-solve Scaling
4+
=================
5+
6+
Linopy can rescale your model before handing it to a solver. Scaling can
7+
improve numerical robustness when constraint coefficients or bounds span
8+
different orders of magnitude.
9+
10+
How it works
11+
------------
12+
13+
- **Row scaling** (default) rescales constraint rows by their largest (or
14+
RMS) coefficient and applies the same factor to the RHS and duals.
15+
- **Column scaling** (optional) rescales continuous variables so column
16+
norms are close to 1. Bounds, objective coefficients, and primal values
17+
are adjusted accordingly. Integer/binary variables stay unscaled by default
18+
to preserve integrality.
19+
- Scaling is undone automatically on primal/dual values and the objective
20+
before they are stored on the model.
21+
22+
API
23+
---
24+
25+
Enable scaling via ``Model.solve(scale=...)``:
26+
27+
.. code-block:: python
28+
29+
import pandas as pd
30+
import linopy
31+
from linopy.scaling import ScaleOptions
32+
33+
m = linopy.Model()
34+
35+
hours = pd.RangeIndex(24, name="h")
36+
p = m.add_variables(lower=0, name="prod", coords=[hours])
37+
38+
# Coefficients with very different magnitudes
39+
m.add_constraints(1e3 * p <= 5e4, name="capacity")
40+
m.add_constraints(0.01 * p.sum() >= 10, name="energy")
41+
42+
# Default: row scaling using max-norm
43+
m.solve(scale=True)
44+
45+
# Custom: row+column scaling on continuous variables, RMS norm
46+
m.solve(
47+
scale=ScaleOptions(
48+
enabled=True,
49+
method="row-l2",
50+
variable_scaling=True,
51+
scale_integer_variables=False,
52+
)
53+
)
54+
55+
Options
56+
-------
57+
58+
``scale`` accepts:
59+
60+
- ``False`` / ``None``: disable scaling (default behavior prior to this feature)
61+
- ``True``: enable row scaling with max-norm
62+
- ``"row-max"`` or ``"row-l2"``: select norm for row scaling
63+
- ``ScaleOptions``: full control (row/column scaling, integer handling,
64+
target magnitude, zero-floor)
65+
66+
Notes
67+
-----
68+
69+
- Remote execution currently disables scaling.
70+
- Many solvers also perform internal scaling; Linopy’s scaling is optional
71+
and aims to help when you need deterministic pre-conditioning on the
72+
problem you pass to the solver.

linopy/io.py

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@
2525
from linopy import solvers
2626
from linopy.constants import CONCAT_DIM
2727
from linopy.objective import Objective
28+
from linopy.scaling import ScalingContext
2829

2930
if TYPE_CHECKING:
3031
from highspy.highs import Highs
3132

33+
from linopy.matrices import MatrixAccessor
3234
from linopy.model import Model
35+
from linopy.scaling import ScaledMatrices
3336

3437

3538
logger = logging.getLogger(__name__)
@@ -162,6 +165,7 @@ def objective_to_file(
162165
f: BufferedWriter,
163166
progress: bool = False,
164167
explicit_coordinate_names: bool = False,
168+
scaling: ScalingContext | None = None,
165169
) -> None:
166170
"""
167171
Write out the objective of a model to a lp file.
@@ -176,6 +180,16 @@ def objective_to_file(
176180
sense = m.objective.sense
177181
f.write(f"{sense}\n\nobj:\n\n".encode())
178182
df = m.objective.to_polars()
183+
if scaling is not None and getattr(scaling, "col_inv", None) is not None:
184+
scale_map = pl.DataFrame(
185+
{"vars": pl.Series(scaling.matrices.vlabels), "col_inv": scaling.col_inv}
186+
)
187+
df = (
188+
df.join(scale_map, on="vars", how="left")
189+
.fill_null(1.0)
190+
.with_columns(pl.col("coeffs") * pl.col("col_inv"))
191+
.drop("col_inv")
192+
)
179193

180194
if m.is_linear:
181195
objective_write_linear_terms(f, df, print_variable)
@@ -200,6 +214,7 @@ def bounds_to_file(
200214
progress: bool = False,
201215
slice_size: int = 2_000_000,
202216
explicit_coordinate_names: bool = False,
217+
scaling: ScalingContext | None = None,
203218
) -> None:
204219
"""
205220
Write out variables of a model to a lp file.
@@ -224,6 +239,24 @@ def bounds_to_file(
224239
var = m.variables[name]
225240
for var_slice in var.iterate_slices(slice_size):
226241
df = var_slice.to_polars()
242+
if scaling is not None:
243+
scale_map = pl.DataFrame(
244+
{
245+
"labels": pl.Series(scaling.matrices.vlabels),
246+
"col_scale": scaling.col_scale,
247+
}
248+
)
249+
df = (
250+
df.join(scale_map, on="labels", how="left")
251+
.fill_null(1.0)
252+
.with_columns(
253+
[
254+
pl.col("lower") * pl.col("col_scale"),
255+
pl.col("upper") * pl.col("col_scale"),
256+
]
257+
)
258+
.drop("col_scale")
259+
)
227260

228261
columns = [
229262
pl.when(pl.col("lower") >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
@@ -334,6 +367,7 @@ def constraints_to_file(
334367
lazy: bool = False,
335368
slice_size: int = 2_000_000,
336369
explicit_coordinate_names: bool = False,
370+
scaling: ScalingContext | None = None,
337371
) -> None:
338372
if not len(m.constraints):
339373
return
@@ -358,6 +392,32 @@ def constraints_to_file(
358392
for con_slice in con.iterate_slices(slice_size):
359393
df = con_slice.to_polars()
360394

395+
if scaling is not None:
396+
row_map = pl.DataFrame(
397+
{
398+
"labels": pl.Series(scaling.matrices.clabels),
399+
"row_scale": scaling.row_scale,
400+
}
401+
)
402+
col_map = pl.DataFrame(
403+
{
404+
"vars": pl.Series(scaling.matrices.vlabels),
405+
"col_inv": scaling.col_inv,
406+
}
407+
)
408+
df = (
409+
df.join(row_map, on="labels", how="left")
410+
.join(col_map, on="vars", how="left")
411+
.fill_null(1.0)
412+
.with_columns(
413+
[
414+
pl.col("coeffs") * pl.col("row_scale") * pl.col("col_inv"),
415+
pl.col("rhs") * pl.col("row_scale"),
416+
]
417+
)
418+
.drop(["row_scale", "col_inv"])
419+
)
420+
361421
if df.height == 0:
362422
continue
363423

@@ -428,26 +488,33 @@ def to_lp_file(
428488
slice_size: int = 2_000_000,
429489
progress: bool = True,
430490
explicit_coordinate_names: bool = False,
491+
scaling: ScalingContext | None = None,
431492
) -> None:
432493
with open(fn, mode="wb") as f:
433494
start = time.time()
434495

435496
objective_to_file(
436-
m, f, progress=progress, explicit_coordinate_names=explicit_coordinate_names
497+
m,
498+
f,
499+
progress=progress,
500+
explicit_coordinate_names=explicit_coordinate_names,
501+
scaling=scaling,
437502
)
438503
constraints_to_file(
439504
m,
440505
f=f,
441506
progress=progress,
442507
slice_size=slice_size,
443508
explicit_coordinate_names=explicit_coordinate_names,
509+
scaling=scaling,
444510
)
445511
bounds_to_file(
446512
m,
447513
f=f,
448514
progress=progress,
449515
slice_size=slice_size,
450516
explicit_coordinate_names=explicit_coordinate_names,
517+
scaling=scaling,
451518
)
452519
binaries_to_file(
453520
m,
@@ -477,6 +544,7 @@ def to_file(
477544
slice_size: int = 2_000_000,
478545
progress: bool | None = None,
479546
explicit_coordinate_names: bool = False,
547+
scaling: ScalingContext | None = None,
480548
) -> Path:
481549
"""
482550
Write out a model to a lp or mps file.
@@ -502,6 +570,7 @@ def to_file(
502570
slice_size=slice_size,
503571
progress=progress,
504572
explicit_coordinate_names=explicit_coordinate_names,
573+
scaling=scaling,
505574
)
506575

507576
elif io_api == "mps":
@@ -512,7 +581,10 @@ def to_file(
512581

513582
# Use very fast highspy implementation
514583
# Might be replaced by custom writer, however needs C/Rust bindings for performance
515-
h = m.to_highspy(explicit_coordinate_names=explicit_coordinate_names)
584+
h = m.to_highspy(
585+
explicit_coordinate_names=explicit_coordinate_names,
586+
matrices=scaling.matrices if scaling else None,
587+
)
516588
h.writeModel(str(fn))
517589
else:
518590
raise ValueError(
@@ -523,7 +595,10 @@ def to_file(
523595

524596

525597
def to_mosek(
526-
m: Model, task: Any | None = None, explicit_coordinate_names: bool = False
598+
m: Model,
599+
task: Any | None = None,
600+
explicit_coordinate_names: bool = False,
601+
matrices: MatrixAccessor | ScaledMatrices | None = None,
527602
) -> Any:
528603
"""
529604
Export model to MOSEK.
@@ -552,7 +627,7 @@ def to_mosek(
552627
task.appendvars(m.nvars)
553628
task.appendcons(m.ncons)
554629

555-
M = m.matrices
630+
M = m.matrices if matrices is None else matrices
556631
# for j, n in enumerate(("x" + M.vlabels.astype(str).astype(object))):
557632
# task.putvarname(j, n)
558633

@@ -635,7 +710,10 @@ def to_mosek(
635710

636711

637712
def to_gurobipy(
638-
m: Model, env: Any | None = None, explicit_coordinate_names: bool = False
713+
m: Model,
714+
env: Any | None = None,
715+
explicit_coordinate_names: bool = False,
716+
matrices: MatrixAccessor | ScaledMatrices | None = None,
639717
) -> Any:
640718
"""
641719
Export the model to gurobipy.
@@ -662,7 +740,7 @@ def to_gurobipy(
662740
m.constraints.sanitize_missings()
663741
model = gurobipy.Model(env=env)
664742

665-
M = m.matrices
743+
M = m.matrices if matrices is None else matrices
666744

667745
names = np.vectorize(print_variable)(M.vlabels).astype(object)
668746
kwargs = {}
@@ -687,7 +765,11 @@ def to_gurobipy(
687765
return model
688766

689767

690-
def to_highspy(m: Model, explicit_coordinate_names: bool = False) -> Highs:
768+
def to_highspy(
769+
m: Model,
770+
explicit_coordinate_names: bool = False,
771+
matrices: MatrixAccessor | ScaledMatrices | None = None,
772+
) -> Highs:
691773
"""
692774
Export the model to highspy.
693775
@@ -710,7 +792,7 @@ def to_highspy(m: Model, explicit_coordinate_names: bool = False) -> Highs:
710792
m, explicit_coordinate_names=explicit_coordinate_names
711793
)
712794

713-
M = m.matrices
795+
M = m.matrices if matrices is None else matrices
714796
h = highspy.Highs()
715797
h.addVars(len(M.vlabels), M.lb, M.ub)
716798
if len(m.binaries) + len(m.integers):

0 commit comments

Comments
 (0)