PyOptInterface

repository·master·Indexed 43 days ago

https://github.com/metab0t/pyoptinterface

A high-performance Python library providing a unified API for constructing and solving optimization models. It acts as a thin wrapper over native C APIs to ensure low overhead and fast model construction. Version 0.6.1 supports various problem types including LP, MILP, QP, MIQP, SOCP, and NLP, with interfaces for commercial solvers (COPT, Gurobi, Xpress, Mosek, KNITRO) and open-source solvers (HiGHS, Ipopt).

Tokens
37.5K
Snippets
130
Records
160
Agent score
52%

What's inside PyOptInterface

  1. Benchmark model construction performance

    master

    PyOptInterface is benchmarked based on its ability to generate optimization models and pass them to solvers. Performance is measured in terms of 'model construction time' (the time taken by the modeling interface to generate the model and hand it off to the optimizer).

    Key performance characteristics observed in benchmarks:

    • Comparison with direct solver APIs: PyOptInterface shows performance competitive with C++ interfaces and significantly faster than direct Python APIs like gurobipy or coptpy for large-scale models.
    • Comparison with other modeling languages: It outperforms Pyomo by orders of magnitude in model construction time. It also shows significant performance advantages over linopy and cvxpy when generating and solving linear programming models.
    • Scalability: The interface maintains high efficiency as the number of variables increases, from tens of thousands to millions of variables.
  2. Supported problem types and optimizers

    master

    PyOptInterface supports a wide range of mathematical programming problems and various commercial and open-source solvers.

    Supported Problem Types

    • Linear Programming (LP)
    • Mixed-Integer Linear Programming (MILP)
    • Quadratic Programming (QP)
    • Mixed-Integer Quadratic Programming (MIQP)
    • Quadratically Constrained Quadratic Programming (QCQP)
    • Mixed-Integer Quadratically Constrained Quadratic Programming (MIQCQP)
    • Second-Order Cone Programming (SOCP)
    • Mixed-Integer Second-Order Cone Programming (MISOCP)
    • Exponential Cone Programming (ECP)
    • Mixed-Integer Exponential Cone Programming (MIECP)
    • Nonlinear Programming (NLP)

    Supported Optimizers

    • Commercial: COPT, Gurobi, Xpress, Mosek, KNITRO
    • Open Source: HiGHS, Ipopt
  3. Understand the Bring Your Own Container (BYOC) principle

    master
    PyOptInterface does not restrict how you store variables, constraints, or expressions. You can use standard Python data structures like lists or dictionaries because the library only requires the object handles (compact C++ objects) to manipulate them. While you can use any container, PyOptInterface provides specialized containers like tupledict for structured multidimensional data.
  4. Define constraint senses

    master

    When creating constraints, you must specify the sense (the inequality or equality relationship). PyOptInterface provides poi.ConstraintSense and several convenient aliases:

    • poi.Eq (alias for poi.ConstraintSense.Equal): ==
    • poi.Leq (alias for poi.ConstraintSense.LessEqual): <=
    • poi.Geq (alias for poi.ConstraintSense.GreaterEqual): >=

    These can be passed to the sense argument of constraint creation methods or used directly with comparison operators in supported methods.

  5. Use `tupledict` for multidimensional data

    master

    A tupledict is a dictionary-like object where keys are n-tuples representing multidimensional indices. It is designed to store and manipulate variables, constraints, or expressions indexed by multiple axes, similar to tupledict in Gurobi or Sets in AMPL.

    Creation methods:

    1. Empty constructor: Initialize and set values manually using [].
    2. Dictionary constructor: Pass an existing dictionary with tuple keys.
    3. make_tupledict: Use a rule-based approach with index sets and a mapping function.
    import pyoptinterface as poi
    
    # Method 1: Manual
    td = poi.tupledict()
    td[1, 2] = 3
    
    # Method 2: From dict
    td = poi.tupledict({(1, 2): 3, (4, 5): 6})
    
    # Method 3: Rule-based
    I = range(3)
    J = [6, 7]
    K = ("Asia", "Europe")
    def f(i, j, k):
        return f"value_{i}_{j}_{k}"
    td = poi.make_tupledict(I, J, K, rule=f)
  6. How callbacks work in PyOptInterface

    master

    A callback is a user-defined Python function used to monitor or intervene in the optimization process (e.g., stopping when a condition is met or recording intermediate results). This is particularly useful for Mixed-Integer Programming (MIP) to control the branch-and-bound process.

    Important Notes:

    • Callback behavior depends heavily on the specific optimizer being used.
    • Callback support is currently limited to Gurobi, COPT, and Xpress.
    • PyOptInterface does not use a strictly unified API for callbacks because they are tightly coupled with the underlying solver; instead, it unifies common parts while aiming to expose all features available in the vendor's Python bindings.

    Callback Function Signature: A callback function must accept two arguments:

    1. model: The optimization model instance.
    2. where: A flag indicating the stage of the optimization process (e.g., CallbackCodes for Gurobi, callback contexts for COPT, or specific callback points for Xpress).
    def my_callback(model, where):
        # Implementation logic
        pass
  7. Use Numpy ndarray as a container for variables and constraints

    master

    PyOptInterface follows the 'Bring Your Own Container' (BYOC) principle. Instead of being restricted to specific container types, you can use standard Numpy ndarray objects to store variables and constraints. This allows you to leverage Numpy features like fancy-indexing and slicing to construct complex optimization models efficiently.

    To use Numpy with PyOptInterface:

    1. Initialize an ndarray with dtype=object to hold the variable objects.
    2. Populate the array using model.add_variable().
    3. Use Numpy slicing (e.g., x[i, :]) or methods (e.g., x.diagonal()) within poi.quicksum() to define constraints.
    import numpy as np
    import pyoptinterface as poi
    from pyoptinterface import highs
    
    model = highs.Model()
    N = 8
    
    # Create a 2D array of binary variables
    x = np.empty((N, N), dtype=object)
    for i in range(N):
        for j in range(N):
            x[i, j] = model.add_variable(domain=poi.VariableDomain.Binary)
    
    # Use Numpy slicing to add constraints
    for i in range(N):
        model.add_linear_constraint(poi.quicksum(x[i, :]), poi.Eq, 1.0)
        model.add_linear_constraint(poi.quicksum(x[:, i]), poi.Eq, 1.0)
    
    model.optimize()
  8. How PyOptInterface loads optimizers

    master

    PyOptInterface uses a Dynamic Loading technique to load the dynamic libraries of optimizers at runtime. This allows you to switch optimizers without recompiling your code.

    There are two ways to configure optimizers:

    1. Automatic detection: The library searches for the optimizer using environment variables, official Python bindings (if available), and system loadable paths (e.g., LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS, or PATH on Windows).
    2. Manual specification: You can explicitly provide the path to the optimizer's dynamic library file (e.g., .so, .dll, or .dylib) using the load_library method.
  9. Use poi.make_tupledict for multidimensional constraints

    master

    When you need to apply the same constraint logic across multiple dimensions (e.g., for every generator $i$ at every time $t$), use poi.make_tupledict.

    This method takes a range of indices and a rule function. The rule function is called for each index (or combination of indices) and should return a constraint object created via model.add_linear_constraint or similar.

    def con(t):
        lhs = poi.quicksum(P[i, t] for i in range(N_G))
        rhs = total_demand[t]
        return model.add_linear_constraint(lhs, poi.Eq, rhs, name=f"powerbalance_{t}")
    
    # Creates a collection of constraints indexed by t
    constraints = poi.make_tupledict(range(T), rule=con)
  10. How the `nl.graph()` context works for nonlinear programming

    master

    In PyOptInterface, nonlinear expressions cannot be constructed in the global scope. You must use the nl.graph() context manager to declare nonlinear expressions. This context allows PyOptInterface to trace the computational graph, enabling automatic differentiation for calculating gradients and Hessians.

    Key Rules:

    • All nonlinear expressions (using nl functions or operators) must be defined inside a with nl.graph(): block.
    • Both model.add_nl_constraint() and model.add_nl_objective() must be called within the same nl.graph() context where the expressions were defined.
    • For performance, especially in large-scale problems with repetitive structures, place the nl.graph() context inside loops so that PyOptInterface can recognize and accelerate the shared structure across iterations.
    import pyoptinterface as poi
    from pyoptinterface import nl, ipopt
    
    model = ipopt.Model()
    x = model.add_variable(name="x")
    
    with nl.graph():
        # Nonlinear expressions must be inside this context
        z = nl.exp(x) * nl.pow(x, 2)
        model.add_nl_constraint(z <= 10.0)
  11. Mix nonlinear and linear/quadratic constraints

    master

    PyOptInterface allows you to mix nonlinear constraints (add_nl_constraint) with linear (add_linear_constraint) and quadratic (add_quadratic_constraint) constraints in the same optimization problem. Linear and quadratic constraints do not require the nl.graph() context, but they can be used alongside nonlinear ones.

    for i in range(N):
        with nl.graph():
            # Nonlinear part
            model.add_nl_constraint(x[i + 1] - x[i] - 0.5 * h * (nl.sin(t[i]) + nl.sin(t[i + 1])) == 0.0)
            model.add_nl_objective(0.5 * h * (u[i]**2 + u[i+1]**2))
        
        # Linear part (can be outside or inside, but doesn't require nl.graph)
        model.add_linear_constraint(t[i + 1] - t[i] - 0.5 * h * u[i + 1] - 0.5 * h * u[i] == 0.0)
  12. Decompose and compose large optimization models

    master

    To build large, maintainable optimization models in PyOptInterface, you should separate the model management from the problem data. The recommended pattern involves two components:

    1. A model object: A thin wrapper around the optimizer API (e.g., highs.Model()) that manages variables, constraints, and the objective function. It does not store the problem state itself.
    2. A container: A dict-like object to store references to variables and constraints. Using types.SimpleNamespace is recommended as it allows for easy attribute access (e.g., container.x).

    You can decompose the modeling process by defining small, specialized functions that take a custom class (containing both the model and the container) as an argument. These functions can then be composed to build the full problem.

    import types
    import numpy as np
    import pyoptinterface as poi
    from pyoptinterface import highs
    
    # 1. Define a class to hold the model and the container
    class OptModel:
        def __init__(self):
            self.container = types.SimpleNamespace()
            self.model = highs.Model()
    
    # 2. Define modular functions for variables and constraints
    def declare_variables(m: OptModel, N):
        model = m.model
        x = np.empty((N, N), dtype=object)
        for i in range(N):
            for j in range(N):
                x[i, j] = model.add_variable(domain=poi.VariableDomain.Binary)
        m.container.x = x
        m.container.N = N
    
    def add_constraints(m: OptModel):
        container = m.container
        x = container.x
        model = m.model
        N = container.N
        # Example: Row constraints
        for i in range(N):
            model.add_linear_constraint(poi.quicksum(x[i, :]), poi.Eq, 1.0)
    
    # 3. Compose and solve
    m = OptModel()
    declare_variables(m, 8)
    add_constraints(m)
    m.model.optimize()
    
    print("Termination status:", m.model.get_model_attribute(poi.ModelAttribute.TerminationStatus))