From d510b97621d658414e11af7dc5dae0005aad5cdd Mon Sep 17 00:00:00 2001 From: Laurent Perron Date: Wed, 9 Feb 2022 10:48:30 +0100 Subject: [PATCH] add pdlp to the code base; build it with bazel, call it through MPSolver --- WORKSPACE | 20 + ortools/base/BUILD.bazel | 2 +- ortools/linear_solver/BUILD.bazel | 5 + ortools/linear_solver/CMakeLists.txt | 1 + ortools/linear_solver/linear_solver.cc | 3 + ortools/linear_solver/linear_solver.h | 8 + ortools/linear_solver/linear_solver.proto | 28 + ortools/linear_solver/model_validator.cc | 140 ++ ortools/linear_solver/pdlp_interface.cc | 328 +++ ortools/linear_solver/sat_interface.cc | 1 + ortools/pdlp/BUILD.bazel | 387 ++++ ortools/pdlp/gtest_main.cc | 23 + ortools/pdlp/iteration_stats.cc | 552 +++++ ortools/pdlp/iteration_stats.h | 108 + ortools/pdlp/iteration_stats_test.cc | 281 +++ ortools/pdlp/primal_dual_hybrid_gradient.cc | 2005 +++++++++++++++++ ortools/pdlp/primal_dual_hybrid_gradient.h | 166 ++ .../pdlp/primal_dual_hybrid_gradient_test.cc | 1458 ++++++++++++ ortools/pdlp/quadratic_program.cc | 366 +++ ortools/pdlp/quadratic_program.h | 216 ++ ortools/pdlp/quadratic_program_io.cc | 114 + ortools/pdlp/quadratic_program_io.h | 51 + ortools/pdlp/quadratic_program_test.cc | 488 ++++ ortools/pdlp/sharded_optimization_utils.cc | 797 +++++++ ortools/pdlp/sharded_optimization_utils.h | 191 ++ .../pdlp/sharded_optimization_utils_test.cc | 616 +++++ ortools/pdlp/sharded_quadratic_program.cc | 155 ++ ortools/pdlp/sharded_quadratic_program.h | 91 + .../pdlp/sharded_quadratic_program_test.cc | 84 + ortools/pdlp/sharder.cc | 320 +++ ortools/pdlp/sharder.h | 330 +++ ortools/pdlp/sharder_test.cc | 496 ++++ ortools/pdlp/solve_log.proto | 377 ++++ ortools/pdlp/solvers.proto | 319 +++ ortools/pdlp/solvers_proto_validation.cc | 159 ++ ortools/pdlp/solvers_proto_validation.h | 44 + ortools/pdlp/solvers_proto_validation_test.cc | 334 +++ ortools/pdlp/termination.cc | 175 ++ ortools/pdlp/termination.h | 94 + ortools/pdlp/termination_test.cc | 470 ++++ ortools/pdlp/test_util.cc | 289 +++ ortools/pdlp/test_util.h | 417 ++++ ortools/pdlp/test_util_test.cc | 331 +++ ortools/pdlp/trust_region.cc | 1200 ++++++++++ ortools/pdlp/trust_region.h | 167 ++ ortools/pdlp/trust_region_test.cc | 1093 +++++++++ 46 files changed, 15299 insertions(+), 1 deletion(-) create mode 100644 ortools/linear_solver/pdlp_interface.cc create mode 100644 ortools/pdlp/BUILD.bazel create mode 100644 ortools/pdlp/gtest_main.cc create mode 100644 ortools/pdlp/iteration_stats.cc create mode 100644 ortools/pdlp/iteration_stats.h create mode 100644 ortools/pdlp/iteration_stats_test.cc create mode 100644 ortools/pdlp/primal_dual_hybrid_gradient.cc create mode 100644 ortools/pdlp/primal_dual_hybrid_gradient.h create mode 100644 ortools/pdlp/primal_dual_hybrid_gradient_test.cc create mode 100644 ortools/pdlp/quadratic_program.cc create mode 100644 ortools/pdlp/quadratic_program.h create mode 100644 ortools/pdlp/quadratic_program_io.cc create mode 100644 ortools/pdlp/quadratic_program_io.h create mode 100644 ortools/pdlp/quadratic_program_test.cc create mode 100644 ortools/pdlp/sharded_optimization_utils.cc create mode 100644 ortools/pdlp/sharded_optimization_utils.h create mode 100644 ortools/pdlp/sharded_optimization_utils_test.cc create mode 100644 ortools/pdlp/sharded_quadratic_program.cc create mode 100644 ortools/pdlp/sharded_quadratic_program.h create mode 100644 ortools/pdlp/sharded_quadratic_program_test.cc create mode 100644 ortools/pdlp/sharder.cc create mode 100644 ortools/pdlp/sharder.h create mode 100644 ortools/pdlp/sharder_test.cc create mode 100644 ortools/pdlp/solve_log.proto create mode 100644 ortools/pdlp/solvers.proto create mode 100644 ortools/pdlp/solvers_proto_validation.cc create mode 100644 ortools/pdlp/solvers_proto_validation.h create mode 100644 ortools/pdlp/solvers_proto_validation_test.cc create mode 100644 ortools/pdlp/termination.cc create mode 100644 ortools/pdlp/termination.h create mode 100644 ortools/pdlp/termination_test.cc create mode 100644 ortools/pdlp/test_util.cc create mode 100644 ortools/pdlp/test_util.h create mode 100644 ortools/pdlp/test_util_test.cc create mode 100644 ortools/pdlp/trust_region.cc create mode 100644 ortools/pdlp/trust_region.h create mode 100644 ortools/pdlp/trust_region_test.cc diff --git a/WORKSPACE b/WORKSPACE index 3b9572e2b6..49287efb9a 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -91,3 +91,23 @@ new_git_repository( tag = "v800", remote = "https://github.com/scipopt/scip.git", ) + +# Eigen has no Bazel build. +http_archive( + name = "eigen", + sha256 = "b4c198460eba6f28d34894e3a5710998818515104d6e74e5cc331ce31e46e626", + strip_prefix = "eigen-3.4.0", + urls = [ + "https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.bz2", + ], + build_file_content = +""" +cc_library( + name = 'eigen3', + srcs = [], + includes = ['.'], + hdrs = glob(['Eigen/**']), + visibility = ['//visibility:public'], +) +""" +) diff --git a/ortools/base/BUILD.bazel b/ortools/base/BUILD.bazel index e306c7cd93..a652b09094 100644 --- a/ortools/base/BUILD.bazel +++ b/ortools/base/BUILD.bazel @@ -38,8 +38,8 @@ cc_library( "integral_types.h", "log_severity.h", "logging.h", - "logging_flags.h", "logging_export.h", + "logging_flags.h", "logging_utilities.h", "macros.h", "raw_logging.h", diff --git a/ortools/linear_solver/BUILD.bazel b/ortools/linear_solver/BUILD.bazel index 94658231a3..1b63af4402 100644 --- a/ortools/linear_solver/BUILD.bazel +++ b/ortools/linear_solver/BUILD.bazel @@ -60,6 +60,7 @@ cc_library( "linear_solver.cc", "lpi_glop.cpp", "model_validator.cc", + "pdlp_interface.cc", "sat_interface.cc", "sat_proto_solver.cc", "sat_solver_utils.cc", @@ -102,6 +103,7 @@ cc_library( ":scip_with_glop", ":model_exporter", "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", "@com_google_absl//absl/strings", "@com_google_absl//absl/synchronization", "@com_google_absl//absl/types:optional", @@ -119,6 +121,9 @@ cc_library( "//ortools/glop:parameters_cc_proto", "//ortools/gscip:legacy_scip_params", "//ortools/gurobi:environment", + "//ortools/pdlp:primal_dual_hybrid_gradient", + "//ortools/pdlp:solve_log_cc_proto", + "//ortools/pdlp:solvers_cc_proto", "//ortools/port:file", "//ortools/port:proto_utils", "//ortools/sat:cp_model_cc_proto", diff --git a/ortools/linear_solver/CMakeLists.txt b/ortools/linear_solver/CMakeLists.txt index 13f7c0cf81..89f1f351c4 100644 --- a/ortools/linear_solver/CMakeLists.txt +++ b/ortools/linear_solver/CMakeLists.txt @@ -1,4 +1,5 @@ file(GLOB _SRCS "*.h" "*.cc") +list(FILTER _SRCS EXCLUDE REGEX ".*/pdlp_interface.cc") # Needs eigen3. if(USE_SCIP) list(APPEND _SRCS ${LPI_GLOP_SRC}) diff --git a/ortools/linear_solver/linear_solver.cc b/ortools/linear_solver/linear_solver.cc index c457622b23..8c19d0b3e0 100644 --- a/ortools/linear_solver/linear_solver.cc +++ b/ortools/linear_solver/linear_solver.cc @@ -23,6 +23,7 @@ #include #include #include +#include #include #include "absl/status/status.h" @@ -69,6 +70,7 @@ namespace operations_research { bool SolverTypeIsMip(MPModelRequest::SolverType solver_type) { switch (solver_type) { + case MPModelRequest::PDLP_LINEAR_PROGRAMMING: case MPModelRequest::GLOP_LINEAR_PROGRAMMING: case MPModelRequest::CLP_LINEAR_PROGRAMMING: case MPModelRequest::GLPK_LINEAR_PROGRAMMING: @@ -530,6 +532,7 @@ constexpr {MPSolver::BOP_INTEGER_PROGRAMMING, "bop"}, {MPSolver::GUROBI_MIXED_INTEGER_PROGRAMMING, "gurobi"}, {MPSolver::GLPK_MIXED_INTEGER_PROGRAMMING, "glpk"}, + {MPSolver::PDLP_LINEAR_PROGRAMMING, "pdlp"}, {MPSolver::KNAPSACK_MIXED_INTEGER_PROGRAMMING, "knapsack"}, {MPSolver::CPLEX_MIXED_INTEGER_PROGRAMMING, "cplex"}, {MPSolver::XPRESS_MIXED_INTEGER_PROGRAMMING, "xpress"}, diff --git a/ortools/linear_solver/linear_solver.h b/ortools/linear_solver/linear_solver.h index fd2f6908b2..4f5db00e1c 100644 --- a/ortools/linear_solver/linear_solver.h +++ b/ortools/linear_solver/linear_solver.h @@ -193,6 +193,10 @@ class MPSolver { CLP_LINEAR_PROGRAMMING = 0, GLPK_LINEAR_PROGRAMMING = 1, GLOP_LINEAR_PROGRAMMING = 2, // Recommended default value. Made in Google. + // In-house linear programming solver based on the primal-dual hybrid + // gradient method. Sometimes faster than Glop for medium-size problems and + // scales to much larger problems than Glop. + PDLP_LINEAR_PROGRAMMING = 8, // Integer programming problems. // ----------------------------- @@ -843,6 +847,7 @@ class MPSolver { friend class GLOPInterface; friend class BopInterface; friend class SatInterface; + friend class PdlpInterface; friend class KnapsackInterface; // Debugging: verify that the given MPVariable* belongs to this solver. @@ -1062,6 +1067,7 @@ class MPObjective { friend class GLOPInterface; friend class BopInterface; friend class SatInterface; + friend class PdlpInterface; friend class KnapsackInterface; // Constructor. An objective points to a single MPSolverInterface @@ -1171,6 +1177,7 @@ class MPVariable { friend class MPVariableSolutionValueTest; friend class BopInterface; friend class SatInterface; + friend class PdlpInterface; friend class KnapsackInterface; // Constructor. A variable points to a single MPSolverInterface that @@ -1312,6 +1319,7 @@ class MPConstraint { friend class GLOPInterface; friend class BopInterface; friend class SatInterface; + friend class PdlpInterface; friend class KnapsackInterface; // Constructor. A constraint points to a single MPSolverInterface diff --git a/ortools/linear_solver/linear_solver.proto b/ortools/linear_solver/linear_solver.proto index 89bb49d019..f09659ee8d 100644 --- a/ortools/linear_solver/linear_solver.proto +++ b/ortools/linear_solver/linear_solver.proto @@ -293,6 +293,29 @@ message MPModelProto { // try to return a solution "close" to this assignment in case of multiple // optimal solutions. optional PartialVariableAssignment solution_hint = 6; + + // Annotations can be freely added by users who want to attach arbitrary + // payload to the model's variables or constraints. + message Annotation { + // The target of an Annotation is a single entity (e.g. a variable). + // Several Annotations may apply to the same entity. + enum TargetType { + VARIABLE_DEFAULT = 0; + CONSTRAINT = 1; + GENERAL_CONSTRAINT = 2; + } + optional TargetType target_type = 1; + // If both `target_index` and `target_name` are set, they must point to the + // same entity. + optional int32 target_index = 2; // Index in the MPModelProto. + optional string target_name = 3; // Alternate to index. Assumes uniqueness. + + // The payload is a (key, value) string pair. Depending on the use cases, + // one of the two may be omitted. + optional string payload_key = 4; + optional string payload_value = 5; + } + repeated Annotation annotation = 9; } // To support 'unspecified' double value in proto3, the simplest is to wrap @@ -417,6 +440,11 @@ message MPModelRequest { // variables). SAT_INTEGER_PROGRAMMING = 14; + + // In-house linear programming solver based on the primal-dual hybrid + // gradient method. Sometimes faster than Glop for medium-size problems and + // scales to much larger problems than Glop. + PDLP_LINEAR_PROGRAMMING = 8; KNAPSACK_MIXED_INTEGER_PROGRAMMING = 13; } optional SolverType solver_type = 2; diff --git a/ortools/linear_solver/model_validator.cc b/ortools/linear_solver/model_validator.cc index a78e3d5f0b..f79474f285 100644 --- a/ortools/linear_solver/model_validator.cc +++ b/ortools/linear_solver/model_validator.cc @@ -20,12 +20,14 @@ #include "absl/container/flat_hash_map.h" #include "absl/container/flat_hash_set.h" #include "absl/status/status.h" +#include "absl/status/statusor.h" #include "absl/strings/match.h" #include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" #include "absl/types/optional.h" #include "ortools/base/accurate_sum.h" #include "ortools/base/commandlineflags.h" +#include "ortools/base/map_util.h" #include "ortools/linear_solver/linear_solver.pb.h" #include "ortools/port/file.h" #include "ortools/port/proto_utils.h" @@ -418,6 +420,133 @@ std::string FindErrorInSolutionHint( return std::string(); } +namespace { +// Maps the names of variables (or constraints, or other entities) to their +// index in the MPModelProto. Non-unique names are supported, but are singled +// out as such, by setting their index (the 'value' of the map entry) to -1. +template +absl::flat_hash_map BuildNameToIndexMap( + const google::protobuf::RepeatedPtrField& entities) { + absl::flat_hash_map out; + for (int i = 0; i < entities.size(); ++i) { + int& index = gtl::LookupOrInsert(&out, entities.Get(i).name(), i); + if (index != i) index = -1; + } + return out; +} + +class LazyMPModelNameToIndexMaps { + public: + explicit LazyMPModelNameToIndexMaps(const MPModelProto& model) + : model_(model) {} + + absl::StatusOr LookupName( + MPModelProto::Annotation::TargetType target_type, + const std::string& name) { + const absl::flat_hash_map* map = nullptr; + switch (target_type) { + case MPModelProto::Annotation::VARIABLE_DEFAULT: + if (!variable_name_to_index_) { + variable_name_to_index_ = BuildNameToIndexMap(model_.variable()); + } + map = &variable_name_to_index_.value(); + break; + case MPModelProto::Annotation::CONSTRAINT: + if (!constraint_name_to_index_) { + constraint_name_to_index_ = BuildNameToIndexMap(model_.constraint()); + } + map = &constraint_name_to_index_.value(); + break; + case MPModelProto::Annotation::GENERAL_CONSTRAINT: + if (!general_constraint_name_to_index_) { + general_constraint_name_to_index_ = + BuildNameToIndexMap(model_.general_constraint()); + } + map = &general_constraint_name_to_index_.value(); + break; + } + const int index = gtl::FindWithDefault(*map, name, -2); + if (index == -2) return absl::NotFoundError("name not found"); + if (index == -1) return absl::InvalidArgumentError("name is not unique"); + return index; + } + + private: + const MPModelProto& model_; + std::optional> variable_name_to_index_; + std::optional> + constraint_name_to_index_; + std::optional> + general_constraint_name_to_index_; +}; +} // namespace + +std::string FindErrorInAnnotation(const MPModelProto::Annotation& annotation, + const MPModelProto& model, + LazyMPModelNameToIndexMaps* name_maps) { + // Checks related to the 'target' fields. + if (!annotation.has_target_index() && !annotation.has_target_name()) { + return "One of target_index or target_name must be set"; + } + if (!MPModelProto::Annotation::TargetType_IsValid(annotation.target_type())) { + return "Invalid target_type"; + } + int num_entitities = -1; + switch (annotation.target_type()) { + case MPModelProto::Annotation::VARIABLE_DEFAULT: + num_entitities = model.variable_size(); + break; + case MPModelProto::Annotation::CONSTRAINT: + num_entitities = model.constraint_size(); + break; + case MPModelProto::Annotation::GENERAL_CONSTRAINT: + num_entitities = model.general_constraint_size(); + break; + } + int target_index = -1; + if (annotation.has_target_index()) { + target_index = annotation.target_index(); + if (target_index < 0 || target_index >= num_entitities) { + return "Invalid target_index"; + } + } + if (annotation.has_target_name()) { + if (annotation.has_target_index()) { + // No need to build the name lookup maps to verify consistency: we can + // even accept a name that is not unique, as long as the pointed entity + // (identified by its index) has the right name. + std::string name; + switch (annotation.target_type()) { + case MPModelProto::Annotation::VARIABLE_DEFAULT: + name = model.variable(target_index).name(); + break; + case MPModelProto::Annotation::CONSTRAINT: + name = model.constraint(target_index).name(); + break; + case MPModelProto::Annotation::GENERAL_CONSTRAINT: + name = model.general_constraint(target_index).name(); + break; + } + if (annotation.target_name() != name) { + return absl::StrFormat( + "target_name='%s' doesn't match the name '%s' of target_index=%d", + annotation.target_name(), name, target_index); + } + } else { // !annotation.has_target_index() + const absl::StatusOr index_or = name_maps->LookupName( + annotation.target_type(), annotation.target_name()); + if (!index_or.ok()) { + return absl::StrCat("Bad target_name: ", index_or.status().message()); + } + target_index = index_or.value(); + } + } + + // As of 2022-02, there are no checks related to the 'payload' fields. They + // can be set, unset, everything goes. + return ""; +} + } // namespace std::string FindErrorInMPModelProto( @@ -534,6 +663,17 @@ std::string FindErrorInMPModelProto( return absl::StrCat("In solution_hint(): ", error); } + // Validate the annotations. + { + LazyMPModelNameToIndexMaps name_maps(model); + for (int a = 0; a < model.annotation_size(); ++a) { + error = FindErrorInAnnotation(model.annotation(a), model, &name_maps); + if (!error.empty()) { + return absl::StrCat("In annotation #", a, ": ", error); + } + } + } + return std::string(); } diff --git a/ortools/linear_solver/pdlp_interface.cc b/ortools/linear_solver/pdlp_interface.cc new file mode 100644 index 0000000000..16f8bf4022 --- /dev/null +++ b/ortools/linear_solver/pdlp_interface.cc @@ -0,0 +1,328 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include + +#include "absl/base/attributes.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/linear_solver/linear_solver.h" +#include "ortools/linear_solver/linear_solver.pb.h" +#include "ortools/linear_solver/model_validator.h" +#include "ortools/pdlp/primal_dual_hybrid_gradient.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" +#include "ortools/port/proto_utils.h" +#include "ortools/util/lazy_mutable_copy.h" + +namespace operations_research { + +class PdlpInterface : public MPSolverInterface { + public: + explicit PdlpInterface(MPSolver* const solver); + ~PdlpInterface() override; + + // ----- Solve ----- + MPSolver::ResultStatus Solve(const MPSolverParameters& param) override; + absl::optional DirectlySolveProto( + const MPModelRequest& request, std::atomic* interrupt) override; + + // ----- Model modifications and extraction ----- + void Reset() override; + void SetOptimizationDirection(bool maximize) override; + void SetVariableBounds(int index, double lb, double ub) override; + void SetVariableInteger(int index, bool integer) override; + void SetConstraintBounds(int index, double lb, double ub) override; + void AddRowConstraint(MPConstraint* const ct) override; + void AddVariable(MPVariable* const var) override; + void SetCoefficient(MPConstraint* const constraint, + const MPVariable* const variable, double new_value, + double old_value) override; + void ClearConstraint(MPConstraint* const constraint) override; + void SetObjectiveCoefficient(const MPVariable* const variable, + double coefficient) override; + void SetObjectiveOffset(double value) override; + void ClearObjective() override; + + // ------ Query statistics on the solution and the solve ------ + int64_t iterations() const override; + int64_t nodes() const override; + MPSolver::BasisStatus row_status(int constraint_index) const override; + MPSolver::BasisStatus column_status(int variable_index) const override; + + // ----- Misc ----- + bool IsContinuous() const override; + bool IsLP() const override; + bool IsMIP() const override; + + std::string SolverVersion() const override; + void* underlying_solver() override; + + void ExtractNewVariables() override; + void ExtractNewConstraints() override; + void ExtractObjective() override; + + void SetParameters(const MPSolverParameters& param) override; + void SetRelativeMipGap(double value) override; + void SetPrimalTolerance(double value) override; + void SetDualTolerance(double value) override; + void SetPresolveMode(int value) override; + void SetScalingMode(int value) override; + void SetLpAlgorithm(int value) override; + bool SetSolverSpecificParametersAsString( + const std::string& parameters) override; + absl::Status SetNumThreads(int num_threads) override; + + private: + void NonIncrementalChange(); + + pdlp::PrimalDualHybridGradientParams parameters_; + pdlp::SolveLog solve_log_; +}; + +PdlpInterface::PdlpInterface(MPSolver* const solver) + : MPSolverInterface(solver) {} + +PdlpInterface::~PdlpInterface() {} + +MPSolver::ResultStatus PdlpInterface::Solve(const MPSolverParameters& param) { + // Reset extraction as this interface is not incremental yet. + Reset(); + ExtractModel(); + + SetParameters(param); + solver_->SetSolverSpecificParametersAsString( + solver_->solver_specific_parameter_string_); + + // Time limit. + if (solver_->time_limit()) { + VLOG(1) << "Setting time limit = " << solver_->time_limit() << " ms."; + parameters_.mutable_termination_criteria()->set_time_sec_limit( + static_cast(solver_->time_limit()) / 1000.0); + } + + // Mark variables and constraints as extracted. + for (int i = 0; i < solver_->variables_.size(); ++i) { + set_variable_as_extracted(i, true); + } + for (int i = 0; i < solver_->constraints_.size(); ++i) { + set_constraint_as_extracted(i, true); + } + + MPModelProto model_proto; + solver_->ExportModelToProto(&model_proto); + // TODO(user): Adjust logging level based on quiet_. + absl::StatusOr solution = + pdlp::SolveMpModelProto(model_proto, parameters_, + /*relax_integer_variables=*/true); + + if (!solution.ok()) { + LOG(ERROR) << "Unexpected error solving with PDLP: " << solution.status(); + return MPSolver::ABNORMAL; + } + + const MPSolutionResponse& response = solution->response; + + // The solution must be marked as synchronized even when no solution exists. + sync_status_ = SOLUTION_SYNCHRONIZED; + result_status_ = static_cast(response.status()); + solve_log_ = solution->solve_log; + + if (response.status() == MPSOLVER_FEASIBLE || + response.status() == MPSOLVER_OPTIMAL) { + const absl::Status result = solver_->LoadSolutionFromProto(response); + if (!result.ok()) { + LOG(ERROR) << "LoadSolutionFromProto failed: " << result; + } + } + + return result_status_; +} + +absl::optional PdlpInterface::DirectlySolveProto( + const MPModelRequest& request, std::atomic* interrupt) { + if (interrupt != nullptr) { + return absl::nullopt; + } + + MPSolutionResponse response; + if (request.has_solver_specific_parameters()) { + if (!solver_->SetSolverSpecificParametersAsString( + request.solver_specific_parameters())) { + response.set_status( + MPSolverResponseStatus::MPSOLVER_MODEL_INVALID_SOLVER_PARAMETERS); + return response; + } + } + + // TODO(user): Adjust logging level based on enable_internal_solver_output. + + if (request.has_solver_time_limit_seconds()) { + VLOG(1) << "Setting time limit = " << request.solver_time_limit_seconds() + << " s."; + parameters_.mutable_termination_criteria()->set_time_sec_limit( + request.solver_time_limit_seconds()); + } + + const absl::optional> optional_model = + ExtractValidMPModelOrPopulateResponseStatus(request, &response); + if (!optional_model) { + LOG_IF(WARNING, request.enable_internal_solver_output()) + << "Failed to extract a valid model from protocol buffer. Status: " + << ProtoEnumToString(response.status()) << " (" + << response.status() << "): " << response.status_str(); + return absl::nullopt; + } + + absl::StatusOr solution = + pdlp::SolveMpModelProto(optional_model->get(), parameters_, + /*relax_integer_variables=*/true); + + if (!solution.ok()) { + LOG(ERROR) << "Unexpected error solving with PDLP: " << solution.status(); + response.set_status(MPSolverResponseStatus::MPSOLVER_ABNORMAL); + response.set_status_str(solution.status().ToString()); + return response; + } + + return solution->response; +} + +void PdlpInterface::Reset() { ResetExtractionInformation(); } + +void PdlpInterface::SetOptimizationDirection(bool maximize) { + NonIncrementalChange(); +} + +void PdlpInterface::SetVariableBounds(int index, double lb, double ub) { + NonIncrementalChange(); +} + +void PdlpInterface::SetVariableInteger(int index, bool integer) { + NonIncrementalChange(); +} + +void PdlpInterface::SetConstraintBounds(int index, double lb, double ub) { + NonIncrementalChange(); +} + +void PdlpInterface::AddRowConstraint(MPConstraint* const ct) { + NonIncrementalChange(); +} + +void PdlpInterface::AddVariable(MPVariable* const var) { + NonIncrementalChange(); +} + +void PdlpInterface::SetCoefficient(MPConstraint* const constraint, + const MPVariable* const variable, + double new_value, double old_value) { + NonIncrementalChange(); +} + +void PdlpInterface::ClearConstraint(MPConstraint* const constraint) { + NonIncrementalChange(); +} + +void PdlpInterface::SetObjectiveCoefficient(const MPVariable* const variable, + double coefficient) { + NonIncrementalChange(); +} + +void PdlpInterface::SetObjectiveOffset(double value) { NonIncrementalChange(); } + +void PdlpInterface::ClearObjective() { NonIncrementalChange(); } + +int64_t PdlpInterface::iterations() const { + return solve_log_.iteration_count(); +} + +int64_t PdlpInterface::nodes() const { + LOG(DFATAL) << "Number of nodes only available for discrete problems"; + return MPSolverInterface::kUnknownNumberOfNodes; +} + +MPSolver::BasisStatus PdlpInterface::row_status(int constraint_index) const { + // TODO(user): While basis status isn't well defined for PDLP, we could + // guess statuses that might be useful. + return MPSolver::BasisStatus::FREE; +} + +MPSolver::BasisStatus PdlpInterface::column_status(int variable_index) const { + // TODO(user): While basis status isn't well defined for PDLP, we could + // guess statuses that might be useful. + return MPSolver::BasisStatus::FREE; +} + +bool PdlpInterface::IsContinuous() const { return true; } + +bool PdlpInterface::IsLP() const { return true; } + +bool PdlpInterface::IsMIP() const { return false; } + +std::string PdlpInterface::SolverVersion() const { return "PDLP Solver"; } + +// TODO(user): Consider returning the SolveLog here, as it could be essential +// for interpreting the PDLP solution. +void* PdlpInterface::underlying_solver() { return nullptr; } + +void PdlpInterface::ExtractNewVariables() { NonIncrementalChange(); } + +void PdlpInterface::ExtractNewConstraints() { NonIncrementalChange(); } + +void PdlpInterface::ExtractObjective() { NonIncrementalChange(); } + +void PdlpInterface::SetParameters(const MPSolverParameters& param) { + SetCommonParameters(param); +} + +absl::Status PdlpInterface::SetNumThreads(int num_threads) { + if (num_threads < 1) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid number of threads: ", num_threads)); + } + parameters_.set_num_threads(num_threads); + return absl::OkStatus(); +} + +// These have no effect. Use SetSolverSpecificParametersAsString instead. +void PdlpInterface::SetPrimalTolerance(double value) {} +void PdlpInterface::SetDualTolerance(double value) {} +void PdlpInterface::SetScalingMode(int value) {} +void PdlpInterface::SetLpAlgorithm(int value) {} +void PdlpInterface::SetRelativeMipGap(double value) {} +void PdlpInterface::SetPresolveMode(int value) {} + +bool PdlpInterface::SetSolverSpecificParametersAsString( + const std::string& parameters) { + return ProtobufTextFormatMergeFromString(parameters, ¶meters_); +} + +void PdlpInterface::NonIncrementalChange() { + // The current implementation is not incremental. + sync_status_ = MUST_RELOAD; +} + +// Register PDLP in the global linear solver factory. +MPSolverInterface* BuildPdlpInterface(MPSolver* const solver) { + return new PdlpInterface(solver); +} + +} // namespace operations_research diff --git a/ortools/linear_solver/sat_interface.cc b/ortools/linear_solver/sat_interface.cc index ab7a8aa551..ea5a7b034c 100644 --- a/ortools/linear_solver/sat_interface.cc +++ b/ortools/linear_solver/sat_interface.cc @@ -265,6 +265,7 @@ void SatInterface::ExtractNewConstraints() { NonIncrementalChange(); } void SatInterface::ExtractObjective() { NonIncrementalChange(); } void SatInterface::SetParameters(const MPSolverParameters& param) { + parameters_.Clear(); parameters_.set_num_search_workers(num_threads_); SetCommonParameters(param); } diff --git a/ortools/pdlp/BUILD.bazel b/ortools/pdlp/BUILD.bazel new file mode 100644 index 0000000000..71df0f8f66 --- /dev/null +++ b/ortools/pdlp/BUILD.bazel @@ -0,0 +1,387 @@ +load("@rules_cc//cc:defs.bzl", "cc_proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "solve_log_proto", + srcs = ["solve_log.proto"], + deps = [":solvers_proto"], +) + +cc_proto_library( + name = "solve_log_cc_proto", + deps = [":solve_log_proto"], +) + +proto_library( + name = "solvers_proto", + srcs = ["solvers.proto"], + deps = ["//ortools/glop:parameters_proto"], +) + +cc_proto_library( + name = "solvers_cc_proto", + deps = [":solvers_proto"], +) + +cc_library( + name = "gtest_main", + srcs = ["gtest_main.cc"], + deps = [ + "//ortools/base", + "@com_google_googletest//:gtest", + ], +) + +cc_library( + name = "sharder", + srcs = ["sharder.cc"], + hdrs = ["sharder.h"], + deps = [ + "//ortools/base", + "//ortools/base:mathutil", + "//ortools/base:threadpool", + "//ortools/base:timer", + "@com_google_absl//absl/synchronization", + "@com_google_absl//absl/time", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "sharder_test", + size = "small", + srcs = ["sharder_test.cc"], + deps = [ + ":gtest_main", + ":sharder", + "//ortools/base", + "//ortools/base:mathutil", + "@com_google_absl//absl/random:distributions", + "@com_google_absl//absl/strings", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "quadratic_program", + srcs = ["quadratic_program.cc"], + hdrs = ["quadratic_program.h"], + deps = [ + "//ortools/base", + "//ortools/base:status_macros", + "//ortools/linear_solver:linear_solver_cc_proto", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "quadratic_program_test", + size = "small", + srcs = ["quadratic_program_test.cc"], + deps = [ + ":gtest_main", + ":quadratic_program", + ":test_util", + "//ortools/base:protobuf_util", + "//ortools/linear_solver:linear_solver_cc_proto", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "test_util", + srcs = ["test_util.cc"], + hdrs = ["test_util.h"], + deps = [ + ":quadratic_program", + "//ortools/base", + "@com_google_absl//absl/types:span", + "@com_google_googletest//:gtest", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "test_util_test", + srcs = ["test_util_test.cc"], + deps = [ + ":gtest_main", + ":test_util", + "//ortools/base", + "@com_google_absl//absl/types:span", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "sharded_quadratic_program", + srcs = ["sharded_quadratic_program.cc"], + hdrs = ["sharded_quadratic_program.h"], + deps = [ + ":quadratic_program", + ":sharder", + "//ortools/base", + "//ortools/base:threadpool", + "@com_google_absl//absl/memory", + "@com_google_absl//absl/strings", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "sharded_quadratic_program_test", + srcs = ["sharded_quadratic_program_test.cc"], + deps = [ + ":gtest_main", + ":quadratic_program", + ":sharded_quadratic_program", + ":sharder", + ":test_util", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "sharded_optimization_utils", + srcs = ["sharded_optimization_utils.cc"], + hdrs = ["sharded_optimization_utils.h"], + deps = [ + ":quadratic_program", + ":sharded_quadratic_program", + ":sharder", + ":solve_log_cc_proto", + "//ortools/base", + "//ortools/base:mathutil", + "@com_google_absl//absl/algorithm:container", + "@com_google_absl//absl/random:distributions", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "sharded_optimization_utils_test", + size = "small", + srcs = ["sharded_optimization_utils_test.cc"], + deps = [ + ":gtest_main", + ":quadratic_program", + ":sharded_optimization_utils", + ":sharded_quadratic_program", + ":sharder", + ":solve_log_cc_proto", + ":test_util", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "termination", + srcs = ["termination.cc"], + hdrs = ["termination.h"], + deps = [ + ":solve_log_cc_proto", + ":solvers_cc_proto", + "//ortools/base", + "@com_google_absl//absl/types:optional", + ], +) + +cc_test( + name = "termination_test", + size = "small", + srcs = ["termination_test.cc"], + deps = [ + ":gtest_main", + ":solve_log_cc_proto", + ":solvers_cc_proto", + ":termination", + "//ortools/base:protobuf_util", + "@com_google_absl//absl/types:optional", + ], +) + +cc_library( + name = "trust_region", + srcs = ["trust_region.cc"], + hdrs = ["trust_region.h"], + deps = [ + ":quadratic_program", + ":sharded_optimization_utils", + ":sharded_quadratic_program", + ":sharder", + "//ortools/base", + "//ortools/base:mathutil", + "@com_google_absl//absl/algorithm:container", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "trust_region_test", + srcs = ["trust_region_test.cc"], + deps = [ + ":gtest_main", + ":quadratic_program", + ":sharded_optimization_utils", + ":sharded_quadratic_program", + ":sharder", + ":test_util", + ":trust_region", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "solvers_proto_validation", + srcs = ["solvers_proto_validation.cc"], + hdrs = ["solvers_proto_validation.h"], + deps = [ + ":solvers_cc_proto", + "//ortools/base:status_macros", + "@com_google_absl//absl/status", + ], +) + +cc_test( + name = "solvers_proto_validation_test", + srcs = ["solvers_proto_validation_test.cc"], + deps = [ + ":gtest_main", + ":solvers_cc_proto", + ":solvers_proto_validation", + "@com_google_absl//absl/status", + ], +) + +cc_library( + name = "quadratic_program_io", + srcs = ["quadratic_program_io.cc"], + hdrs = ["quadratic_program_io.h"], + deps = [ + ":quadratic_program", + "//ortools/base", + "//ortools/base:mathutil", + "//ortools/base:status_macros", + "//ortools/linear_solver:linear_solver_cc_proto", + "//ortools/linear_solver:model_exporter", + "//ortools/lp_data:mps_reader", + "//ortools/util:file_util", + "@com_google_absl//absl/base", + "@com_google_absl//absl/container:flat_hash_map", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + + +cc_library( + name = "iteration_stats", + srcs = ["iteration_stats.cc"], + hdrs = ["iteration_stats.h"], + deps = [ + ":quadratic_program", + ":sharded_quadratic_program", + ":sharder", + ":solve_log_cc_proto", + ":solvers_cc_proto", + "//ortools/base", + "//ortools/base:mathutil", + "@com_google_absl//absl/random:distributions", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/strings:str_format", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "iteration_stats_test", + srcs = ["iteration_stats_test.cc"], + deps = [ + ":gtest_main", + ":iteration_stats", + ":quadratic_program", + ":sharded_quadratic_program", + ":solve_log_cc_proto", + ":test_util", + "//ortools/base:protobuf_util", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_library( + name = "primal_dual_hybrid_gradient", + srcs = ["primal_dual_hybrid_gradient.cc"], + hdrs = ["primal_dual_hybrid_gradient.h"], + deps = [ + ":iteration_stats", + ":quadratic_program", + ":sharded_optimization_utils", + ":sharded_quadratic_program", + ":sharder", + ":solve_log_cc_proto", + ":solvers_cc_proto", + ":solvers_proto_validation", + ":termination", + ":trust_region", + "//ortools/base", + "//ortools/base:mathutil", + "//ortools/base:status_macros", + "//ortools/base:timer", + "//ortools/glop:parameters_cc_proto", + "//ortools/glop:preprocessor", + "//ortools/linear_solver:linear_solver_cc_proto", + "//ortools/lp_data", + "//ortools/lp_data:base", + "//ortools/lp_data:proto_utils", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/time", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) + +cc_test( + name = "primal_dual_hybrid_gradient_test", + size = "medium", + srcs = ["primal_dual_hybrid_gradient_test.cc"], + shard_count = 3, + deps = [ + ":gtest_main", + ":iteration_stats", + ":primal_dual_hybrid_gradient", + ":quadratic_program", + ":quadratic_program_io", + ":sharded_quadratic_program", + ":solve_log_cc_proto", + ":solvers_cc_proto", + ":test_util", + "//ortools/base:protobuf_util", + "//ortools/linear_solver", + "//ortools/linear_solver:linear_solver_cc_proto", + "//ortools/lp_data", + "//ortools/lp_data:base", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/types:optional", + "@eigen//:eigen3", + ], +) diff --git a/ortools/pdlp/gtest_main.cc b/ortools/pdlp/gtest_main.cc new file mode 100644 index 0000000000..4372e62e94 --- /dev/null +++ b/ortools/pdlp/gtest_main.cc @@ -0,0 +1,23 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "gtest/gtest.h" +#include "ortools/base/commandlineflags.h" +#include "ortools/base/logging.h" + +int main(int argc, char** argv) { + testing::InitGoogleTest(&argc, argv); + google::InitGoogleLogging(argv[0]); + absl::ParseCommandLine(argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/ortools/pdlp/iteration_stats.cc b/ortools/pdlp/iteration_stats.cc new file mode 100644 index 0000000000..3c16562ced --- /dev/null +++ b/ortools/pdlp/iteration_stats.cc @@ -0,0 +1,552 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/iteration_stats.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/random/distributions.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { +namespace { + +using ::Eigen::VectorXd; + +// ResidualNorms contains four measures of the infeasibility of a primal or +// dual solution. "objective_correction" is the (additive) adjustment to the +// objective function from the reduced costs. "objective_full_correction" is the +// (additive) adjustment to the objective function if all dual residuals were +// set to zero, while l_inf_residual and l_2_residual are the L_infinity and L_2 +// norms of the residuals (portions of the primal gradient not included in the +// reduced costs). +struct ResidualNorms { + double objective_correction; + double objective_full_correction; + double l_inf_residual; + double l_2_residual; +}; + +// Computes norms of the primal residual infeasibilities (b - A x) of the +// unscaled problem. Note the primal residuals of the unscaled problem are equal +// to those of the scaled problem divided by row_scaling_vec. Does not perform +// any corrections (so the returned .correction == 0.0). sharded_qp is assumed +// to be the scaled problem. If use_homogeneous_primal_bounds is set to true +// the residuals are computed with upper and lower bounds zeroed out (note that +// we only zero out the bounds that are finite in the original problem). +ResidualNorms PrimalResidualNorms( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& row_scaling_vec, + const VectorXd& scaled_primal_solution, + bool use_homogeneous_constraint_bounds = false) { + const QuadraticProgram& qp = sharded_qp.Qp(); + CHECK_EQ(row_scaling_vec.size(), sharded_qp.DualSize()); + CHECK_EQ(scaled_primal_solution.size(), sharded_qp.PrimalSize()); + + VectorXd primal_product = TransposedMatrixVectorProduct( + sharded_qp.TransposedConstraintMatrix(), scaled_primal_solution, + sharded_qp.TransposedConstraintMatrixSharder()); + VectorXd local_l_inf_residual(sharded_qp.DualSharder().NumShards()); + VectorXd local_sumsq_residual(sharded_qp.DualSharder().NumShards()); + sharded_qp.DualSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const auto lower_bound_shard = shard(qp.constraint_lower_bounds); + const auto upper_bound_shard = shard(qp.constraint_upper_bounds); + const auto row_scaling_shard = shard(row_scaling_vec); + const auto primal_product_shard = shard(primal_product); + double l_inf_residual = 0.0; + double sumsq_residual = 0.0; + for (int64_t i = 0; i < primal_product_shard.size(); ++i) { + const double upper_bound = (use_homogeneous_constraint_bounds && + std::isfinite(upper_bound_shard[i])) + ? 0.0 + : upper_bound_shard[i]; + const double lower_bound = (use_homogeneous_constraint_bounds && + std::isfinite(lower_bound_shard[i])) + ? 0.0 + : lower_bound_shard[i]; + double scaled_residual = 0.0; + if (primal_product_shard[i] > upper_bound) { + scaled_residual = primal_product_shard[i] - upper_bound; + + } else if (primal_product_shard[i] < lower_bound) { + scaled_residual = lower_bound - primal_product_shard[i]; + } + const double residual = scaled_residual / row_scaling_shard[i]; + l_inf_residual = std::max(l_inf_residual, residual); + sumsq_residual += residual * residual; + } + local_l_inf_residual[shard.Index()] = l_inf_residual; + local_sumsq_residual[shard.Index()] = sumsq_residual; + }); + return ResidualNorms{ + .objective_correction = 0.0, + .objective_full_correction = 0.0, + .l_inf_residual = local_l_inf_residual.lpNorm(), + .l_2_residual = std::sqrt(local_sumsq_residual.sum()), + }; +} + +// Decides whether a primal gradient term should be handled as a reduced cost or +// as a dual residual. +bool HandlePrimalGradientTermAsReducedCost(double primal_gradient, + double primal_value, + double lower_bound, + double upper_bound) { + if (primal_gradient == 0.0) return true; + return std::abs(primal_value - + (primal_gradient > 0.0 ? lower_bound : upper_bound)) <= + std::abs(primal_value); +} + +// Computes norms of the dual residuals and reduced costs of the unscaled +// problem. Note the primal gradient of the unscaled problem is equal to the +// scaled primal gradient divided by col_scaling_vec. sharded_qp is assumed to +// be the scaled problem. See +// https://developers.google.com/optimization/lp/pdlp_math for details and +// notation. Primal gradients that have corresponding (finite) bounds (the +// finite terms from (l^v)^T[r]_+ − (u^v)^T[r]_− in the dual objective), and +// have |x - b| <= |x| (where x is the variable's value and b is the +// corresponding bound) are treated as reduced costs and accumulated in +// objective_correction, while the other primal gradient terms are handled as +// residual infeasibilities in l_inf_residual and l_2_residual. +ResidualNorms DualResidualNorms(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& col_scaling_vec, + const VectorXd& scaled_primal_solution, + const VectorXd& scaled_primal_gradient) { + const QuadraticProgram& qp = sharded_qp.Qp(); + CHECK_EQ(col_scaling_vec.size(), sharded_qp.PrimalSize()); + CHECK_EQ(scaled_primal_gradient.size(), sharded_qp.PrimalSize()); + VectorXd local_dual_correction(sharded_qp.PrimalSharder().NumShards()); + VectorXd local_dual_full_correction(sharded_qp.PrimalSharder().NumShards()); + VectorXd local_l_inf_residual(sharded_qp.PrimalSharder().NumShards()); + VectorXd local_sumsq_residual(sharded_qp.PrimalSharder().NumShards()); + sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const auto lower_bound_shard = shard(qp.variable_lower_bounds); + const auto upper_bound_shard = shard(qp.variable_upper_bounds); + const auto primal_gradient_shard = shard(scaled_primal_gradient); + const auto col_scaling_shard = shard(col_scaling_vec); + const auto primal_solution_shard = shard(scaled_primal_solution); + double dual_correction = 0.0; + double dual_full_correction = 0.0; + double l_inf_residual = 0.0; + double sumsq_residual = 0.0; + for (int64_t i = 0; i < primal_gradient_shard.size(); ++i) { + // The corrections use the scaled values because + // unscaled_lower_bound = lower_bound * scale and + // unscaled_primal_gradient = primal_gradient / scale, so the scales + // cancel out. + if (primal_gradient_shard[i] == 0.0) continue; + const double bound_for_rc = primal_gradient_shard[i] > 0.0 + ? lower_bound_shard[i] + : upper_bound_shard[i]; + dual_full_correction += bound_for_rc * primal_gradient_shard[i]; + if (HandlePrimalGradientTermAsReducedCost( + primal_gradient_shard[i], primal_solution_shard[i], + lower_bound_shard[i], upper_bound_shard[i])) { + dual_correction += bound_for_rc * primal_gradient_shard[i]; + } else { + const double scaled_residual = std::abs(primal_gradient_shard[i]); + const double residual = scaled_residual / col_scaling_shard[i]; + l_inf_residual = std::max(l_inf_residual, residual); + sumsq_residual += residual * residual; + } + } + local_dual_correction[shard.Index()] = dual_correction; + local_dual_full_correction[shard.Index()] = dual_full_correction; + local_l_inf_residual[shard.Index()] = l_inf_residual; + local_sumsq_residual[shard.Index()] = sumsq_residual; + }); + return ResidualNorms{ + .objective_correction = local_dual_correction.sum(), + .objective_full_correction = local_dual_full_correction.sum(), + .l_inf_residual = local_l_inf_residual.lpNorm(), + .l_2_residual = std::sqrt(local_sumsq_residual.sum()), + }; +} + +// Returns Qx. +VectorXd ObjectiveProduct(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution) { + CHECK_EQ(primal_solution.size(), sharded_qp.PrimalSize()); + VectorXd result(primal_solution.size()); + if (IsLinearProgram(sharded_qp.Qp())) { + result.setZero(); + } else { + sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + shard(result) = + shard(*sharded_qp.Qp().objective_matrix) * shard(primal_solution); + }); + } + return result; +} + +// Returns 1/2 x^T Q x (the quadratic term in the objective). +double QuadraticObjective(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution, + const VectorXd& objective_product) { + CHECK_EQ(primal_solution.size(), sharded_qp.PrimalSize()); + CHECK_EQ(objective_product.size(), sharded_qp.PrimalSize()); + return 0.5 * + Dot(objective_product, primal_solution, sharded_qp.PrimalSharder()); +} + +// Returns objective_product + c − A^T y when use_zero_primal_objective = +// false, and returns − A^T y when use_zero_primal_objective = true. +// objective_product is passed by copy, and modified in place. +VectorXd PrimalGradientFromObjectiveProduct( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& dual_solution, + VectorXd objective_product, bool use_zero_primal_objective = false) { + const QuadraticProgram& qp = sharded_qp.Qp(); + CHECK_EQ(dual_solution.size(), sharded_qp.DualSize()); + CHECK_EQ(objective_product.size(), sharded_qp.PrimalSize()); + + // Note that this modifies objective_product, replacing its entries with + // the primal gradient. + sharded_qp.ConstraintMatrixSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + if (use_zero_primal_objective) { + shard(objective_product) = + -shard(qp.constraint_matrix).transpose() * dual_solution; + } else { + shard(objective_product) += + shard(qp.objective_vector) - + shard(qp.constraint_matrix).transpose() * dual_solution; + } + }); + return objective_product; +} + +// Returns the value of y term in the objective of the dual problem, see +// (l^c)^T[y]_+ − (u^c)^T[y]_− in the dual objective from +// https://developers.google.com/optimization/lp/pdlp_math. +double DualObjectiveBoundsTerm(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& dual_solution) { + const QuadraticProgram& qp = sharded_qp.Qp(); + return sharded_qp.DualSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + // This assumes that the dual variables are feasible, that is, that + // the term corresponding to the "y" variables in the dual objective + // in https://developers.google.com/optimization/lp/pdlp_math is finite. + const auto lower_bound_shard = shard(qp.constraint_lower_bounds); + const auto upper_bound_shard = shard(qp.constraint_upper_bounds); + const auto dual_shard = shard(dual_solution); + // Can't use .dot(.cwiseMin()) because that gives 0 * inf = NaN. + double sum = 0.0; + for (int64_t i = 0; i < dual_shard.size(); ++i) { + if (dual_shard[i] > 0.0) { + sum += lower_bound_shard[i] * dual_shard[i]; + } else if (dual_shard[i] < 0.0) { + sum += upper_bound_shard[i] * dual_shard[i]; + } + } + return sum; + }); +} + +// Computes the projection of the vector onto a pseudo-random vector determined +// by seed_generator. seed_generator is used as the source of a random seed for +// each shard's portion of the vector. +double RandomProjection(const VectorXd& vector, const Sharder& sharder, + std::mt19937& seed_generator) { + std::vector shard_seeds; + shard_seeds.reserve(sharder.NumShards()); + for (int shard = 0; shard < sharder.NumShards(); ++shard) { + shard_seeds.emplace_back((seed_generator)()); + } + // Computes vector * gaussian_random_vector and + // ||gaussian_random_vector||^2 to normalize by afterwards. + VectorXd dot_product(sharder.NumShards()); + VectorXd gaussian_norm_squared(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const auto vector_shard = shard(vector); + double shard_dot_product = 0.0; + double shard_norm_squared = 0.0; + std::mt19937 random{shard_seeds[shard.Index()]}; + for (int64_t i = 0; i < vector_shard.size(); ++i) { + const double projection_element = absl::Gaussian(random, 0.0, 1.0); + shard_dot_product += projection_element * vector_shard[i]; + shard_norm_squared += MathUtil::Square(projection_element); + } + dot_product[shard.Index()] = shard_dot_product; + gaussian_norm_squared[shard.Index()] = shard_norm_squared; + }); + return dot_product.sum() / std::sqrt(gaussian_norm_squared.sum()); +} +} // namespace + +ConvergenceInformation ComputeConvergenceInformation( + const ShardedQuadraticProgram& scaled_sharded_qp, + const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec, + const Eigen::VectorXd& scaled_primal_solution, + const Eigen::VectorXd& scaled_dual_solution, PointType candidate_type) { + const QuadraticProgram& qp = scaled_sharded_qp.Qp(); + CHECK_EQ(col_scaling_vec.size(), scaled_sharded_qp.PrimalSize()); + CHECK_EQ(row_scaling_vec.size(), scaled_sharded_qp.DualSize()); + CHECK_EQ(scaled_primal_solution.size(), scaled_sharded_qp.PrimalSize()); + CHECK_EQ(scaled_dual_solution.size(), scaled_sharded_qp.DualSize()); + + // See https://developers.google.com/optimization/lp/pdlp_math#rescaling for + // notes describing the connection between the scaled and unscaled problem. + + ConvergenceInformation result; + ResidualNorms primal_residuals = PrimalResidualNorms( + scaled_sharded_qp, row_scaling_vec, scaled_primal_solution); + result.set_l_inf_primal_residual(primal_residuals.l_inf_residual); + result.set_l2_primal_residual(primal_residuals.l_2_residual); + + result.set_l_inf_primal_variable( + ScaledLInfNorm(scaled_primal_solution, col_scaling_vec, + scaled_sharded_qp.PrimalSharder())); + result.set_l2_primal_variable(ScaledNorm(scaled_primal_solution, + col_scaling_vec, + scaled_sharded_qp.PrimalSharder())); + result.set_l_inf_dual_variable(ScaledLInfNorm( + scaled_dual_solution, row_scaling_vec, scaled_sharded_qp.DualSharder())); + result.set_l2_dual_variable(ScaledNorm(scaled_dual_solution, row_scaling_vec, + scaled_sharded_qp.DualSharder())); + + VectorXd scaled_objective_product = + ObjectiveProduct(scaled_sharded_qp, scaled_primal_solution); + const double quadratic_objective = QuadraticObjective( + scaled_sharded_qp, scaled_primal_solution, scaled_objective_product); + VectorXd scaled_primal_gradient = PrimalGradientFromObjectiveProduct( + scaled_sharded_qp, scaled_dual_solution, + std::move(scaled_objective_product)); + result.set_primal_objective(qp.ApplyObjectiveScalingAndOffset( + quadratic_objective + Dot(qp.objective_vector, scaled_primal_solution, + scaled_sharded_qp.PrimalSharder()))); + + // This is the dual objective from + // https://developers.google.com/optimization/lp/pdlp_math minus the last term + // (involving r). All scaling terms cancel out. + const double dual_objective_piece = + -quadratic_objective + + DualObjectiveBoundsTerm(scaled_sharded_qp, scaled_dual_solution); + + ResidualNorms dual_residuals = + DualResidualNorms(scaled_sharded_qp, col_scaling_vec, + scaled_primal_solution, scaled_primal_gradient); + result.set_dual_objective(qp.ApplyObjectiveScalingAndOffset( + dual_objective_piece + dual_residuals.objective_correction)); + result.set_corrected_dual_objective(qp.ApplyObjectiveScalingAndOffset( + dual_objective_piece + dual_residuals.objective_full_correction)); + result.set_l_inf_dual_residual(dual_residuals.l_inf_residual); + result.set_l2_dual_residual(dual_residuals.l_2_residual); + + result.set_candidate_type(candidate_type); + return result; +} + +InfeasibilityInformation ComputeInfeasibilityInformation( + const ShardedQuadraticProgram& scaled_sharded_qp, + const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec, + const Eigen::VectorXd& scaled_primal_ray, + const Eigen::VectorXd& scaled_dual_ray, PointType candidate_type) { + const QuadraticProgram& qp = scaled_sharded_qp.Qp(); + CHECK_EQ(col_scaling_vec.size(), scaled_sharded_qp.PrimalSize()); + CHECK_EQ(row_scaling_vec.size(), scaled_sharded_qp.DualSize()); + CHECK_EQ(scaled_primal_ray.size(), scaled_sharded_qp.PrimalSize()); + CHECK_EQ(scaled_dual_ray.size(), scaled_sharded_qp.DualSize()); + + double l_inf_primal = ScaledLInfNorm(scaled_primal_ray, col_scaling_vec, + scaled_sharded_qp.PrimalSharder()); + double l_inf_dual = ScaledLInfNorm(scaled_dual_ray, row_scaling_vec, + scaled_sharded_qp.DualSharder()); + InfeasibilityInformation result; + // Compute primal infeasibility information. + VectorXd scaled_primal_gradient = PrimalGradientFromObjectiveProduct( + scaled_sharded_qp, scaled_dual_ray, + VectorXd::Zero(scaled_sharded_qp.PrimalSize()), + /*use_zero_primal_objective=*/true); + ResidualNorms dual_residuals = + DualResidualNorms(scaled_sharded_qp, col_scaling_vec, scaled_primal_ray, + scaled_primal_gradient); + + double dual_ray_objective = + DualObjectiveBoundsTerm(scaled_sharded_qp, scaled_dual_ray) + + dual_residuals.objective_correction; + if (l_inf_dual > 0) { + result.set_dual_ray_objective(dual_ray_objective / l_inf_dual); + result.set_max_dual_ray_infeasibility(dual_residuals.l_inf_residual / + l_inf_dual); + } else { + result.set_dual_ray_objective(0.0); + result.set_max_dual_ray_infeasibility(0.0); + } + + // Compute dual infeasibility information. + ResidualNorms primal_residuals = + PrimalResidualNorms(scaled_sharded_qp, row_scaling_vec, scaled_primal_ray, + /*use_homogeneous_constraint_bounds=*/true); + // primal_residuals contains the violations of the linear constraints. The + // signs of the components are also constrained by the presence or absence + // of variable bounds. + VectorXd primal_ray_local_sign_max_violation( + scaled_sharded_qp.PrimalSharder().NumShards()); + scaled_sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const auto lower_bound_shard = + shard(scaled_sharded_qp.Qp().variable_lower_bounds); + const auto upper_bound_shard = + shard(scaled_sharded_qp.Qp().variable_upper_bounds); + const auto ray_shard = shard(scaled_primal_ray); + const auto scale_shard = shard(col_scaling_vec); + double local_max = 0.0; + for (int64_t i = 0; i < ray_shard.size(); ++i) { + if (std::isfinite(lower_bound_shard[i])) { + local_max = std::max(local_max, -ray_shard[i] * scale_shard[i]); + } + if (std::isfinite(upper_bound_shard[i])) { + local_max = std::max(local_max, ray_shard[i] * scale_shard[i]); + } + } + primal_ray_local_sign_max_violation[shard.Index()] = local_max; + }); + const double primal_ray_sign_max_violation = + primal_ray_local_sign_max_violation.lpNorm(); + + if (l_inf_primal > 0.0) { + VectorXd scaled_objective_product = + ObjectiveProduct(scaled_sharded_qp, scaled_primal_ray); + result.set_primal_ray_quadratic_norm( + LInfNorm(scaled_objective_product, scaled_sharded_qp.PrimalSharder()) / + l_inf_primal); + result.set_max_primal_ray_infeasibility( + std::max(primal_residuals.l_inf_residual, + primal_ray_sign_max_violation) / + l_inf_primal); + result.set_primal_ray_linear_objective( + Dot(scaled_primal_ray, qp.objective_vector, + scaled_sharded_qp.PrimalSharder()) / + l_inf_primal); + } else { + result.set_primal_ray_quadratic_norm(0.0); + result.set_max_primal_ray_infeasibility(0.0); + result.set_primal_ray_linear_objective(0.0); + } + + result.set_candidate_type(candidate_type); + return result; +} + +ConvergenceInformation ComputeScaledConvergenceInformation( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& primal_solution, + const VectorXd& dual_solution, PointType candidate_type) { + return ComputeConvergenceInformation( + sharded_qp, VectorXd::Ones(sharded_qp.PrimalSize()), + VectorXd::Ones(sharded_qp.DualSize()), primal_solution, dual_solution, + candidate_type); +} + +VectorXd ReducedCosts(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution, + const VectorXd& dual_solution, + bool use_zero_primal_objective) { + VectorXd objective_product; + if (use_zero_primal_objective) { + objective_product = VectorXd::Zero(sharded_qp.PrimalSize()); + } else { + objective_product = ObjectiveProduct(sharded_qp, primal_solution); + } + VectorXd reduced_costs = PrimalGradientFromObjectiveProduct( + sharded_qp, dual_solution, std::move(objective_product), + use_zero_primal_objective); + sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + auto rc_shard = shard(reduced_costs); + const auto lower_bound_shard = + shard(sharded_qp.Qp().variable_lower_bounds); + const auto upper_bound_shard = + shard(sharded_qp.Qp().variable_upper_bounds); + const auto primal_solution_shard = shard(primal_solution); + for (int64_t i = 0; i < rc_shard.size(); ++i) { + if (rc_shard[i] != 0.0 && + !HandlePrimalGradientTermAsReducedCost( + rc_shard[i], primal_solution_shard[i], lower_bound_shard[i], + upper_bound_shard[i])) { + rc_shard[i] = 0.0; + } + } + }); + return reduced_costs; +} + +absl::optional GetConvergenceInformation( + const IterationStats& stats, PointType candidate_type) { + for (const auto& convergence_information : stats.convergence_information()) { + if (convergence_information.candidate_type() == candidate_type) { + return convergence_information; + } + } + return absl::nullopt; +} + +absl::optional GetInfeasibilityInformation( + const IterationStats& stats, PointType candidate_type) { + for (const auto& infeasibility_information : + stats.infeasibility_information()) { + if (infeasibility_information.candidate_type() == candidate_type) { + return infeasibility_information; + } + } + return absl::nullopt; +} + +absl::optional GetPointMetadata(const IterationStats& stats, + const PointType point_type) { + for (const auto& metadata : stats.point_metadata()) { + if (metadata.point_type() == point_type) { + return metadata; + } + } + return absl::nullopt; +} + +void SetRandomProjections(const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_solution, + const std::vector& random_projection_seeds, + PointMetadata& metadata) { + for (const int random_projection_seed : random_projection_seeds) { + std::mt19937 seed_generator(random_projection_seed); + metadata.mutable_random_primal_projections()->Add(RandomProjection( + primal_solution, sharded_qp.PrimalSharder(), seed_generator)); + metadata.mutable_random_dual_projections()->Add(RandomProjection( + dual_solution, sharded_qp.DualSharder(), seed_generator)); + } +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/iteration_stats.h b/ortools/pdlp/iteration_stats.h new file mode 100644 index 0000000000..d6ba89eb42 --- /dev/null +++ b/ortools/pdlp/iteration_stats.h @@ -0,0 +1,108 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_ITERATION_STATS_H_ +#define PDLP_ITERATION_STATS_H_ + +#include +#include +#include + +#include "Eigen/Core" +#include "absl/types/optional.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { + +// Returns convergence statistics about a primal/dual solution pair. The stats +// are with respect to sharded_qp (which is typically scaled). +// This function is equivalent to ComputeConvergenceInformation given scaling +// vectors uniformly equal to one. +ConvergenceInformation ComputeScaledConvergenceInformation( + const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_solution, PointType candidate_type); + +// Returns convergence statistics about a primal/dual solution pair. It is +// assumed that scaled_sharded_qp has been transformed from the original qp by +// ShardedQuadraticProgram::RescaleQuadraticProgram(col_scaling_vec, +// row_scaling_vec). scaled_primal_solution and scaled_dual_solution are +// solutions for the scaled problem. The stats are computed with respect to the +// implicit original problem. +// NOTE: This function assumes that scaled_primal_solution satisfies the +// variable bounds and scaled_dual_solution satisfies the dual variable bounds; +// see +// https://developers.google.com/optimization/lp/pdlp_math#dual_variable_bounds. +ConvergenceInformation ComputeConvergenceInformation( + const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec, + const Eigen::VectorXd& scaled_primal_solution, + const Eigen::VectorXd& scaled_dual_solution, PointType candidate_type); + +// Returns infeasibility statistics about a primal/dual infeasibility +// certificate estimate. It is assumed that scaled_sharded_qp has been +// transformed from the original qp by +// ShardedQuadraticProgram::RescaleQuadraticProgram(col_scaling_vec, +// row_scaling_vec). primal_ray and dual_ray are certificates for the scaled +// problem. The stats are computed with respect to the implicit original +// problem. +InfeasibilityInformation ComputeInfeasibilityInformation( + const ShardedQuadraticProgram& scaled_sharded_qp, + const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec, + const Eigen::VectorXd& scaled_primal_ray, + const Eigen::VectorXd& scaled_dual_ray, PointType candidate_type); + +// Computes the reduced costs vector, objective_matrix * primal_solution + +// objective_vector - constraint_matrix * dual_solution - dual_residuals, when +// use_zero_primal_objective is false, and -constraint_matrix * dual_solution - +// dual_residuals when use_zero_primal_objective is true. The elements of the +// vector are corrected component-wise to zero to ensure that the dual objective +// takes a finite value. See +// https://developers.google.com/optimization/lp/pdlp_math#reduced_costs_dual_residuals_and_the_corrected_dual_objective. +Eigen::VectorXd ReducedCosts(const ShardedQuadraticProgram& scaled_sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_solution, + bool use_zero_primal_objective = false); + +// Finds and returns the ConvergenceInformation with the specified +// candidate_type, or absl::nullopt if no such candidate exists. +absl::optional GetConvergenceInformation( + const IterationStats& stats, PointType candidate_type); + +// Finds and returns the InfeasibilityInformation with the specified +// candidate_type, or absl::nullopt if no such candidate exists. +absl::optional GetInfeasibilityInformation( + const IterationStats& stats, PointType candidate_type); + +// Finds and returns the PointMetadata with the specified +// point_type, or absl::nullopt if no such point exists. +absl::optional GetPointMetadata(const IterationStats& stats, + PointType point_type); + +// For each entry in random_projection_seeds, computes a random projection of +// the primal/dual solution pair onto pseudo-random vectors generated from that +// seed and adds the results to +// random_primal_projections/random_dual_projections in metadata. +void SetRandomProjections(const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_solution, + const std::vector& random_projection_seeds, + PointMetadata& metadata); + +} // namespace operations_research::pdlp + +#endif // PDLP_ITERATION_STATS_H_ diff --git a/ortools/pdlp/iteration_stats_test.cc b/ortools/pdlp/iteration_stats_test.cc new file mode 100644 index 0000000000..12abd64a7a --- /dev/null +++ b/ortools/pdlp/iteration_stats_test.cc @@ -0,0 +1,281 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/iteration_stats.h" + +#include +#include +#include + +#include "Eigen/Core" +#include "absl/types/optional.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/protobuf_util.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/test_util.h" + +namespace operations_research::pdlp { +namespace { + +using ::google::protobuf::util::ParseTextOrDie; + +using ::testing::AllOf; +using ::testing::Each; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::Ge; +using ::testing::Le; +using ::testing::Ne; +using ::testing::SizeIs; + +TEST(CorrectedDualTest, SimpleLpWithSuboptimalDual) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestLp(), num_threads, num_shards); + + Eigen::VectorXd primal_solution(4), dual_solution(4); + // Set the primal variables that have primal gradients at their bounds, so + // that the primal gradients are reduced costs. + primal_solution << 0, 0, 6, 2.5; + dual_solution << -2, 0, 2.375, 1; + const ConvergenceInformation stats = ComputeScaledConvergenceInformation( + sharded_qp, primal_solution, dual_solution, POINT_TYPE_CURRENT_ITERATE); + // -36.5 = -14 - 24 - 9.5 - 1 - 3 + 15 + EXPECT_DOUBLE_EQ(stats.dual_objective(), -36.5); + EXPECT_DOUBLE_EQ(stats.corrected_dual_objective(), -36.5); +} + +// This is similar to SimpleLpWithSuboptimalDual, except with +// x_2 = 2. In the dual correction calculation, the corresponding bound is 6, so +// the primal gradient will be treated as a residual of 0.5 instead of a dual +// correction of -3, but in the corrected dual objective it is still treated as +// a dual correction. +TEST(CorrectedDualTest, SimpleLpWithVariableFarFromBound) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestLp(), num_threads, num_shards); + + Eigen::VectorXd primal_solution(4), dual_solution(4); + primal_solution << 0, 0, 2, 2.5; + dual_solution << -2, 0, 2.375, 1; + const ConvergenceInformation stats = ComputeScaledConvergenceInformation( + sharded_qp, primal_solution, dual_solution, POINT_TYPE_CURRENT_ITERATE); + // -33.5 = -14 - 24 - 9.5 - 1 + 15 + EXPECT_DOUBLE_EQ(stats.dual_objective(), -33.5); + EXPECT_DOUBLE_EQ(stats.corrected_dual_objective(), -36.5); + EXPECT_DOUBLE_EQ(stats.l_inf_dual_residual(), 0.5); + EXPECT_DOUBLE_EQ(stats.l2_dual_residual(), 0.5); +} + +TEST(CorrectedDualObjective, QpSuboptimal) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestDiagonalQp1(), num_threads, + num_shards); + + Eigen::VectorXd primal_solution(2), dual_solution(1); + dual_solution << -3; + primal_solution << -2.0, 2.0; + const ConvergenceInformation stats = ComputeScaledConvergenceInformation( + sharded_qp, primal_solution, dual_solution, POINT_TYPE_CURRENT_ITERATE); + // primal gradient vector: [-6, 4] + // Constant term: 5 + // Quadratic term: -(16+4)/2 = -10 + // Dual objective term: -3 * 1 + // Primal variables at bounds term: 2*-6 + -2*4 = -20 + // -28.0 = 5 - 10 - 3 - 20 + EXPECT_DOUBLE_EQ(stats.corrected_dual_objective(), -28.0); +} + +TEST(RandomProjectionsTest, OneRandomProjectionsOfZeroVector) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestLp(), num_threads, num_shards); + + PointMetadata metadata; + SetRandomProjections(sharded_qp, /*primal_solution=*/Eigen::VectorXd::Zero(4), + /*dual_solution=*/Eigen::VectorXd::Zero(4), + /*random_projection_seeds=*/{1}, metadata); + EXPECT_THAT(metadata.random_primal_projections(), ElementsAre(0.0)); + EXPECT_THAT(metadata.random_dual_projections(), ElementsAre(0.0)); +} + +TEST(RandomProjectionsTest, TwoRandomProjectionsOfVector) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestLp(), num_threads, num_shards); + + PointMetadata metadata; + SetRandomProjections(sharded_qp, /*primal_solution=*/Eigen::VectorXd::Ones(4), + /*dual_solution=*/Eigen::VectorXd::Zero(4), + /*random_projection_seeds=*/{1, 2}, metadata); + EXPECT_THAT(metadata.random_primal_projections(), SizeIs(2)); + EXPECT_THAT(metadata.random_dual_projections(), SizeIs(2)); + // The primal solution has norm 2; the random projection should only reduce + // the norm. Obtaining 0.0 is a probability-zero event. + EXPECT_THAT(metadata.random_primal_projections(), + Each(AllOf(Ge(-2.0), Le(2.0), Ne(0.0)))); + EXPECT_THAT(metadata.random_dual_projections(), Each(Eq(0.0))); +} + +TEST(ReducedCostsTest, SimpleLp) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestLp(), num_threads, num_shards); + + Eigen::VectorXd primal_solution(4), dual_solution(4); + // Use a primal solution at the relevant bounds, to ensure handling as + // reduced costs. + primal_solution << 0.0, -2.0, 6.0, 3.5; + dual_solution << 1.0, 0.0, 0.0, -2.0; + // c is: [5.5, -2, -1, 1] + // -A'y is: [-2, -1, 2, -4] + // c - A'y is: [3.5, -3.0, 1.0, -3.0]. + EXPECT_THAT(ReducedCosts(sharded_qp, primal_solution, dual_solution), + ElementsAre(0.0, 0.0, 0.0, -3.0)); + EXPECT_THAT(ReducedCosts(sharded_qp, primal_solution, dual_solution, + /*use_zero_primal_objective=*/true), + ElementsAre(0.0, 0.0, 0.0, -4.0)); +} + +TEST(ReducedCostsTest, SimpleLpWithGapResiduals) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestLp(), num_threads, num_shards); + + Eigen::VectorXd primal_solution(4), dual_solution(4); + primal_solution = Eigen::VectorXd::Zero(4); + dual_solution << 1.0, 0.0, 0.0, -1.0; + // c is: [5.5, -2, -1, 1] + // -A'y is: [-2, -1, 0.5, -3] + // c - A'y is: [3.5, -3.0, -0.5, -2.0]. + // When the primal variable is 0.0 and the bound is not 0.0, the c - A'y is + // always handled as a residual. + EXPECT_THAT(ReducedCosts(sharded_qp, primal_solution, dual_solution), + ElementsAre(0.0, 0.0, 0.0, 0.0)); + // If the primal variables are closer to the bound, c - A'y is handled as a + // reduced cost. + primal_solution << 0.0, 0.0, 4.0, 3.0; + EXPECT_THAT(ReducedCosts(sharded_qp, primal_solution, dual_solution), + ElementsAre(0.0, 0.0, -0.5, -2.0)); +} + +TEST(ReducedCostsTest, SimpleQp) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestDiagonalQp1(), num_threads, + num_shards); + + Eigen::VectorXd primal_solution(2), dual_solution(1); + primal_solution << 1.0, 2.0; + dual_solution << 0.0; + // Q*x is: [4.0, 2.0] + // c is: [-1, -1] + // A'y is zero. + // The second primal gradient term is handled as a residual, not a reduced + // cost. + EXPECT_THAT(ReducedCosts(sharded_qp, primal_solution, dual_solution), + ElementsAre(3.0, 0.0)); + EXPECT_THAT(ReducedCosts(sharded_qp, primal_solution, dual_solution, + /*use_zero_primal_objective=*/true), + ElementsAre(0.0, 0.0)); +} + +TEST(GetConvergenceInformation, GetsCorrectEntry) { + const auto test_stats = ParseTextOrDie(R"pb( + convergence_information { + candidate_type: POINT_TYPE_CURRENT_ITERATE + primal_objective: 1.0 + } + convergence_information { + candidate_type: POINT_TYPE_AVERAGE_ITERATE + primal_objective: 2.0 + } + )pb"); + const auto average_info = + GetConvergenceInformation(test_stats, POINT_TYPE_AVERAGE_ITERATE); + ASSERT_TRUE(average_info.has_value()); + EXPECT_EQ(average_info->candidate_type(), POINT_TYPE_AVERAGE_ITERATE); + EXPECT_EQ(average_info->primal_objective(), 2.0); + + const auto current_info = + GetConvergenceInformation(test_stats, POINT_TYPE_CURRENT_ITERATE); + ASSERT_TRUE(current_info.has_value()); + EXPECT_EQ(current_info->candidate_type(), POINT_TYPE_CURRENT_ITERATE); + EXPECT_EQ(current_info->primal_objective(), 1.0); + + EXPECT_THAT( + GetConvergenceInformation(test_stats, POINT_TYPE_ITERATE_DIFFERENCE), + Eq(absl::nullopt)); +} + +TEST(GetInfeasibilityInformation, GetsCorrectEntry) { + const auto test_stats = ParseTextOrDie(R"pb( + infeasibility_information { + candidate_type: POINT_TYPE_CURRENT_ITERATE + primal_ray_linear_objective: 1.0 + } + infeasibility_information { + candidate_type: POINT_TYPE_AVERAGE_ITERATE + primal_ray_linear_objective: 2.0 + } + )pb"); + const auto average_info = + GetInfeasibilityInformation(test_stats, POINT_TYPE_AVERAGE_ITERATE); + ASSERT_TRUE(average_info.has_value()); + EXPECT_EQ(average_info->candidate_type(), POINT_TYPE_AVERAGE_ITERATE); + EXPECT_EQ(average_info->primal_ray_linear_objective(), 2.0); + + const auto current_info = + GetInfeasibilityInformation(test_stats, POINT_TYPE_CURRENT_ITERATE); + ASSERT_TRUE(current_info.has_value()); + EXPECT_EQ(current_info->candidate_type(), POINT_TYPE_CURRENT_ITERATE); + EXPECT_EQ(current_info->primal_ray_linear_objective(), 1.0); + + EXPECT_THAT( + GetInfeasibilityInformation(test_stats, POINT_TYPE_ITERATE_DIFFERENCE), + Eq(absl::nullopt)); +} + +TEST(GetPointMetadata, GetsCorrectEntry) { + const auto test_stats = ParseTextOrDie(R"pb( + point_metadata { + point_type: POINT_TYPE_CURRENT_ITERATE + active_primal_variable_count: 1 + } + point_metadata { + point_type: POINT_TYPE_AVERAGE_ITERATE + active_primal_variable_count: 2 + } + )pb"); + const auto average_info = + GetPointMetadata(test_stats, POINT_TYPE_AVERAGE_ITERATE); + ASSERT_TRUE(average_info.has_value()); + EXPECT_EQ(average_info->point_type(), POINT_TYPE_AVERAGE_ITERATE); + EXPECT_EQ(average_info->active_primal_variable_count(), 2); + + const auto current_info = + GetPointMetadata(test_stats, POINT_TYPE_CURRENT_ITERATE); + ASSERT_TRUE(current_info.has_value()); + EXPECT_EQ(current_info->point_type(), POINT_TYPE_CURRENT_ITERATE); + EXPECT_EQ(current_info->active_primal_variable_count(), 1); + + EXPECT_THAT(GetPointMetadata(test_stats, POINT_TYPE_ITERATE_DIFFERENCE), + Eq(absl::nullopt)); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/primal_dual_hybrid_gradient.cc b/ortools/pdlp/primal_dual_hybrid_gradient.cc new file mode 100644 index 0000000000..b36d6bab3f --- /dev/null +++ b/ortools/pdlp/primal_dual_hybrid_gradient.cc @@ -0,0 +1,2005 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/primal_dual_hybrid_gradient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/base/status_macros.h" +#include "ortools/base/timer.h" +#include "ortools/glop/parameters.pb.h" +#include "ortools/glop/preprocessor.h" +#include "ortools/linear_solver/linear_solver.pb.h" +#include "ortools/lp_data/lp_types.h" +#include "ortools/lp_data/proto_utils.h" +#include "ortools/pdlp/iteration_stats.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_optimization_utils.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" +#include "ortools/pdlp/solvers_proto_validation.h" +#include "ortools/pdlp/termination.h" +#include "ortools/pdlp/trust_region.h" + +namespace operations_research::pdlp { + +namespace { + +using ::Eigen::VectorXd; + +using IterationStatsCallback = + std::function; + +// Returns infinity norm of the given matrix viewed as a vector. +double MaxAbsCoefficient( + const Eigen::SparseMatrix& matrix) { + // Note: matrix.coeffs().lpNorm() gives a link error. + return matrix.nonZeros() ? matrix.coeffs().cwiseAbs().maxCoeff() : 0; +} + +// If `num_shards' is positive, returns it. Otherwise returns a reasonable +// number of shards to use with ShardedQuadraticProgram for the given number of +// threads. +int NumShards(const int num_shards, const int num_threads) { + if (num_shards > 0) return num_shards; + return num_threads == 1 ? 1 : 4 * num_threads; +} + +std::string ToString(const ConvergenceInformation& convergence_information, + const RelativeConvergenceInformation& relative_information, + const OptimalityNorm residual_norm) { + constexpr absl::string_view kFormatStr = + "%#12.6g %#12.6g %#12.6g | %#12.6g %#12.6g %#12.6g | %#12.6g %#12.6g | " + "%#12.6g %#12.6g"; + switch (residual_norm) { + case OPTIMALITY_NORM_L2: + return absl::StrFormat(kFormatStr, + relative_information.relative_l2_primal_residual, + relative_information.relative_l2_dual_residual, + relative_information.relative_optimality_gap, + convergence_information.l2_primal_residual(), + convergence_information.l2_dual_residual(), + convergence_information.primal_objective() - + convergence_information.dual_objective(), + convergence_information.primal_objective(), + convergence_information.dual_objective(), + convergence_information.l2_primal_variable(), + convergence_information.l2_dual_variable()); + case OPTIMALITY_NORM_L_INF: + return absl::StrFormat( + kFormatStr, relative_information.relative_l_inf_primal_residual, + relative_information.relative_l_inf_dual_residual, + relative_information.relative_optimality_gap, + convergence_information.l_inf_primal_residual(), + convergence_information.l_inf_dual_residual(), + convergence_information.primal_objective() - + convergence_information.dual_objective(), + convergence_information.primal_objective(), + convergence_information.dual_objective(), + convergence_information.l2_primal_variable(), + convergence_information.l2_dual_variable()); + case OPTIMALITY_NORM_UNSPECIFIED: + LOG(FATAL) << "Invalid residual norm."; + } +} + +std::string ToShortString( + const ConvergenceInformation& convergence_information, + const RelativeConvergenceInformation& relative_information, + const OptimalityNorm residual_norm) { + constexpr absl::string_view kFormatStr = + "%#10.4g %#10.4g %#10.4g | %#10.4g %#10.4g"; + switch (residual_norm) { + case OPTIMALITY_NORM_L2: + return absl::StrFormat(kFormatStr, + relative_information.relative_l2_primal_residual, + relative_information.relative_l2_dual_residual, + relative_information.relative_optimality_gap, + convergence_information.primal_objective(), + convergence_information.dual_objective()); + case OPTIMALITY_NORM_L_INF: + return absl::StrFormat( + kFormatStr, relative_information.relative_l_inf_primal_residual, + relative_information.relative_l_inf_dual_residual, + relative_information.relative_optimality_gap, + convergence_information.primal_objective(), + convergence_information.dual_objective()); + case OPTIMALITY_NORM_UNSPECIFIED: + LOG(FATAL) << "Invalid residual norm."; + } +} + +// Returns a string describing iter_stats, based on the ConvergenceInformation +// with candidate_type==preferred_candidate if one exists, otherwise based on +// the first value, if any. termination_criteria.optimality_norm determines the +// norm in which the residuals are displayed. +std::string ToString(const IterationStats& iter_stats, + const TerminationCriteria& termination_criteria, + const QuadraticProgramBoundNorms& bound_norms, + PointType preferred_candidate) { + std::string iteration_string = + absl::StrFormat("%6d %8.1f %6.1f", iter_stats.iteration_number(), + iter_stats.cumulative_kkt_matrix_passes(), + iter_stats.cumulative_time_sec()); + auto convergence_information = + GetConvergenceInformation(iter_stats, preferred_candidate); + if (!convergence_information.has_value() && + iter_stats.convergence_information_size() > 0) { + convergence_information = iter_stats.convergence_information(0); + } + if (convergence_information.has_value()) { + const RelativeConvergenceInformation relative_information = + ComputeRelativeResiduals(termination_criteria.eps_optimal_absolute(), + termination_criteria.eps_optimal_relative(), + bound_norms, *convergence_information); + return absl::StrCat(iteration_string, " | ", + ToString(*convergence_information, relative_information, + termination_criteria.optimality_norm())); + } + return iteration_string; +} + +std::string ToShortString(const IterationStats& iter_stats, + const TerminationCriteria& termination_criteria, + const QuadraticProgramBoundNorms& bound_norms, + PointType preferred_candidate) { + std::string iteration_string = + absl::StrFormat("%6d %6.1f", iter_stats.iteration_number(), + iter_stats.cumulative_time_sec()); + auto convergence_information = + GetConvergenceInformation(iter_stats, preferred_candidate); + if (!convergence_information.has_value() && + iter_stats.convergence_information_size() > 0) { + convergence_information = iter_stats.convergence_information(0); + } + if (convergence_information.has_value()) { + const RelativeConvergenceInformation relative_information = + ComputeRelativeResiduals(termination_criteria.eps_optimal_absolute(), + termination_criteria.eps_optimal_relative(), + bound_norms, *convergence_information); + return absl::StrCat( + iteration_string, " | ", + ToShortString(*convergence_information, relative_information, + termination_criteria.optimality_norm())); + } + return iteration_string; +} + +// Returns a label string corresponding to the format of ToString(). +std::string ConvergenceInformationLabelString() { + return absl::StrFormat( + "%12s %12s %12s | %12s %12s %12s | %12s %12s | %12s %12s", "rel_prim_res", + "rel_dual_res", "rel_gap", "prim_resid", "dual_resid", "obj_gap", + "prim_obj", "dual_obj", "prim_var_l2", "dual_var_l2"); +} + +std::string ConvergenceInformationLabelShortString() { + return absl::StrFormat("%10s %10s %10s | %10s %10s", "rel_p_res", "rel_d_res", + "rel_gap", "prim_obj", "dual_obj"); +} + +std::string IterationStatsLabelString() { + return absl::StrCat( + absl::StrFormat("%6s %8s %6s", "iter#", "kkt_pass", "time"), " | ", + ConvergenceInformationLabelString()); +} + +std::string IterationStatsLabelShortString() { + return absl::StrCat(absl::StrFormat("%6s %6s", "iter#", "time"), " | ", + ConvergenceInformationLabelShortString()); +} + +enum class InnerStepOutcome { + kSuccessful, + kForceNumericalTermination, +}; + +class Solver { + public: + // Assumes that the qp and params are valid. + // Note that the qp is intentionally passed by value. + Solver(QuadraticProgram qp, const PrimalDualHybridGradientParams& params); + + // Not copyable or movable because of const and reference members. + Solver(const Solver&) = delete; + Solver& operator=(const Solver&) = delete; + + // Zero is used if initial_solution is nullopt. + SolverResult Solve(absl::optional initial_solution, + IterationStatsCallback iteration_stats_callback); + + private: + struct NextSolutionAndDelta { + VectorXd value; + // delta is value - current_solution. + VectorXd delta; + }; + + struct DistanceBasedRestartInfo { + double distance_moved_last_restart_period; + int length_of_last_restart_period; + }; + + struct PresolveInfo { + explicit PresolveInfo(ShardedQuadraticProgram original_qp, + const PrimalDualHybridGradientParams& params) + : preprocessor_parameters(PreprocessorParameters(params)), + preprocessor(&preprocessor_parameters), + sharded_original_qp(std::move(original_qp)), + trivial_col_scaling_vec( + VectorXd::Ones(sharded_original_qp.PrimalSize())), + trivial_row_scaling_vec( + VectorXd::Ones(sharded_original_qp.DualSize())) {} + glop::GlopParameters preprocessor_parameters; + glop::MainLpPreprocessor preprocessor; + ShardedQuadraticProgram sharded_original_qp; + bool presolved_problem_was_maximization = false; + const VectorXd trivial_col_scaling_vec, trivial_row_scaling_vec; + }; + + // Movement terms (weighted squared norms of primal and dual deltas) larger + // than this cause termination because iterates are diverging, and likely to + // cause infinite and NaN values. + constexpr static double kDivergentMovement = 1.0e100; + + NextSolutionAndDelta ComputeNextPrimalSolution(double primal_step_size) const; + + NextSolutionAndDelta ComputeNextDualSolution( + double dual_step_size, double extrapolation_factor, + const NextSolutionAndDelta& next_primal) const; + + double ComputeMovement(const VectorXd& delta_primal, + const VectorXd& delta_dual) const; + + double ComputeNonlinearity(const VectorXd& delta_primal, + const VectorXd& next_dual_product) const; + + // Creates all the simple-to-compute statistics in stats. + IterationStats CreateSimpleIterationStats(RestartChoice restart_used) const; + + RestartChoice ChooseRestartToApply(bool is_major_iteration); + + VectorXd PrimalAverage() const; + + VectorXd DualAverage() const; + + double ComputeNewPrimalWeight() const; + + // Picks the primal and dual solutions according to output_type, unscales them + // and makes the closing changes to the SolveLog. This function should only be + // called once the solver is finishing its execution. + // NOTE: The primal_solution and dual_solution are used as the output except + // when output_type is POINT_TYPE_CURRENT_ITERATE or + // POINT_TYPE_ITERATE_DIFFERENCE, in which case the values are computed from + // Solver data. NOTE: Both primal_solution and dual_solution are passed by + // copy. To avoid unnecessary copying, move these objects (i.e. use + // std::move()). + SolverResult ConstructSolverResult(VectorXd primal_solution, + VectorXd dual_solution, + const IterationStats& stats, + TerminationReason termination_reason, + PointType output_type, + SolveLog solve_log) const; + + // Adds one entry of convergence information and infeasibility information to + // stats using the input solutions. The primal_solution and dual_solution are + // solutions for sharded_qp. The col_scaling_vec and row_scaling_vec are used + // to implicitly unscale sharded_qp when computing the relevant information. + void AddConvergenceAndInfeasibilityInformation( + const VectorXd& primal_solution, const VectorXd& dual_solution, + const ShardedQuadraticProgram& sharded_qp, + const VectorXd& col_scaling_vec, const VectorXd& row_scaling_vec, + PointType candidate_type, IterationStats& stats) const; + + // Adds one entry of PointMetadata to stats using the input solutions. + void AddPointMetadata(const VectorXd& primal_solution, + const VectorXd& dual_solution, PointType point_type, + IterationStats& stats) const; + + // Returns a TerminationReasonAndPointType when the termination criteria are + // satisfied, otherwise returns nothing. Uses the primal and dual vectors to + // compute solution statistics and adds them to the stats proto. + // NOTE: The primal and dual input pair should be a scaled solution. + absl::optional + UpdateIterationStatsAndCheckTermination(bool force_numerical_termination, + const VectorXd& primal_average, + const VectorXd& dual_average, + IterationStats& stats) const; + + double DistanceTraveledFromLastStart(const VectorXd& primal_solution, + const VectorXd& dual_solution) const; + + LocalizedLagrangianBounds ComputeLocalizedBoundsAtCurrent() const; + + LocalizedLagrangianBounds ComputeLocalizedBoundsAtAverage() const; + + double InitialPrimalWeight(double l2_norm_primal_linear_objective, + double l2_norm_constraint_bounds) const; + + void ComputeAndApplyRescaling(); + + // Applies the given RestartChoice. If a restart is chosen, updates the + // state of the algorithm accordingly and computes a new primal weight. + void ApplyRestartChoice(RestartChoice restart_to_apply); + + absl::optional MajorIterationAndTerminationCheck( + bool force_numerical_termination, SolveLog& solve_log); + + bool ShouldDoAdaptiveRestartHeuristic(double candidate_normalized_gap) const; + + RestartChoice DetermineDistanceBasedRestartChoice() const; + + void ResetAverageToCurrent(); + + void LogNumericalTermination() const; + + void LogInnerIterationLimitHit() const; + + void LogQuadraticProgramStats(const QuadraticProgramStats& stats); + + // Takes a step based on the Malitsky and Pock linesearch algorithm. + // (https://arxiv.org/pdf/1608.08883.pdf) + // The current implementation is provably convergent (at an optimal rate) + // for LP programs (provided we do not change the primal weight at every major + // iteration). Further, we have observed that this rule is very sensitive to + // the parameter choice whenever we apply the primal weight recomputation + // heuristic. + InnerStepOutcome TakeMalitskyPockStep(); + + // Takes a step based on the adaptive heuristic presented in Section 3.1 of + // https://arxiv.org/pdf/2106.04756.pdf (further generalized to QP). + InnerStepOutcome TakeAdaptiveStep(); + + // Takes a constant-size step. + InnerStepOutcome TakeConstantSizeStep(); + + const QuadraticProgram& WorkingQp() const { return sharded_working_qp_.Qp(); } + + // TODO(user): experiment with different preprocessor types. + static glop::GlopParameters PreprocessorParameters( + const PrimalDualHybridGradientParams& params); + + // If presolve is enabled, moves sharded_working_qp_ to + // presolve_info_.sharded_original_qp and computes the presolved linear + // program and installs it in sharded_working_qp_. Clears initial_solution if + // presolve is enabled. If presolve solves the problem completely returns the + // appropriate TerminationReason. Otherwise returns nullopt. If presolve + // is disabled or an error occurs modifies nothing and returns nullopt. + absl::optional ApplyPresolveIfEnabled( + absl::optional* initial_solution); + + PrimalAndDualSolution RecoverOriginalSolution( + PrimalAndDualSolution working_solution) const; + + WallTimer timer_; + const PrimalDualHybridGradientParams params_; + const int num_shards_; + // This is the QP that PDHG is run on. It has been reduced by presolve and/or + // rescaled, if those are enabled. The original problem is available in + // presolve_info_->sharded_original_qp if presolve_info_.has_value(), and + // otherwise can be obtained by undoing the scaling of sharded_working_qp_ by + // col_scaling_vec_ and row_scaling_vec_. + ShardedQuadraticProgram sharded_working_qp_; + ShardedWeightedAverage primal_average_; + ShardedWeightedAverage dual_average_; + IterationStatsCallback iteration_stats_callback_; + QuadraticProgramBoundNorms original_bound_norms_; + + // Set iff presolve is enabled. + absl::optional presolve_info_; + + double step_size_; + // For Malitsky-Pock linesearch only: step_size_ / previous_step_size + double ratio_last_two_step_sizes_; + double primal_weight_; + // For adaptive restarts only. + double normalized_gap_at_last_trial_ = + std::numeric_limits::infinity(); + // For adaptive restarts only. + double normalized_gap_at_last_restart_ = + std::numeric_limits::infinity(); + int iterations_completed_; + int num_rejected_steps_; + VectorXd current_primal_solution_; + VectorXd current_dual_solution_; + VectorXd current_primal_delta_; + VectorXd current_dual_delta_; + // A cache of constraint_matrix.transpose() * current_dual_solution. + VectorXd current_dual_product_; + // The primal point at which the algorithm was last restarted from, or + // the initial primal starting point if no restart has occurred. + VectorXd last_primal_start_point_; + // The dual point at which the algorithm was last restarted from, or + // the initial dual starting point if no restart has occurred. + VectorXd last_dual_start_point_; + // Information for deciding whether to trigger a distance-based restart. + // The distances are initialized to +inf to force a restart during the first + // major iteration check. + DistanceBasedRestartInfo distance_based_restart_info_ = { + .distance_moved_last_restart_period = + std::numeric_limits::infinity(), + .length_of_last_restart_period = 1, + }; + // The scaling vectors that map the original (or presolved) quadratic program + // to the working version. See + // ShardedQuadraticProgram::RescaleQuadraticProgram() for details. + VectorXd col_scaling_vec_; + VectorXd row_scaling_vec_; +}; + +Solver::Solver(QuadraticProgram qp, + const PrimalDualHybridGradientParams& params) + : params_(params), + num_shards_(NumShards(params.num_shards(), params.num_threads())), + sharded_working_qp_(std::move(qp), params.num_threads(), num_shards_), + primal_average_(&sharded_working_qp_.PrimalSharder()), + dual_average_(&sharded_working_qp_.DualSharder()) {} + +Solver::NextSolutionAndDelta Solver::ComputeNextPrimalSolution( + double primal_step_size) const { + const int64_t primal_size = sharded_working_qp_.PrimalSize(); + NextSolutionAndDelta result = { + .value = VectorXd(primal_size), + .delta = VectorXd(primal_size), + }; + const QuadraticProgram& qp = WorkingQp(); + // This computes the primal portion of the PDHG algorithm: + // argmin_x[gradient(f)(current_primal_solution)'x + g(x) + // + current_dual_solution' K x + // + (0.5 / primal_step_size) * norm(x - current_primal_solution) ^ 2] + // See Sections 2 - 3 of Chambolle and Pock and the comment in the header. + // We omitted the constant terms from Chambolle and Pock's (7). + // This minimization is easy to do in closed form since it can be separated + // into independent problems for each of the primal variables. + sharded_working_qp_.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + if (!IsLinearProgram(qp)) { + const VectorXd diagonal_scaling = + primal_step_size * + shard(qp.objective_matrix->diagonal()).array() + + 1.0; + shard(result.value) = + (shard(current_primal_solution_) - + primal_step_size * + (shard(qp.objective_vector) - shard(current_dual_product_))) + // Scale i-th element by 1 / (1 + primal_step_size * Q_{ii}) + .cwiseQuotient(diagonal_scaling) + .cwiseMin(shard(qp.variable_upper_bounds)) + .cwiseMax(shard(qp.variable_lower_bounds)); + } else { + // The formula in the LP case is simplified for better performance. + shard(result.value) = + (shard(current_primal_solution_) - + primal_step_size * + (shard(qp.objective_vector) - shard(current_dual_product_))) + .cwiseMin(shard(qp.variable_upper_bounds)) + .cwiseMax(shard(qp.variable_lower_bounds)); + } + shard(result.delta) = + shard(result.value) - shard(current_primal_solution_); + }); + return result; +} + +Solver::NextSolutionAndDelta Solver::ComputeNextDualSolution( + double dual_step_size, double extrapolation_factor, + const NextSolutionAndDelta& next_primal_solution) const { + const int64_t dual_size = sharded_working_qp_.DualSize(); + NextSolutionAndDelta result = { + .value = VectorXd(dual_size), + .delta = VectorXd(dual_size), + }; + const QuadraticProgram& qp = WorkingQp(); + VectorXd extrapolated_primal(sharded_working_qp_.PrimalSize()); + sharded_working_qp_.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + shard(extrapolated_primal) = + (shard(next_primal_solution.value) + + extrapolation_factor * shard(next_primal_solution.delta)); + }); + // TODO(user): Refactor this multiplication so that we only do one matrix + // vector mutiply for the primal variable. This only applies to Malitsky and + // Pock and not to the adaptive step size rule. + sharded_working_qp_.TransposedConstraintMatrixSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + VectorXd temp = + shard(current_dual_solution_) - + dual_step_size * + shard(sharded_working_qp_.TransposedConstraintMatrix()) + .transpose() * + extrapolated_primal; + // Each element of the argument of the cwiseMin is the critical point + // of the respective 1D minimization problem if it's negative. + // Likewise the argument to the cwiseMax is the critical point if + // positive. + shard(result.value) = + VectorXd::Zero(temp.size()) + .cwiseMin(temp + + dual_step_size * shard(qp.constraint_upper_bounds)) + .cwiseMax(temp + + dual_step_size * shard(qp.constraint_lower_bounds)); + shard(result.delta) = + (shard(result.value) - shard(current_dual_solution_)); + }); + return result; +} + +double Solver::ComputeMovement(const VectorXd& delta_primal, + const VectorXd& delta_dual) const { + const double primal_movement = + (0.5 * primal_weight_) * + SquaredNorm(delta_primal, sharded_working_qp_.PrimalSharder()); + const double dual_movement = + (0.5 / primal_weight_) * + SquaredNorm(delta_dual, sharded_working_qp_.DualSharder()); + return primal_movement + dual_movement; +} + +double Solver::ComputeNonlinearity(const VectorXd& delta_primal, + const VectorXd& next_dual_product) const { + // Lemma 1 in Chambolle and Pock includes a term with L_f, the Lipshitz + // constant of f. This is zero in our formulation. + return sharded_working_qp_.PrimalSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + return -shard(delta_primal) + .dot(shard(next_dual_product) - + shard(current_dual_product_)); + }); +} + +IterationStats Solver::CreateSimpleIterationStats( + RestartChoice restart_used) const { + IterationStats stats; + double num_kkt_passes_per_rejected_step = 1.0; + if (params_.linesearch_rule() == + PrimalDualHybridGradientParams::MALITSKY_POCK_LINESEARCH_RULE) { + num_kkt_passes_per_rejected_step = 0.5; + } + stats.set_iteration_number(iterations_completed_); + stats.set_cumulative_rejected_steps(num_rejected_steps_); + // TODO(user): This formula doesn't account for kkt passes in major + // iterations. + stats.set_cumulative_kkt_matrix_passes(iterations_completed_ + + num_kkt_passes_per_rejected_step * + num_rejected_steps_); + stats.set_cumulative_time_sec(timer_.Get()); + stats.set_restart_used(restart_used); + stats.set_step_size(step_size_); + stats.set_primal_weight(primal_weight_); + return stats; +} + +double Solver::DistanceTraveledFromLastStart( + const VectorXd& primal_solution, const VectorXd& dual_solution) const { + return std::sqrt((0.5 * primal_weight_) * + SquaredDistance(primal_solution, + last_primal_start_point_, + sharded_working_qp_.PrimalSharder()) + + (0.5 / primal_weight_) * + SquaredDistance(dual_solution, last_dual_start_point_, + sharded_working_qp_.DualSharder())); +} + +LocalizedLagrangianBounds Solver::ComputeLocalizedBoundsAtCurrent() const { + const double distance_traveled_by_current = DistanceTraveledFromLastStart( + current_primal_solution_, current_dual_solution_); + return ComputeLocalizedLagrangianBounds( + sharded_working_qp_, current_primal_solution_, current_dual_solution_, + PrimalDualNorm::kEuclideanNorm, primal_weight_, + distance_traveled_by_current, + /*primal_product=*/nullptr, ¤t_dual_product_, + params_.use_diagonal_qp_trust_region_solver(), + params_.diagonal_qp_trust_region_solver_tolerance()); +} + +LocalizedLagrangianBounds Solver::ComputeLocalizedBoundsAtAverage() const { + // TODO(user): These vectors are recomputed again for termination checks + // and again if we eventually restart to the average. + VectorXd average_primal = PrimalAverage(); + VectorXd average_dual = DualAverage(); + + const double distance_traveled_by_average = + DistanceTraveledFromLastStart(average_primal, average_dual); + + return ComputeLocalizedLagrangianBounds( + sharded_working_qp_, average_primal, average_dual, + PrimalDualNorm::kEuclideanNorm, primal_weight_, + distance_traveled_by_average, + /*primal_product=*/nullptr, /*dual_product=*/nullptr, + params_.use_diagonal_qp_trust_region_solver(), + params_.diagonal_qp_trust_region_solver_tolerance()); +} + +bool AverageHasBetterPotential( + const LocalizedLagrangianBounds& local_bounds_at_average, + const LocalizedLagrangianBounds& local_bounds_at_current) { + return BoundGap(local_bounds_at_average) / + MathUtil::Square(local_bounds_at_average.radius) < + BoundGap(local_bounds_at_current) / + MathUtil::Square(local_bounds_at_current.radius); +} + +double NormalizedGap( + const LocalizedLagrangianBounds& local_bounds_at_candidate) { + const double distance_traveled_by_candidate = + local_bounds_at_candidate.radius; + return BoundGap(local_bounds_at_candidate) / distance_traveled_by_candidate; +} + +// TODO(user): Review / cleanup adaptive heuristic. +bool Solver::ShouldDoAdaptiveRestartHeuristic( + double candidate_normalized_gap) const { + const double gap_reduction_ratio = + candidate_normalized_gap / normalized_gap_at_last_restart_; + if (gap_reduction_ratio < params_.sufficient_reduction_for_restart()) { + return true; + } + if (gap_reduction_ratio < params_.necessary_reduction_for_restart() && + candidate_normalized_gap > normalized_gap_at_last_trial_) { + // We've made the "necessary" amount of progress, and iterates appear to + // be getting worse, so restart. + return true; + } + return false; +} + +RestartChoice Solver::DetermineDistanceBasedRestartChoice() const { + // The following checks are safeguards that normally should not be triggered. + if (primal_average_.NumTerms() == 0) { + return RESTART_CHOICE_NO_RESTART; + } else if (distance_based_restart_info_.length_of_last_restart_period == 0) { + return RESTART_CHOICE_RESTART_TO_AVERAGE; + } + const int restart_period_length = primal_average_.NumTerms(); + const double distance_moved_this_restart_period_by_average = + DistanceTraveledFromLastStart(primal_average_.ComputeAverage(), + dual_average_.ComputeAverage()); + const double distance_moved_last_restart_period = + distance_based_restart_info_.distance_moved_last_restart_period; + + // A restart should be triggered when the normalized distance traveled by + // the average is at least a constant factor smaller than the last. + // TODO(user): Experiment with using .necessary_reduction_for_restart() + // as a heuristic when deciding if a restart should be triggered. + if ((distance_moved_this_restart_period_by_average / restart_period_length) < + params_.sufficient_reduction_for_restart() * + (distance_moved_last_restart_period / + distance_based_restart_info_.length_of_last_restart_period)) { + // Restart at current solution when it yields a smaller normalized potential + // function value than the average (heuristic suggested by ohinder@). + if (AverageHasBetterPotential(ComputeLocalizedBoundsAtAverage(), + ComputeLocalizedBoundsAtCurrent())) { + return RESTART_CHOICE_RESTART_TO_AVERAGE; + } else { + return RESTART_CHOICE_WEIGHTED_AVERAGE_RESET; + } + } else { + return RESTART_CHOICE_NO_RESTART; + } +} + +RestartChoice Solver::ChooseRestartToApply(const bool is_major_iteration) { + if (!primal_average_.HasNonzeroWeight() && + !dual_average_.HasNonzeroWeight()) { + return RESTART_CHOICE_NO_RESTART; + } + // TODO(user): This forced restart is very important for the performance of + // ADAPTIVE_HEURISTIC. Test if the impact comes primarily from the first + // forced restart (which would unseat a good initial starting point that could + // prevent restarts early in the solve) or if it's really needed for the full + // duration of the solve. If it is really needed, should we then trigger major + // iterations on powers of two? + const int restart_length = primal_average_.NumTerms(); + if (restart_length >= iterations_completed_ / 2 && + params_.restart_strategy() == + PrimalDualHybridGradientParams::ADAPTIVE_HEURISTIC) { + if (AverageHasBetterPotential(ComputeLocalizedBoundsAtAverage(), + ComputeLocalizedBoundsAtCurrent())) { + return RESTART_CHOICE_RESTART_TO_AVERAGE; + } else { + return RESTART_CHOICE_WEIGHTED_AVERAGE_RESET; + } + } + if (is_major_iteration) { + switch (params_.restart_strategy()) { + case PrimalDualHybridGradientParams::NO_RESTARTS: + return RESTART_CHOICE_WEIGHTED_AVERAGE_RESET; + case PrimalDualHybridGradientParams::EVERY_MAJOR_ITERATION: + return RESTART_CHOICE_RESTART_TO_AVERAGE; + case PrimalDualHybridGradientParams::ADAPTIVE_HEURISTIC: { + const LocalizedLagrangianBounds local_bounds_at_average = + ComputeLocalizedBoundsAtAverage(); + const LocalizedLagrangianBounds local_bounds_at_current = + ComputeLocalizedBoundsAtCurrent(); + double normalized_gap; + RestartChoice choice; + if (AverageHasBetterPotential(local_bounds_at_average, + local_bounds_at_current)) { + normalized_gap = NormalizedGap(local_bounds_at_average); + choice = RESTART_CHOICE_RESTART_TO_AVERAGE; + } else { + normalized_gap = NormalizedGap(local_bounds_at_current); + choice = RESTART_CHOICE_WEIGHTED_AVERAGE_RESET; + } + if (ShouldDoAdaptiveRestartHeuristic(normalized_gap)) { + return choice; + } else { + normalized_gap_at_last_trial_ = normalized_gap; + return RESTART_CHOICE_NO_RESTART; + } + } + case PrimalDualHybridGradientParams::ADAPTIVE_DISTANCE_BASED: { + return DetermineDistanceBasedRestartChoice(); + } + default: + LOG(FATAL) << "Unrecognized restart_strategy " + << params_.restart_strategy(); + return RESTART_CHOICE_UNSPECIFIED; + } + } else { + return RESTART_CHOICE_NO_RESTART; + } +} + +VectorXd Solver::PrimalAverage() const { + if (primal_average_.HasNonzeroWeight()) { + return primal_average_.ComputeAverage(); + } else { + return current_primal_solution_; + } +} + +VectorXd Solver::DualAverage() const { + if (dual_average_.HasNonzeroWeight()) { + return dual_average_.ComputeAverage(); + } else { + return current_dual_solution_; + } +} + +double Solver::ComputeNewPrimalWeight() const { + const double primal_distance = + Distance(current_primal_solution_, last_primal_start_point_, + sharded_working_qp_.PrimalSharder()); + const double dual_distance = + Distance(current_dual_solution_, last_dual_start_point_, + sharded_working_qp_.DualSharder()); + // This choice of a nonzero tolerance balances performance and numerical + // issues caused by very huge or very tiny weights. It was picked as the best + // among {0.0, 1.0e-20, 2.0e-16, 1.0e-10, 1.0e-5} on the preprocessed MIPLIB + // dataset. The effect of changing this value is relatively minor overall. + constexpr double kNonzeroTol = 1.0e-10; + if (primal_distance <= kNonzeroTol || primal_distance >= 1.0 / kNonzeroTol || + dual_distance <= kNonzeroTol || dual_distance >= 1.0 / kNonzeroTol) { + return primal_weight_; + } + const double smoothing_param = params_.primal_weight_update_smoothing(); + const double unsmoothed_new_primal_weight = dual_distance / primal_distance; + const double new_primal_weight = + std::exp(smoothing_param * std::log(unsmoothed_new_primal_weight) + + (1.0 - smoothing_param) * std::log(primal_weight_)); + VLOG(4) << "New computed primal weight is " << new_primal_weight + << " at iteration " << iterations_completed_; + return new_primal_weight; +} + +SolverResult Solver::ConstructSolverResult(VectorXd primal_solution, + VectorXd dual_solution, + const IterationStats& stats, + TerminationReason termination_reason, + PointType output_type, + SolveLog solve_log) const { + switch (output_type) { + case POINT_TYPE_AVERAGE_ITERATE: + solve_log.set_solution_type(POINT_TYPE_AVERAGE_ITERATE); + break; + case POINT_TYPE_CURRENT_ITERATE: + AssignVector(current_primal_solution_, + sharded_working_qp_.PrimalSharder(), primal_solution); + AssignVector(current_dual_solution_, sharded_working_qp_.DualSharder(), + dual_solution); + solve_log.set_solution_type(POINT_TYPE_CURRENT_ITERATE); + break; + case POINT_TYPE_ITERATE_DIFFERENCE: + AssignVector(current_primal_delta_, sharded_working_qp_.PrimalSharder(), + primal_solution); + AssignVector(current_dual_delta_, sharded_working_qp_.DualSharder(), + dual_solution); + solve_log.set_solution_type(POINT_TYPE_ITERATE_DIFFERENCE); + break; + case POINT_TYPE_PRESOLVER_SOLUTION: + solve_log.set_solution_type(POINT_TYPE_PRESOLVER_SOLUTION); + break; + default: + // Default to average whenever the type is POINT_TYPE_NONE. + solve_log.set_solution_type(POINT_TYPE_AVERAGE_ITERATE); + break; + } + VectorXd reduced_costs; + const bool use_zero_primal_objective = + termination_reason == TERMINATION_REASON_PRIMAL_INFEASIBLE; + if (presolve_info_.has_value()) { + // Transform the solutions so they match the original unscaled problem. + PrimalAndDualSolution original_solution = + RecoverOriginalSolution({.primal_solution = std::move(primal_solution), + .dual_solution = std::move(dual_solution)}); + primal_solution = std::move(original_solution.primal_solution); + dual_solution = std::move(original_solution.dual_solution); + // RecoverOriginalSolution doesn't recover reduced costs so we need to + // compute them with respect to the original problem. + reduced_costs = + ReducedCosts(presolve_info_->sharded_original_qp, primal_solution, + dual_solution, use_zero_primal_objective); + } else { + reduced_costs = ReducedCosts(sharded_working_qp_, primal_solution, + dual_solution, use_zero_primal_objective); + // Transform the solutions so they match the original unscaled problem. + CoefficientWiseProductInPlace( + col_scaling_vec_, sharded_working_qp_.PrimalSharder(), primal_solution); + CoefficientWiseProductInPlace( + row_scaling_vec_, sharded_working_qp_.DualSharder(), dual_solution); + CoefficientWiseQuotientInPlace( + col_scaling_vec_, sharded_working_qp_.PrimalSharder(), reduced_costs); + } + if (iteration_stats_callback_ != nullptr) { + iteration_stats_callback_( + {.termination_criteria = params_.termination_criteria(), + .iteration_stats = stats, + .bound_norms = original_bound_norms_}); + } + + VLOG(1) << "Termination reason: " + << TerminationReason_Name(termination_reason); + VLOG(1) << "Solution point type: " << PointType_Name(output_type); + VLOG(1) << "Final solution stats:"; + VLOG(1) << IterationStatsLabelString(); + VLOG(1) << ToString(stats, params_.termination_criteria(), + original_bound_norms_, solve_log.solution_type()); + solve_log.set_iteration_count(stats.iteration_number()); + solve_log.set_termination_reason(termination_reason); + solve_log.set_solve_time_sec(stats.cumulative_time_sec()); + *solve_log.mutable_solution_stats() = stats; + return SolverResult{.primal_solution = std::move(primal_solution), + .dual_solution = std::move(dual_solution), + .reduced_costs = std::move(reduced_costs), + .solve_log = std::move(solve_log)}; +} + +void SetActiveSetInformation(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution, + const VectorXd& dual_solution, + const VectorXd& primal_start_point, + const VectorXd& dual_start_point, + PointMetadata& metadata) { + CHECK_EQ(primal_solution.size(), sharded_qp.PrimalSize()); + CHECK_EQ(dual_solution.size(), sharded_qp.DualSize()); + CHECK_EQ(primal_start_point.size(), sharded_qp.PrimalSize()); + CHECK_EQ(dual_start_point.size(), sharded_qp.DualSize()); + + const QuadraticProgram& qp = sharded_qp.Qp(); + metadata.set_active_primal_variable_count( + static_cast(sharded_qp.PrimalSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + const auto primal_shard = shard(primal_solution); + const auto lower_bound_shard = shard(qp.variable_lower_bounds); + const auto upper_bound_shard = shard(qp.variable_upper_bounds); + return (primal_shard.array() > lower_bound_shard.array() && + primal_shard.array() < upper_bound_shard.array()) + .count(); + }))); + + // Most of the computation from the previous ParallelSumOverShards is + // duplicated here. However the overhead shouldn't be too large, and using + // ParallelSumOverShards is simpler than just using ParallelForEachShard. + metadata.set_active_primal_variable_change( + static_cast(sharded_qp.PrimalSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + const auto primal_shard = shard(primal_solution); + const auto primal_start_shard = shard(primal_start_point); + const auto lower_bound_shard = shard(qp.variable_lower_bounds); + const auto upper_bound_shard = shard(qp.variable_upper_bounds); + return ((primal_shard.array() > lower_bound_shard.array() && + primal_shard.array() < upper_bound_shard.array()) != + (primal_start_shard.array() > lower_bound_shard.array() && + primal_start_shard.array() < upper_bound_shard.array())) + .count(); + }))); + + metadata.set_active_dual_variable_count( + static_cast(sharded_qp.DualSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + const auto dual_shard = shard(dual_solution); + const auto lower_bound_shard = shard(qp.constraint_lower_bounds); + const auto upper_bound_shard = shard(qp.constraint_upper_bounds); + const double kInfinity = std::numeric_limits::infinity(); + return (dual_shard.array() != 0.0 || + (lower_bound_shard.array() == -kInfinity && + upper_bound_shard.array() == kInfinity)) + .count(); + }))); + + metadata.set_active_dual_variable_change( + static_cast(sharded_qp.DualSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + const auto dual_shard = shard(dual_solution); + const auto dual_start_shard = shard(dual_start_point); + const auto lower_bound_shard = shard(qp.constraint_lower_bounds); + const auto upper_bound_shard = shard(qp.constraint_upper_bounds); + const double kInfinity = std::numeric_limits::infinity(); + return ((dual_shard.array() != 0.0 || + (lower_bound_shard.array() == -kInfinity && + upper_bound_shard.array() == kInfinity)) != + (dual_start_shard.array() != 0.0 || + (lower_bound_shard.array() == -kInfinity && + upper_bound_shard.array() == kInfinity))) + .count(); + }))); +} + +void Solver::AddConvergenceAndInfeasibilityInformation( + const VectorXd& primal_solution, const VectorXd& dual_solution, + const ShardedQuadraticProgram& sharded_qp, const VectorXd& col_scaling_vec, + const VectorXd& row_scaling_vec, PointType candidate_type, + IterationStats& stats) const { + *stats.add_convergence_information() = ComputeConvergenceInformation( + sharded_qp, col_scaling_vec, row_scaling_vec, primal_solution, + dual_solution, candidate_type); + *stats.add_infeasibility_information() = ComputeInfeasibilityInformation( + sharded_qp, col_scaling_vec, row_scaling_vec, primal_solution, + dual_solution, candidate_type); +} + +void Solver::AddPointMetadata(const VectorXd& primal_solution, + const VectorXd& dual_solution, + PointType point_type, + IterationStats& stats) const { + PointMetadata metadata; + metadata.set_point_type(point_type); + std::vector random_projection_seeds( + params_.random_projection_seeds().begin(), + params_.random_projection_seeds().end()); + SetRandomProjections(sharded_working_qp_, primal_solution, dual_solution, + random_projection_seeds, metadata); + if (point_type != POINT_TYPE_ITERATE_DIFFERENCE) { + SetActiveSetInformation(sharded_working_qp_, primal_solution, dual_solution, + last_primal_start_point_, last_dual_start_point_, + metadata); + } + *stats.add_point_metadata() = metadata; +} + +void LogInfoWithoutPrefix(absl::string_view message) { + google::LogMessage("", google::LogMessage::kNoLogPrefix, google::GLOG_INFO) + .stream() + << message; +} + +absl::optional +Solver::UpdateIterationStatsAndCheckTermination( + bool force_numerical_termination, const VectorXd& working_primal_average, + const VectorXd& working_dual_average, IterationStats& stats) const { + if (presolve_info_.has_value()) { + { // This block exists to destroy `original_current` to save RAM. + PrimalAndDualSolution original_current = + RecoverOriginalSolution({.primal_solution = current_primal_solution_, + .dual_solution = current_dual_solution_}); + AddConvergenceAndInfeasibilityInformation( + original_current.primal_solution, original_current.dual_solution, + presolve_info_->sharded_original_qp, + presolve_info_->trivial_col_scaling_vec, + presolve_info_->trivial_row_scaling_vec, POINT_TYPE_CURRENT_ITERATE, + stats); + } + if (primal_average_.HasNonzeroWeight()) { + PrimalAndDualSolution original_average = + RecoverOriginalSolution({.primal_solution = working_primal_average, + .dual_solution = working_dual_average}); + AddConvergenceAndInfeasibilityInformation( + original_average.primal_solution, original_average.dual_solution, + presolve_info_->sharded_original_qp, + presolve_info_->trivial_col_scaling_vec, + presolve_info_->trivial_row_scaling_vec, POINT_TYPE_AVERAGE_ITERATE, + stats); + } + } else { + AddConvergenceAndInfeasibilityInformation( + current_primal_solution_, current_dual_solution_, sharded_working_qp_, + col_scaling_vec_, row_scaling_vec_, POINT_TYPE_CURRENT_ITERATE, stats); + if (primal_average_.HasNonzeroWeight()) { + AddConvergenceAndInfeasibilityInformation( + working_primal_average, working_dual_average, sharded_working_qp_, + col_scaling_vec_, row_scaling_vec_, POINT_TYPE_AVERAGE_ITERATE, + stats); + } + } + AddPointMetadata(current_primal_solution_, current_dual_solution_, + POINT_TYPE_CURRENT_ITERATE, stats); + if (primal_average_.HasNonzeroWeight()) { + AddPointMetadata(working_primal_average, working_dual_average, + POINT_TYPE_AVERAGE_ITERATE, stats); + } + if (current_primal_delta_.size() > 0 && current_dual_delta_.size() > 0) { + if (presolve_info_.has_value()) { + PrimalAndDualSolution original_delta = + RecoverOriginalSolution({.primal_solution = current_primal_delta_, + .dual_solution = current_dual_delta_}); + *stats.add_infeasibility_information() = ComputeInfeasibilityInformation( + presolve_info_->sharded_original_qp, + presolve_info_->trivial_col_scaling_vec, + presolve_info_->trivial_row_scaling_vec, + original_delta.primal_solution, original_delta.dual_solution, + POINT_TYPE_ITERATE_DIFFERENCE); + } else { + *stats.add_infeasibility_information() = ComputeInfeasibilityInformation( + sharded_working_qp_, col_scaling_vec_, row_scaling_vec_, + current_primal_delta_, current_dual_delta_, + POINT_TYPE_ITERATE_DIFFERENCE); + } + AddPointMetadata(current_primal_delta_, current_dual_delta_, + POINT_TYPE_ITERATE_DIFFERENCE, stats); + } + constexpr int kLogEvery = 15; + static std::atomic_int log_counter{0}; + if (VLOG_IS_ON(4)) { + if (log_counter == 0) { + LogInfoWithoutPrefix(absl::StrCat("I ", IterationStatsLabelString())); + } + LogInfoWithoutPrefix(absl::StrCat( + "A ", ToString(stats, params_.termination_criteria(), + original_bound_norms_, POINT_TYPE_AVERAGE_ITERATE))); + LogInfoWithoutPrefix(absl::StrCat( + "C ", ToString(stats, params_.termination_criteria(), + original_bound_norms_, POINT_TYPE_CURRENT_ITERATE))); + } else if (VLOG_IS_ON(3)) { + if (log_counter == 0) { + LogInfoWithoutPrefix(IterationStatsLabelString()); + } + LogInfoWithoutPrefix(ToString(stats, params_.termination_criteria(), + original_bound_norms_, + POINT_TYPE_AVERAGE_ITERATE)); + } else { + if (log_counter == 0) { + LogInfoWithoutPrefix(IterationStatsLabelShortString()); + } + LogInfoWithoutPrefix(ToShortString(stats, params_.termination_criteria(), + original_bound_norms_, + POINT_TYPE_AVERAGE_ITERATE)); + } + if (++log_counter >= kLogEvery) { + log_counter = 0; + } + if (iteration_stats_callback_ != nullptr) { + iteration_stats_callback_( + {.termination_criteria = params_.termination_criteria(), + .iteration_stats = stats, + .bound_norms = original_bound_norms_}); + } + + return CheckTerminationCriteria(params_.termination_criteria(), stats, + original_bound_norms_, + force_numerical_termination); +} + +double Solver::InitialPrimalWeight( + const double l2_norm_primal_linear_objective, + const double l2_norm_constraint_bounds) const { + if (params_.has_initial_primal_weight()) { + return params_.initial_primal_weight(); + } + if (l2_norm_primal_linear_objective > 0.0 && + l2_norm_constraint_bounds > 0.0) { + // The hand-wavy motivation for this choice is that the objective vector + // has units of (objective units)/(primal units) and the constraint + // bounds vector has units of (objective units)/(dual units), + // therefore this ratio has units (dual units)/(primal units). By + // dimensional analysis, these are the same units as the primal weight. + return l2_norm_primal_linear_objective / l2_norm_constraint_bounds; + } else { + return 1.0; + } +} + +void Solver::ComputeAndApplyRescaling() { + ScalingVectors scaling = ApplyRescaling( + RescalingOptions{.l_inf_ruiz_iterations = params_.l_inf_ruiz_iterations(), + .l2_norm_rescaling = params_.l2_norm_rescaling()}, + sharded_working_qp_); + row_scaling_vec_ = std::move(scaling.row_scaling_vec); + col_scaling_vec_ = std::move(scaling.col_scaling_vec); + + CoefficientWiseQuotientInPlace(col_scaling_vec_, + sharded_working_qp_.PrimalSharder(), + current_primal_solution_); + CoefficientWiseQuotientInPlace(row_scaling_vec_, + sharded_working_qp_.DualSharder(), + current_dual_solution_); +} + +void Solver::ApplyRestartChoice(const RestartChoice restart_to_apply) { + switch (restart_to_apply) { + case RESTART_CHOICE_UNSPECIFIED: + case RESTART_CHOICE_NO_RESTART: + return; + case RESTART_CHOICE_WEIGHTED_AVERAGE_RESET: + VLOG(4) << "Restarted to current on iteration " << iterations_completed_ + << " after " << primal_average_.NumTerms() << " iterations"; + break; + case RESTART_CHOICE_RESTART_TO_AVERAGE: + VLOG(4) << "Restarted to average on iteration " << iterations_completed_ + << " after " << primal_average_.NumTerms() << " iterations"; + current_primal_solution_ = primal_average_.ComputeAverage(); + current_dual_solution_ = dual_average_.ComputeAverage(); + current_dual_product_ = TransposedMatrixVectorProduct( + WorkingQp().constraint_matrix, current_dual_solution_, + sharded_working_qp_.ConstraintMatrixSharder()); + break; + } + primal_weight_ = ComputeNewPrimalWeight(); + ratio_last_two_step_sizes_ = 1; + if (params_.restart_strategy() == + PrimalDualHybridGradientParams::ADAPTIVE_HEURISTIC) { + // It's important for the theory that the distances here are calculated + // given the new primal weight. + const LocalizedLagrangianBounds local_bounds_at_last_restart = + ComputeLocalizedBoundsAtCurrent(); + const double distance_traveled_since_last_restart = + local_bounds_at_last_restart.radius; + normalized_gap_at_last_restart_ = BoundGap(local_bounds_at_last_restart) / + distance_traveled_since_last_restart; + normalized_gap_at_last_trial_ = std::numeric_limits::infinity(); + } else if (params_.restart_strategy() == + PrimalDualHybridGradientParams::ADAPTIVE_DISTANCE_BASED) { + // Update parameters for distance-based restarts. + distance_based_restart_info_ = { + .distance_moved_last_restart_period = DistanceTraveledFromLastStart( + current_primal_solution_, current_dual_solution_), + .length_of_last_restart_period = primal_average_.NumTerms()}; + } + primal_average_.Clear(); + dual_average_.Clear(); + AssignVector(current_primal_solution_, sharded_working_qp_.PrimalSharder(), + /*dest=*/last_primal_start_point_); + AssignVector(current_dual_solution_, sharded_working_qp_.DualSharder(), + /*dest=*/last_dual_start_point_); +} + +absl::optional Solver::MajorIterationAndTerminationCheck( + bool force_numerical_termination, SolveLog& solve_log) { + const int iteration_limit = params_.termination_criteria().iteration_limit(); + const int major_iteration_cycle = + iterations_completed_ % params_.major_iteration_frequency(); + const bool is_major_iteration = + major_iteration_cycle == 0 && iterations_completed_ > 0; + const bool check_termination = + major_iteration_cycle % params_.termination_check_frequency() == 0 || + iterations_completed_ == iteration_limit || force_numerical_termination; + // We check termination on every major iteration. + DCHECK(!is_major_iteration || check_termination); + // Just decide what to do for now. The actual restart, if any, is + // performed after the termination check. + const RestartChoice restart = force_numerical_termination + ? RESTART_CHOICE_NO_RESTART + : ChooseRestartToApply(is_major_iteration); + IterationStats stats = CreateSimpleIterationStats(restart); + if (check_termination) { + // Check for termination and update iteration stats with both simple and + // solution statistics. The later are computationally harder to compute and + // hence only computed here. + VectorXd primal_average = PrimalAverage(); + VectorXd dual_average = DualAverage(); + + const absl::optional + maybe_termination_reason = UpdateIterationStatsAndCheckTermination( + force_numerical_termination, primal_average, dual_average, stats); + if (params_.record_iteration_stats()) { + *solve_log.add_iteration_stats() = stats; + } + // We've terminated. + if (maybe_termination_reason.has_value()) { + return ConstructSolverResult(std::move(primal_average), + std::move(dual_average), stats, + maybe_termination_reason->reason, + maybe_termination_reason->type, solve_log); + } + } else if (params_.record_iteration_stats()) { + // Record simple iteration stats only. + *solve_log.add_iteration_stats() = stats; + } + ApplyRestartChoice(restart); + return absl::nullopt; +} + +void Solver::ResetAverageToCurrent() { + primal_average_.Clear(); + dual_average_.Clear(); + primal_average_.Add(current_primal_solution_, /*weight=*/1.0); + dual_average_.Add(current_dual_solution_, /*weight=*/1.0); +} + +void Solver::LogNumericalTermination() const { + LOG(WARNING) << "Forced numerical termination at iteration " + << iterations_completed_; +} + +void Solver::LogInnerIterationLimitHit() const { + LOG(WARNING) << "Inner iteration limit reached at iteration " + << iterations_completed_; +} + +void Solver::LogQuadraticProgramStats(const QuadraticProgramStats& stats) { + VLOG(1) << absl::StrFormat("There are %i variables, %i constraints, and %i ", + stats.num_variables(), stats.num_constraints(), + stats.constraint_matrix_num_nonzeros()) + << "constraint matrix nonzeros."; + if (WorkingQp().constraint_matrix.nonZeros() > 0) { + VLOG(1) << "Absolute values of nonzero constraint matrix elements: " + << absl::StrFormat("largest=%f, smallest=%f, avg=%f", + stats.constraint_matrix_abs_max(), + stats.constraint_matrix_abs_min(), + stats.constraint_matrix_abs_avg()); + VLOG(1) << "Constraint matrix, infinity norm: " + << absl::StrFormat("max(row & col)=%f, min_col=%f, min_row=%f", + stats.constraint_matrix_abs_max(), + stats.constraint_matrix_col_min_l_inf_norm(), + stats.constraint_matrix_row_min_l_inf_norm()); + VLOG(1) << "Constraint bounds statistics (max absolute value per row): " + << absl::StrFormat("largest=%f, smallest=%f, avg=%f, l2_norm=%f", + stats.combined_bounds_max(), + stats.combined_bounds_min(), + stats.combined_bounds_avg(), + stats.combined_bounds_l2_norm()); + } + if (!IsLinearProgram(WorkingQp())) { + VLOG(1) << absl::StrFormat( + "There are %i nonzero diagonal coefficients in the objective matrix.", + stats.objective_matrix_num_nonzeros()); + VLOG(1) << "Absolute values of nonzero objective matrix elements: " + << absl::StrFormat("largest=%f, smallest=%f, avg=%f", + stats.objective_matrix_abs_max(), + stats.objective_matrix_abs_min(), + stats.objective_matrix_abs_avg()); + } + VLOG(1) << "Absolute values of objective vector elements: " + << absl::StrFormat("largest=%f, smallest=%f, avg=%f, l2_norm=%f", + stats.objective_vector_abs_max(), + stats.objective_vector_abs_min(), + stats.objective_vector_abs_avg(), + stats.objective_vector_l2_norm()); + + VLOG(1) << "Gaps between variable upper and lower bounds: " + << absl::StrFormat( + "#finite=%i of %i, largest=%f, smallest=%f, avg=%f", + stats.variable_bound_gaps_num_finite(), stats.num_variables(), + stats.variable_bound_gaps_max(), + stats.variable_bound_gaps_min(), + stats.variable_bound_gaps_avg()); +} + +InnerStepOutcome Solver::TakeMalitskyPockStep() { + InnerStepOutcome outcome = InnerStepOutcome::kSuccessful; + const double primal_step_size = step_size_ / primal_weight_; + NextSolutionAndDelta next_primal_solution = + ComputeNextPrimalSolution(primal_step_size); + // The theory by Malitsky and Pock holds for any new_step_size in the interval + // [step_size, step_size * sqrt(1 + theta)]. The dilating coefficient + // determines where in this interval the new step size lands. + double dilating_coeff = + 1 + (params_.malitsky_pock_parameters().step_size_interpolation() * + (sqrt(1 + ratio_last_two_step_sizes_) - 1)); + double new_primal_step_size = primal_step_size * dilating_coeff; + double step_size_downscaling = + params_.malitsky_pock_parameters().step_size_downscaling_factor(); + double contraction_factor = + params_.malitsky_pock_parameters().linesearch_contraction_factor(); + const double dual_weight = primal_weight_ * primal_weight_; + int inner_iterations = 0; + for (bool accepted_step = false; !accepted_step; ++inner_iterations) { + if (inner_iterations >= 60) { + LogInnerIterationLimitHit(); + ResetAverageToCurrent(); + outcome = InnerStepOutcome::kForceNumericalTermination; + break; + } + const double new_last_two_step_sizes_ratio = + new_primal_step_size / primal_step_size; + NextSolutionAndDelta next_dual_solution = ComputeNextDualSolution( + dual_weight * new_primal_step_size, new_last_two_step_sizes_ratio, + next_primal_solution); + + VectorXd next_dual_product = TransposedMatrixVectorProduct( + WorkingQp().constraint_matrix, next_dual_solution.value, + sharded_working_qp_.ConstraintMatrixSharder()); + double delta_dual_norm = + Norm(next_dual_solution.delta, sharded_working_qp_.DualSharder()); + double delta_dual_prod_norm = + Distance(current_dual_product_, next_dual_product, + sharded_working_qp_.PrimalSharder()); + if (primal_weight_ * new_primal_step_size * delta_dual_prod_norm <= + contraction_factor * delta_dual_norm) { + // Accept new_step_size as a good step. + step_size_ = new_primal_step_size * primal_weight_; + ratio_last_two_step_sizes_ = new_last_two_step_sizes_ratio; + // Malitsky and Pock guarantee uses a nonsymmetric weighted average, + // the primal variable average involves the initial point, while the dual + // doesn't. See Theorem 2 in https://arxiv.org/pdf/1608.08883.pdf for + // details. + if (!primal_average_.HasNonzeroWeight()) { + primal_average_.Add( + current_primal_solution_, + /*weight=*/new_primal_step_size * new_last_two_step_sizes_ratio); + } + + current_primal_solution_ = std::move(next_primal_solution.value); + current_dual_solution_ = std::move(next_dual_solution.value); + current_dual_product_ = std::move(next_dual_product); + primal_average_.Add(current_primal_solution_, + /*weight=*/new_primal_step_size); + dual_average_.Add(current_dual_solution_, + /*weight=*/new_primal_step_size); + const double movement = + ComputeMovement(next_primal_solution.delta, next_dual_solution.delta); + if (movement == 0.0) { + LogNumericalTermination(); + ResetAverageToCurrent(); + outcome = InnerStepOutcome::kForceNumericalTermination; + } else if (movement > kDivergentMovement) { + LogNumericalTermination(); + outcome = InnerStepOutcome::kForceNumericalTermination; + } + current_primal_delta_ = std::move(next_primal_solution.delta); + current_dual_delta_ = std::move(next_dual_solution.delta); + break; + } else { + new_primal_step_size = step_size_downscaling * new_primal_step_size; + } + } + // inner_iterations isn't incremented for the accepted step. + num_rejected_steps_ += inner_iterations; + return outcome; +} + +InnerStepOutcome Solver::TakeAdaptiveStep() { + bool force_numerical_termination = false; + for (bool accepted_step = false; !accepted_step;) { + const double primal_step_size = step_size_ / primal_weight_; + const double dual_step_size = step_size_ * primal_weight_; + NextSolutionAndDelta next_primal_solution = + ComputeNextPrimalSolution(primal_step_size); + NextSolutionAndDelta next_dual_solution = ComputeNextDualSolution( + dual_step_size, /*extrapolation_factor=*/1.0, next_primal_solution); + const double movement = + ComputeMovement(next_primal_solution.delta, next_dual_solution.delta); + if (movement == 0.0) { + LogNumericalTermination(); + ResetAverageToCurrent(); + force_numerical_termination = true; + break; + } else if (movement > kDivergentMovement) { + LogNumericalTermination(); + force_numerical_termination = true; + break; + } + VectorXd next_dual_product = TransposedMatrixVectorProduct( + WorkingQp().constraint_matrix, next_dual_solution.value, + sharded_working_qp_.ConstraintMatrixSharder()); + const double nonlinearity = + ComputeNonlinearity(next_primal_solution.delta, next_dual_product); + + // See equation (5) in https://arxiv.org/pdf/2106.04756.pdf. + const double step_size_limit = + nonlinearity > 0 ? movement / nonlinearity + : std::numeric_limits::infinity(); + + if (step_size_ <= step_size_limit) { + current_primal_solution_ = std::move(next_primal_solution.value); + current_dual_solution_ = std::move(next_dual_solution.value); + current_dual_product_ = std::move(next_dual_product); + current_primal_delta_ = std::move(next_primal_solution.delta); + current_dual_delta_ = std::move(next_dual_solution.delta); + primal_average_.Add(current_primal_solution_, /*weight=*/step_size_); + dual_average_.Add(current_dual_solution_, /*weight=*/step_size_); + accepted_step = true; + } + const double total_steps_attempted = + num_rejected_steps_ + iterations_completed_ + 1; + // Our step sizes are a factor 1 - (total_steps_attempted + 1)^(- + // step_size_reduction_exponent) smaller than they could be as a margin to + // reduce rejected steps. + const double first_term = + (1 - std::pow(total_steps_attempted + 1.0, + -params_.adaptive_linesearch_parameters() + .step_size_reduction_exponent())) * + step_size_limit; + const double second_term = + (1 + std::pow(total_steps_attempted + 1.0, + -params_.adaptive_linesearch_parameters() + .step_size_growth_exponent())) * + step_size_; + // From the first term when we have to reject a step, the step_size + // decreases by a factor of at least 1 - (total_steps_attempted + 1)^(- + // step_size_reduction_exponent). From the second term we increase the + // step_size by a factor of at most 1 + (total_steps_attempted + + // 1)^(-step_size_growth_exponent) Therefore if more than order + // (total_steps_attempted + 1)^(step_size_reduction_exponent + // - step_size_growth_exponent) fraction of the time we have a rejected + // step, we overall decrease the step_size. When the step_size is + // sufficiently small we stop having rejected steps. + step_size_ = std::min(first_term, second_term); + if (!accepted_step) { + ++num_rejected_steps_; + } + } + if (force_numerical_termination) { + return InnerStepOutcome::kForceNumericalTermination; + } + return InnerStepOutcome::kSuccessful; +} + +InnerStepOutcome Solver::TakeConstantSizeStep() { + const double primal_step_size = step_size_ / primal_weight_; + const double dual_step_size = step_size_ * primal_weight_; + NextSolutionAndDelta next_primal_solution = + ComputeNextPrimalSolution(primal_step_size); + NextSolutionAndDelta next_dual_solution = ComputeNextDualSolution( + dual_step_size, /*extrapolation_factor=*/1.0, next_primal_solution); + const double movement = + ComputeMovement(next_primal_solution.delta, next_dual_solution.delta); + if (movement == 0.0) { + LogNumericalTermination(); + ResetAverageToCurrent(); + return InnerStepOutcome::kForceNumericalTermination; + } else if (movement > kDivergentMovement) { + LogNumericalTermination(); + return InnerStepOutcome::kForceNumericalTermination; + } + VectorXd next_dual_product = TransposedMatrixVectorProduct( + WorkingQp().constraint_matrix, next_dual_solution.value, + sharded_working_qp_.ConstraintMatrixSharder()); + current_primal_solution_ = std::move(next_primal_solution.value); + current_dual_solution_ = std::move(next_dual_solution.value); + current_dual_product_ = std::move(next_dual_product); + current_primal_delta_ = std::move(next_primal_solution.delta); + current_dual_delta_ = std::move(next_dual_solution.delta); + primal_average_.Add(current_primal_solution_, /*weight=*/step_size_); + dual_average_.Add(current_dual_solution_, /*weight=*/step_size_); + return InnerStepOutcome::kSuccessful; +} + +glop::GlopParameters Solver::PreprocessorParameters( + const PrimalDualHybridGradientParams& params) { + glop::GlopParameters glop_params; + // TODO(user): Test if dualization helps or hurts performance. + glop_params.set_solve_dual_problem(glop::GlopParameters::NEVER_DO); + // Experiments show that this preprocessing step can hurt because it relaxes + // variable bounds. + glop_params.set_use_implied_free_preprocessor(false); + // We do our own scaling. + glop_params.set_use_scaling(false); + if (params.presolve_options().has_glop_parameters()) { + glop_params.MergeFrom(params.presolve_options().glop_parameters()); + } + return glop_params; +} + +namespace { + +SolverResult ErrorSolverResult(const TerminationReason reason, + const std::string& message) { + SolveLog error_log; + error_log.set_termination_reason(reason); + error_log.set_termination_string(message); + LOG(WARNING) << "The solver did not run because of invalid input: " + << message; + return SolverResult{.solve_log = error_log}; +} + +TerminationReason GlopStatusToTerminationReason( + const glop::ProblemStatus glop_status) { + switch (glop_status) { + case glop::ProblemStatus::OPTIMAL: + return TERMINATION_REASON_OPTIMAL; + case glop::ProblemStatus::INVALID_PROBLEM: + return TERMINATION_REASON_INVALID_PROBLEM; + case glop::ProblemStatus::ABNORMAL: + case glop::ProblemStatus::IMPRECISE: + return TERMINATION_REASON_NUMERICAL_ERROR; + case glop::ProblemStatus::PRIMAL_INFEASIBLE: + case glop::ProblemStatus::DUAL_INFEASIBLE: + case glop::ProblemStatus::INFEASIBLE_OR_UNBOUNDED: + case glop::ProblemStatus::DUAL_UNBOUNDED: + case glop::ProblemStatus::PRIMAL_UNBOUNDED: + return TERMINATION_REASON_PRIMAL_OR_DUAL_INFEASIBLE; + default: + LOG(WARNING) << "Unexpected preprocessor status " << glop_status; + return TERMINATION_REASON_OTHER; + } +} + +} // namespace + +absl::optional Solver::ApplyPresolveIfEnabled( + absl::optional* const initial_solution) { + const bool presolve_enabled = params_.presolve_options().use_glop(); + if (!presolve_enabled) { + return absl::nullopt; + } + if (!IsLinearProgram(WorkingQp())) { + LOG(WARNING) + << "Skipping presolve, which is only supported for linear programs"; + return absl::nullopt; + } + absl::StatusOr model = QpToMpModelProto(WorkingQp()); + if (!model.ok()) { + LOG(WARNING) + << "Skipping presolve because of error converting to MPModelProto: " + << model.status(); + return absl::nullopt; + } + if (initial_solution->has_value()) { + LOG(WARNING) << "Ignoring initial solution. Initial solutions " + "are ignored when presolve is on."; + initial_solution->reset(); + } + glop::LinearProgram glop_lp; + glop::MPModelProtoToLinearProgram(*model, &glop_lp); + // Save RAM + model->Clear(); + presolve_info_.emplace(std::move(sharded_working_qp_), params_); + // To simplify our code we ignore the return value indicating whether + // postprocessing is required. We simply call RecoverSolution() + // unconditionally, which may do nothing. + presolve_info_->preprocessor.Run(&glop_lp); + presolve_info_->presolved_problem_was_maximization = + glop_lp.IsMaximizationProblem(); + // MpModelProto doesn't support scaling factors so any scaling factor was + // eliminated when we converted to MpModelProto. Nothing afterwards should set + // scaling factor. + CHECK_EQ(glop_lp.objective_scaling_factor(), 1.0); + MPModelProto output; + glop::LinearProgramToMPModelProto(glop_lp, &output); + // This will only fail if given an invalid LP, which shouldn't happen. + absl::StatusOr presolved_qp = + QpFromMpModelProto(output, /*relax_integer_variables=*/false); + CHECK_OK(presolved_qp.status()); + sharded_working_qp_ = ShardedQuadraticProgram( + std::move(*presolved_qp), params_.num_threads(), num_shards_); + primal_average_ = + ShardedWeightedAverage(&sharded_working_qp_.PrimalSharder()); + dual_average_ = ShardedWeightedAverage(&sharded_working_qp_.DualSharder()); + // A status of INIT means the preprocessor created a (usually) smaller + // problem that needs solving. Other statuses mean the preprocessor solved + // the problem completely. + if (presolve_info_->preprocessor.status() != glop::ProblemStatus::INIT) { + col_scaling_vec_.setOnes(sharded_working_qp_.PrimalSize()); + row_scaling_vec_.setOnes(sharded_working_qp_.DualSize()); + return GlopStatusToTerminationReason(presolve_info_->preprocessor.status()); + } + return absl::nullopt; +} + +PrimalAndDualSolution Solver::RecoverOriginalSolution( + PrimalAndDualSolution working_solution) const { + glop::ProblemSolution glop_solution(glop::RowIndex{0}, glop::ColIndex{0}); + if (presolve_info_.has_value()) { + // We compute statuses relative to the working problem so we can detect when + // variables are at their bounds without floating-point roundoff induced by + // scaling. + glop_solution = internal::ComputeStatuses(WorkingQp(), working_solution); + } + CoefficientWiseProductInPlace(col_scaling_vec_, + sharded_working_qp_.PrimalSharder(), + working_solution.primal_solution); + CoefficientWiseProductInPlace(row_scaling_vec_, + sharded_working_qp_.DualSharder(), + working_solution.dual_solution); + if (presolve_info_.has_value()) { + glop_solution.primal_values = + glop::DenseRow(working_solution.primal_solution.begin(), + working_solution.primal_solution.end()); + glop_solution.dual_values = + glop::DenseColumn(working_solution.dual_solution.begin(), + working_solution.dual_solution.end()); + // We got the working QP by calling LinearProgramToMPModelProto() and + // QpFromMpModelProto(). We need to negate the duals if the LP resulting + // from presolve was a max problem. + if (presolve_info_->presolved_problem_was_maximization) { + for (glop::RowIndex i{0}; i < glop_solution.dual_values.size(); ++i) { + glop_solution.dual_values[i] *= -1; + } + } + presolve_info_->preprocessor.RecoverSolution(&glop_solution); + PrimalAndDualSolution solution; + solution.primal_solution = + Eigen::Map(glop_solution.primal_values.data(), + glop_solution.primal_values.size().value()); + solution.dual_solution = + Eigen::Map(glop_solution.dual_values.data(), + glop_solution.dual_values.size().value()); + // We called QpToMpModelProto() and MPModelProtoToLinearProgram() to convert + // our original QP into input for glop's preprocessor. The former multiplies + // the objective vector by the objective_scaling_factor, which multiplies + // the duals by that factor as well. To undo this we divide by the + // objective_scaling_factor. + solution.dual_solution /= + presolve_info_->sharded_original_qp.Qp().objective_scaling_factor; + // Glop's preprocessor sometimes violates the primal bounds constraints. To + // be safe we project both primal and dual. + ProjectToPrimalVariableBounds(presolve_info_->sharded_original_qp, + solution.primal_solution); + ProjectToDualVariableBounds(presolve_info_->sharded_original_qp, + solution.dual_solution); + return solution; + } else { + return working_solution; + } +} + +SolverResult Solver::Solve( + absl::optional initial_solution, + IterationStatsCallback iteration_stats_callback) { + SolveLog solve_log; + if (sharded_working_qp_.Qp().problem_name.has_value()) { + solve_log.set_instance_name(*sharded_working_qp_.Qp().problem_name); + } + *solve_log.mutable_params() = params_; + *solve_log.mutable_original_problem_stats() = ComputeStats( + sharded_working_qp_, params_.infinite_constraint_bound_threshold()); + original_bound_norms_ = + BoundNormsFromProblemStats(solve_log.original_problem_stats()); + const std::string preprocessing_string = absl::StrCat( + params_.presolve_options().use_glop() ? "presolving and " : "", + "rescaling:"); + VLOG(1) << "Problem stats before " << preprocessing_string; + LogQuadraticProgramStats(solve_log.original_problem_stats()); + timer_.Start(); + iteration_stats_callback_ = std::move(iteration_stats_callback); + absl::optional maybe_terminate = + ApplyPresolveIfEnabled(&initial_solution); + if (maybe_terminate.has_value()) { + // Glop also feeds zero primal and dual solutions when the preprocessor + // has a non-INIT status. When the preprocessor status is optimal the + // vectors have length 0. When the status is something else the lengths + // may be non-zero, but that's OK since we don't promise to produce a + // meaningful solution in that case. + const int working_dual_size = sharded_working_qp_.DualSize(); + const int working_primal_size = sharded_working_qp_.PrimalSize(); + IterationStats iteration_stats; + iteration_stats.set_cumulative_time_sec(timer_.Get()); + solve_log.set_preprocessing_time_sec(iteration_stats.cumulative_time_sec()); + VectorXd working_primal = VectorXd::Zero(working_primal_size); + VectorXd working_dual = VectorXd::Zero(working_dual_size); + PrimalAndDualSolution original = RecoverOriginalSolution( + {.primal_solution = working_primal, .dual_solution = working_dual}); + AddConvergenceAndInfeasibilityInformation( + original.primal_solution, original.dual_solution, + presolve_info_->sharded_original_qp, + presolve_info_->trivial_col_scaling_vec, + presolve_info_->trivial_row_scaling_vec, POINT_TYPE_PRESOLVER_SOLUTION, + iteration_stats); + absl::optional earned_termination = + CheckTerminationCriteria(params_.termination_criteria(), + iteration_stats, original_bound_norms_, + /*force_numerical_termination=*/false); + TerminationReason final_termination_reason; + if (earned_termination.has_value() && + (earned_termination->reason == TERMINATION_REASON_OPTIMAL || + earned_termination->reason == TERMINATION_REASON_PRIMAL_INFEASIBLE || + earned_termination->reason == TERMINATION_REASON_DUAL_INFEASIBLE)) { + final_termination_reason = earned_termination->reason; + } else { + if (*maybe_terminate == TERMINATION_REASON_OPTIMAL) { + final_termination_reason = TERMINATION_REASON_NUMERICAL_ERROR; + LOG(WARNING) << "Presolve claimed to solve the LP optimally but the " + "solution doesn't satisfy the optimality criteria."; + } else { + final_termination_reason = *maybe_terminate; + } + } + return ConstructSolverResult( + std::move(working_primal), std::move(working_dual), + std::move(iteration_stats), final_termination_reason, + POINT_TYPE_PRESOLVER_SOLUTION, std::move(solve_log)); + } + + // The current solution is updated by ComputeAndApplyRescaling. + if (initial_solution.has_value()) { + current_primal_solution_ = std::move(initial_solution->primal_solution); + current_dual_solution_ = std::move(initial_solution->dual_solution); + } else { + current_primal_solution_.setZero(sharded_working_qp_.PrimalSize()); + current_dual_solution_.setZero(sharded_working_qp_.DualSize()); + } + // The following projections are necessary since all our checks assume that + // the primal and dual variable bounds are satisfied. + ProjectToPrimalVariableBounds(sharded_working_qp_, current_primal_solution_); + ProjectToDualVariableBounds(sharded_working_qp_, current_dual_solution_); + + ComputeAndApplyRescaling(); + *solve_log.mutable_preprocessed_problem_stats() = ComputeStats( + sharded_working_qp_, params_.infinite_constraint_bound_threshold()); + VLOG(1) << "Problem stats after " << preprocessing_string; + LogQuadraticProgramStats(solve_log.preprocessed_problem_stats()); + + if (params_.linesearch_rule() == + PrimalDualHybridGradientParams::CONSTANT_STEP_SIZE_RULE) { + std::mt19937 random(1); + double inverse_step_size; + const auto lipschitz_result = + EstimateMaximumSingularValueOfConstraintMatrix( + sharded_working_qp_, absl::nullopt, absl::nullopt, + /*desired_relative_error=*/0.2, /*failure_probability=*/0.0005, + random); + // With high probability, the estimate of the lipschitz term is within + // +/- estimated_relative_error * lipschitz_term. + const double lipschitz_term_upper_bound = + lipschitz_result.singular_value / + (1.0 - lipschitz_result.estimated_relative_error); + inverse_step_size = lipschitz_term_upper_bound; + step_size_ = inverse_step_size > 0.0 ? 1.0 / inverse_step_size : 1.0; + } else { + // This initial step size is designed to err on the side of being too big. + // This is because + // (i) too-big steps are rejected and hence don't hurt us other than + // wasting + // an iteration and + // (ii) the step size adjustment algorithm shrinks the step size as far as + // needed in a single iteration but raises it slowly. + // The tiny constant is there to keep the step size finite in the case of a + // trivial LP with no constraints. + step_size_ = + 1.0 / + std::max( + 1.0e-20, + solve_log.preprocessed_problem_stats().constraint_matrix_abs_max()); + } + step_size_ *= params_.initial_step_size_scaling(); + + primal_weight_ = InitialPrimalWeight( + solve_log.preprocessed_problem_stats().objective_vector_l2_norm(), + solve_log.preprocessed_problem_stats().combined_bounds_l2_norm()); + last_primal_start_point_ = CloneVector(current_primal_solution_, + sharded_working_qp_.PrimalSharder()); + last_dual_start_point_ = + CloneVector(current_dual_solution_, sharded_working_qp_.DualSharder()); + // Note: Any cached values computed here also need to be recomputed after a + // restart. + ratio_last_two_step_sizes_ = 1; + current_dual_product_ = TransposedMatrixVectorProduct( + WorkingQp().constraint_matrix, current_dual_solution_, + sharded_working_qp_.ConstraintMatrixSharder()); + + // This is set to true if we can't proceed any more because of numerical + // issues. We may or may not have found the optimal solution. + bool force_numerical_termination = false; + + num_rejected_steps_ = 0; + + solve_log.set_preprocessing_time_sec(timer_.Get()); + + for (iterations_completed_ = 0;; ++iterations_completed_) { + // This code performs the logic of the major iterations and termination + // checks. It may modify the current solution and primal weight (e.g., when + // performing a restart). + const absl::optional maybe_result = + MajorIterationAndTerminationCheck(force_numerical_termination, + solve_log); + if (maybe_result.has_value()) { + return maybe_result.value(); + } + + // TODO(user): If we use a step rule that could reject many steps in a + // row, we should add a termination check within this loop also. For the + // Malitsky and Pock rule, we perform a termination check and declare + // NUMERICAL_ERROR whenever we hit 60 inner iterations. + InnerStepOutcome outcome; + switch (params_.linesearch_rule()) { + case PrimalDualHybridGradientParams::MALITSKY_POCK_LINESEARCH_RULE: + outcome = TakeMalitskyPockStep(); + break; + case PrimalDualHybridGradientParams::ADAPTIVE_LINESEARCH_RULE: + outcome = TakeAdaptiveStep(); + break; + case PrimalDualHybridGradientParams::CONSTANT_STEP_SIZE_RULE: + outcome = TakeConstantSizeStep(); + break; + default: + LOG(FATAL) << "Unrecognized linesearch rule " + << params_.linesearch_rule(); + } + if (outcome == InnerStepOutcome::kForceNumericalTermination) { + force_numerical_termination = true; + } + } // loop over iterations +} + +} // namespace + +SolverResult PrimalDualHybridGradient( + QuadraticProgram qp, const PrimalDualHybridGradientParams& params, + IterationStatsCallback iteration_stats_callback) { + return PrimalDualHybridGradient(std::move(qp), params, absl::nullopt, + std::move(iteration_stats_callback)); +} + +SolverResult PrimalDualHybridGradient( + QuadraticProgram qp, const PrimalDualHybridGradientParams& params, + absl::optional initial_solution, + IterationStatsCallback iteration_stats_callback) { + const absl::Status params_status = + ValidatePrimalDualHybridGradientParams(params); + if (!params_status.ok()) { + return ErrorSolverResult(TERMINATION_REASON_INVALID_PARAMETER, + params_status.ToString()); + } + if (!qp.constraint_matrix.isCompressed()) { + return ErrorSolverResult(TERMINATION_REASON_INVALID_PROBLEM, + "constraint_matrix must be in compressed format. " + "Call constraint_matrix.makeCompressed()"); + } + const absl::Status dimensions_status = ValidateQuadraticProgramDimensions(qp); + if (!dimensions_status.ok()) { + return ErrorSolverResult(TERMINATION_REASON_INVALID_PROBLEM, + dimensions_status.ToString()); + } + if (!HasValidBounds(qp)) { + return ErrorSolverResult(TERMINATION_REASON_INVALID_PROBLEM, + "The input problem has inconsistent bounds."); + } + if (qp.objective_scaling_factor == 0) { + return ErrorSolverResult(TERMINATION_REASON_INVALID_PROBLEM, + "The objective scaling factor cannot be zero."); + } + Solver solver(std::move(qp), params); + return solver.Solve(std::move(initial_solution), + std::move(iteration_stats_callback)); +} + +absl::StatusOr SolveMpModelProto( + const MPModelProto& model, const PrimalDualHybridGradientParams& params, + const bool relax_integer_variables, + IterationStatsCallback iteration_stats_callback) { + ASSIGN_OR_RETURN(QuadraticProgram qp, + QpFromMpModelProto(model, relax_integer_variables)); + const double objective_scaling_factor = qp.objective_scaling_factor; + + SolverResult pdhg_result = PrimalDualHybridGradient( + std::move(qp), params, std::move(iteration_stats_callback)); + + // PDLP's statuses don't map very cleanly to MPSolver statuses. Do the best + // we can for now. + MPSolutionResponse response; + switch (pdhg_result.solve_log.termination_reason()) { + case TERMINATION_REASON_OPTIMAL: + response.set_status(MPSOLVER_OPTIMAL); + break; + case TERMINATION_REASON_NUMERICAL_ERROR: + response.set_status(MPSOLVER_ABNORMAL); + break; + case TERMINATION_REASON_PRIMAL_INFEASIBLE: + response.set_status(MPSOLVER_INFEASIBLE); + break; + default: + response.set_status(MPSOLVER_NOT_SOLVED); + } + if (pdhg_result.solve_log.has_termination_string()) { + response.set_status_str(pdhg_result.solve_log.termination_string()); + } + + const absl::optional convergence_information = + GetConvergenceInformation(pdhg_result.solve_log.solution_stats(), + pdhg_result.solve_log.solution_type()); + + if (convergence_information.has_value()) { + response.set_objective_value(convergence_information->primal_objective()); + } + // variable_value and dual_value are supposed to be set iff 'status' is + // OPTIMAL or FEASIBLE. However, we set them in all cases. + + for (const double v : pdhg_result.primal_solution) { + response.add_variable_value(v); + } + + // Recall QpFromMpModelProto converts maximization problems to minimization + // problems for PDLP by negating the objective and setting + // objective_scaling_factor to -1. This maintains the same set of primal + // solutions. Dual solutions need to be negated if objective_scaling_factor is + // -1. + for (const double v : pdhg_result.dual_solution) { + response.add_dual_value(objective_scaling_factor * v); + } + + for (const double v : pdhg_result.reduced_costs) { + response.add_reduced_cost(objective_scaling_factor * v); + } + + return SolutionResponseAndLog{.response = response, + .solve_log = pdhg_result.solve_log}; +} + +namespace internal { + +glop::ProblemSolution ComputeStatuses(const QuadraticProgram& qp, + const PrimalAndDualSolution& solution) { + glop::ProblemSolution glop_solution( + glop::RowIndex(solution.dual_solution.size()), + glop::ColIndex(solution.primal_solution.size())); + // This doesn't matter much as glop's preprocessor doesn't use this much. + // We pick IMPRECISE since we are often calling this code early in the solve. + glop_solution.status = glop::ProblemStatus::IMPRECISE; + for (glop::RowIndex i{0}; i.value() < solution.dual_solution.size(); ++i) { + if (qp.constraint_lower_bounds[i.value()] == + qp.constraint_upper_bounds[i.value()]) { + glop_solution.constraint_statuses[i] = + glop::ConstraintStatus::FIXED_VALUE; + } else if (solution.dual_solution[i.value()] > 0) { + glop_solution.constraint_statuses[i] = + glop::ConstraintStatus::AT_LOWER_BOUND; + } else if (solution.dual_solution[i.value()] < 0) { + glop_solution.constraint_statuses[i] = + glop::ConstraintStatus::AT_UPPER_BOUND; + } else { + glop_solution.constraint_statuses[i] = glop::ConstraintStatus::BASIC; + } + } + + for (glop::ColIndex i{0}; i.value() < solution.primal_solution.size(); ++i) { + const bool at_lb = solution.primal_solution[i.value()] <= + qp.variable_lower_bounds[i.value()]; + const bool at_ub = solution.primal_solution[i.value()] >= + qp.variable_upper_bounds[i.value()]; + // Note that ShardedWeightedAverage is designed so that variables at their + // bounds will be exactly at their bounds even with floating-point roundoff. + if (at_lb) { + if (at_ub) { + glop_solution.variable_statuses[i] = glop::VariableStatus::FIXED_VALUE; + } else { + glop_solution.variable_statuses[i] = + glop::VariableStatus::AT_LOWER_BOUND; + } + } else { + if (at_ub) { + glop_solution.variable_statuses[i] = + glop::VariableStatus::AT_UPPER_BOUND; + } else { + glop_solution.variable_statuses[i] = glop::VariableStatus::BASIC; + } + } + } + return glop_solution; +} + +} // namespace internal + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/primal_dual_hybrid_gradient.h b/ortools/pdlp/primal_dual_hybrid_gradient.h new file mode 100644 index 0000000000..27d301ec5c --- /dev/null +++ b/ortools/pdlp/primal_dual_hybrid_gradient.h @@ -0,0 +1,166 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_PRIMAL_DUAL_HYBRID_GRADIENT_H_ +#define PDLP_PRIMAL_DUAL_HYBRID_GRADIENT_H_ + +#include + +#include "Eigen/Core" +#include "absl/status/statusor.h" +#include "absl/types/optional.h" +#include "ortools/linear_solver/linear_solver.pb.h" +#include "ortools/lp_data/lp_data.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" +#include "ortools/pdlp/termination.h" + +namespace operations_research::pdlp { + +struct PrimalAndDualSolution { + Eigen::VectorXd primal_solution; + Eigen::VectorXd dual_solution; +}; + +// The following table defines the interpretation of the result vectors +// depending on the value of solve_log.termination_reason: (the +// TERMINATION_REASON_ prefix is omitted for brevity): +// +// * OPTIMAL: the vectors satisfy the termination criteria for optimality. +// * PRIMAL_INFEASIBLE: dual_solution and reduced_costs provide an approximate +// certificate of primal infeasibility; see +// https://developers.google.com/optimization/lp/pdlp_math#infeasibility_identification +// for more information. +// * DUAL_INFEASIBLE: primal_solution provides an approximate certificate of +// dual infeasibility; see +// https://developers.google.com/optimization/lp/pdlp_math#infeasibility_identification +// for more information. +// * PRIMAL_OR_DUAL_INFEASIBLE: the problem was shown to be primal and/or dual +// infeasible but no certificate of infeasibility is available. The +// primal_solution and dual_solution have no meaning. This status is only used +// when presolve is enabled. +// * TIME_LIMIT, ITERATION_LIMIT, KKT_MATRIX_PASS_LIMIT, NUMERICAL_ERROR: +// the vectors contain an iterate at the time that the respective event +// occurred. Their values may or may not be meaningful. In some cases +// solution quality information is available; see documentation for +// solve_log.solution_type. +// * INVALID_PROBLEM, INVALID_PARAMETER, OTHER: the solution vectors are +// meaningless and may not have lengths consistent with the input problem. +struct SolverResult { + Eigen::VectorXd primal_solution; + // See https://developers.google.com/optimization/lp/pdlp_math for the + // interpretation of the dual and reduced costs. + Eigen::VectorXd dual_solution; + Eigen::VectorXd reduced_costs; + SolveLog solve_log; +}; + +struct IterationCallbackInfo { + TerminationCriteria termination_criteria; + IterationStats iteration_stats; + QuadraticProgramBoundNorms bound_norms; +}; + +// Solves the given QP using PDLP (Primal-Dual hybrid gradient enhanced for LP). +// +// All operations that are repeated each iteration are executed in parallel +// using params.num_threads() threads. +// +// The algorithm generally follows the description in +// https://arxiv.org/pdf/2106.04756.pdf, with further enhancements for QP. +// Notation here and in the implementation follows Chambolle and Pock, "On the +// ergodic convergence rates of a first-order primal-dual algorithm" +// (http://www.optimization-online.org/DB_FILE/2014/09/4532.pdf). +// That paper doesn't explicitly use the terminology "primal-dual hybrid +// gradient" but their Theorem 1 is analyzing PDHG. See +// https://developers.google.com/optimization/lp/pdlp_math#saddle-point_formulation +// for the saddle-point formulation of the QP we use that is compatible with +// Chambolle and Pock. +// +// We use 0.5 ||.||^2 for both the primal and dual distance functions. +// +// We parameterize the primal and dual step sizes (tau and sigma in Chambolle +// and Pock) as: +// primal_stepsize = step_size / primal_weight +// dual_stepsize = step_size * primal_weight +// where step_size and primal_weight are parameters. +// params.linesearch_rule determines the update rule for step_size. +// params.initial_primal_weight specifies how primal_weight is initialized +// and params.primal_weight_update_smoothing controls how primal_weight is +// updated. +// +// If iteration_stats_callback is not nullptr, then at each termination step +// (when iteration stats are VLOG(2)ed), iteration_stats_callback will also +// be called with those iteration stats. +// +// Callers MUST check solve_log.termination_reason before using the vectors in +// the SolverResult. See the comment on SolverResult for interpreting the +// termination reason. +// +// All objective values reported by the algorithm are transformed by using +// QuadraticProgram::ApplyObjectiveScalingAndOffset. +// +// NOTE: qp is intentionally passed by value, because PrimalDualHybridGradient +// modifies its copy. +SolverResult PrimalDualHybridGradient( + QuadraticProgram qp, const PrimalDualHybridGradientParams& params, + std::function iteration_stats_callback = + nullptr); + +// Like above but optionally starts with the given initial solution. If no +// initial solution is given the zero vector is used. In either case the initial +// solution is projected onto the primal and dual variable bounds before use. +// Convergence should be faster if the initial solution is close to an optimal +// solution. NOTE: initial_solution is intentionally passed by value. +SolverResult PrimalDualHybridGradient( + QuadraticProgram qp, const PrimalDualHybridGradientParams& params, + absl::optional initial_solution, + std::function iteration_stats_callback = + nullptr); + +struct SolutionResponseAndLog { + MPSolutionResponse response; + SolveLog solve_log; +}; + +// Uses PrimalDualHybridGradient to solve the instance specified by the +// MPModelProto. If relax_integer_variables is true, integrality constraints are +// relaxed before solving. If false, integrality constraints result in an error. +// The MPSolutionResponse in the result enables partial compatibility with the +// MPSolver ecosystem, e.g., with MPSolver::LoadSolutionFromProto. The SolveLog +// has more detailed solve information. Users of this interface should be aware +// of the size limitations of MPModelProto (see, e.g., +// large_linear_program.proto). Returns an error if the conversion from +// MPModelProto to QuadraticProgram fails. The lack of an error does not imply +// success. Check solve_log.termination_reason to see if the solve succeeded. +absl::StatusOr SolveMpModelProto( + const MPModelProto& model, const PrimalDualHybridGradientParams& params, + bool relax_integer_variables = false, + std::function iteration_stats_callback = + nullptr); + +namespace internal { + +// Computes variable and constraint statuses. This determines if primal +// variables are at their bounds based on exact comparisons and therefore may +// not work with unscaled solutions. The primal and dual solution in the +// returned ProblemSolution are NOT set. +glop::ProblemSolution ComputeStatuses(const QuadraticProgram& qp, + const PrimalAndDualSolution& solution); + +} // namespace internal + +} // namespace operations_research::pdlp + +#endif // PDLP_PRIMAL_DUAL_HYBRID_GRADIENT_H_ diff --git a/ortools/pdlp/primal_dual_hybrid_gradient_test.cc b/ortools/pdlp/primal_dual_hybrid_gradient_test.cc new file mode 100644 index 0000000000..690f0cc839 --- /dev/null +++ b/ortools/pdlp/primal_dual_hybrid_gradient_test.cc @@ -0,0 +1,1458 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/primal_dual_hybrid_gradient.h" + +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/types/optional.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/protobuf_util.h" +#include "ortools/base/status_macros.h" +#include "ortools/linear_solver/linear_solver.h" +#include "ortools/linear_solver/linear_solver.pb.h" +#include "ortools/lp_data/lp_data.h" +#include "ortools/lp_data/lp_types.h" +#include "ortools/pdlp/iteration_stats.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/quadratic_program_io.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" +#include "ortools/pdlp/test_util.h" + +namespace operations_research::pdlp { +namespace { + +using ::google::protobuf::util::ParseTextOrDie; + +using ::operations_research::glop::ConstraintStatus; +using ::operations_research::glop::VariableStatus; +using ::testing::_; +using ::testing::AnyNumber; +using ::testing::AnyOf; +using ::testing::DoubleNear; +using ::testing::ElementsAre; +using ::testing::ElementsAreArray; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Not; +using ::testing::SizeIs; + +const double kInfinity = std::numeric_limits::infinity(); +PrimalDualHybridGradientParams CreateSolverParams( + const int iteration_limit, const double eps_optimal_absolute, + const bool enable_scaling, const int num_threads, + const bool use_iteration_limit, const bool use_malitsky_pock_linesearch, + const bool use_diagonal_qp_trust_region_solver) { + PrimalDualHybridGradientParams params; + if (!enable_scaling) { + params.set_l2_norm_rescaling(false); + params.set_l_inf_ruiz_iterations(0); + } + if (use_malitsky_pock_linesearch) { + params.set_linesearch_rule( + PrimalDualHybridGradientParams::MALITSKY_POCK_LINESEARCH_RULE); + } + + params.mutable_termination_criteria()->set_eps_optimal_relative(0.0); + if (use_iteration_limit) { + // This effectively forces convergence on the iteration limit only. + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.mutable_termination_criteria()->set_eps_optimal_absolute(0.0); + } else { + params.mutable_termination_criteria()->set_eps_optimal_absolute( + eps_optimal_absolute); + } + if (use_diagonal_qp_trust_region_solver) { + params.set_use_diagonal_qp_trust_region_solver(true); + params.set_diagonal_qp_trust_region_solver_tolerance(1.0e-8); + } + params.set_num_threads(num_threads); + return params; +} + +// Verifies expected termination reason and iteration count for an instance +// where an optimal solution exists. +// The params must have been generated by CreateSolverParams() with the same +// use_iteration_limit. +// Intended usage: +// const bool use_iteration_limit = ...; +// PrimalDualHybridGradientParams params = +// CreateSolverParams(..., use_iteration_limit, ...); +// SolverResult output = PrimalDualHybridGradient(..., params); +// VerifyTerminationReasonAndIterationCount(params, output, +// use_iteration_limit); +void VerifyTerminationReasonAndIterationCount( + const PrimalDualHybridGradientParams& params, const SolverResult& output, + const bool use_iteration_limit) { + if (use_iteration_limit) { + // When a PDHG step has zero length PDHG can no longer make progress and + // hence terminates immediately. In theory a zero step length implies + // optimality but in practice PDHG terminates with a reason of OPTIMAL if + // the optimality checks pass and NUMERICAL_ERROR otherwise. + // When use_iteration_limit==true CreateSolverParams() sets all the epsilons + // to 0, which makes the optimality checks harder to pass but not + // impossible. Both OPTIMAL and NUMERICAL_ERROR are therefore ok termination + // reasons. + EXPECT_THAT( + output.solve_log.termination_reason(), + AnyOf(TERMINATION_REASON_ITERATION_LIMIT, + TERMINATION_REASON_NUMERICAL_ERROR, TERMINATION_REASON_OPTIMAL)); + if (output.solve_log.termination_reason() == + TERMINATION_REASON_ITERATION_LIMIT) { + EXPECT_EQ(output.solve_log.iteration_count(), + params.termination_criteria().iteration_limit()); + } else { + EXPECT_LE(output.solve_log.iteration_count(), + params.termination_criteria().iteration_limit()); + } + } else { + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_OPTIMAL); + EXPECT_LE(output.solve_log.iteration_count(), + params.termination_criteria().iteration_limit()); + } +} + +class PrimalDualHybridGradientLPTest + : public testing::TestWithParam< + std::tuple> { + protected: + PrimalDualHybridGradientParams CreateSolverParamsForFixture( + const int iteration_limit, const double eps_optimal_absolute) { + const auto [enable_scaling, num_threads, use_iteration_limit, + use_malitsky_pock_linesearch] = GetParam(); + return CreateSolverParams(iteration_limit, eps_optimal_absolute, + enable_scaling, num_threads, use_iteration_limit, + use_malitsky_pock_linesearch, + /*use_diagonal_qp_trust_region_solver=*/false); + } + + void VerifyTerminationReasonAndIterationCountForFixture( + const PrimalDualHybridGradientParams& params, + const SolverResult& output) { + const auto [enable_scaling, num_threads, use_iteration_limit, + use_malitsky_pock_linesearch] = GetParam(); + VerifyTerminationReasonAndIterationCount(params, output, + use_iteration_limit); + } +}; + +class PrimalDualHybridGradientDiagonalQPTest + : public testing::TestWithParam< + std::tuple> { + protected: + PrimalDualHybridGradientParams CreateSolverParamsForFixture( + const int iteration_limit, const double eps_optimal_absolute) { + const auto [enable_scaling, num_threads, use_iteration_limit, + use_malitsky_pock_linesearch, + use_diagonal_qp_trust_region_solver] = GetParam(); + return CreateSolverParams(iteration_limit, eps_optimal_absolute, + enable_scaling, num_threads, use_iteration_limit, + use_malitsky_pock_linesearch, + use_diagonal_qp_trust_region_solver); + } + void VerifyTerminationReasonAndIterationCountForFixture( + const PrimalDualHybridGradientParams& params, + const SolverResult& output) { + const auto [enable_scaling, num_threads, use_iteration_limit, + use_malitsky_pock_linesearch, + use_diagonal_qp_trust_region_solver] = GetParam(); + VerifyTerminationReasonAndIterationCount(params, output, + use_iteration_limit); + } +}; + +class PresolveDualScalingTest + : public testing::TestWithParam< + std::tuple> {}; + +// The param true means presolve is on, false means presolve is off. +class SolveMpModelProtoMaybePresolveTest : public testing::TestWithParam { +}; + +INSTANTIATE_TEST_SUITE_P( + QP, PrimalDualHybridGradientDiagonalQPTest, + testing::Combine(testing::Bool(), testing::Values(1, 4), testing::Bool(), + testing::Bool(), testing::Bool()), + [](const testing::TestParamInfo< + PrimalDualHybridGradientDiagonalQPTest::ParamType>& info) { + return absl::StrCat( + std::get<0>(info.param) ? "Scaling" : "NoScaling", "_", + std::get<1>(info.param), "Threads_", + std::get<2>(info.param) ? "IterationLimit" : "NoIterationLimit", "_", + std::get<3>(info.param) ? "MalitskyPockLinesearch" + : "AdaptiveLinesearch", + "_", std::get<4>(info.param) ? "TRSolverDiag" : "TRSolverNoDiag"); + }); + +INSTANTIATE_TEST_SUITE_P( + LP, PrimalDualHybridGradientLPTest, + testing::Combine(testing::Bool(), testing::Values(1, 4), testing::Bool(), + testing::Bool()), + [](const testing::TestParamInfo& + info) { + return absl::StrCat( + std::get<0>(info.param) ? "Scaling" : "NoScaling", "_", + std::get<1>(info.param), "Threads_", + std::get<2>(info.param) ? "IterationLimit" : "NoIterationLimit", "_", + std::get<3>(info.param) ? "MalitskyPockLinesearch" + : "AdaptiveLinesearch"); + }); + +INSTANTIATE_TEST_SUITE_P( + PresolveDualScaling, PresolveDualScalingTest, + testing::Combine(testing::Bool(), testing::Bool()), + [](const testing::TestParamInfo& info) { + return absl::StrCat(std::get<1>(info.param) ? "Dualize" : "NoDualize", + std::get<0>(info.param) ? "NegateAndScaleObjective" + : "NoObjectiveScaling"); + }); + +INSTANTIATE_TEST_SUITE_P( + SolveMpModelProtoMaybePresolve, SolveMpModelProtoMaybePresolveTest, + testing::Bool(), + [](const testing::TestParamInfo< + SolveMpModelProtoMaybePresolveTest::ParamType>& info) { + return info.param ? "PresolveOn" : "PresolveOff"; + }); + +TEST_P(PrimalDualHybridGradientLPTest, UnboundedVariables) { + const int iteration_upperbound = 980; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-7); + params.set_major_iteration_frequency(100); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({-1, 8, 1, 2.5}, 1.0e-4)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({-2, 0, 2.375, 2.0 / 3}, 1.0e-4)); + const auto convergence_info = GetConvergenceInformation( + output.solve_log.solution_stats(), output.solve_log.solution_type()); + ASSERT_TRUE(convergence_info.has_value()); + EXPECT_THAT(convergence_info->primal_objective(), DoubleNear(-34.0, 1.0e-6)); + EXPECT_THAT(convergence_info->dual_objective(), DoubleNear(-34.0, 1.0e-6)); + + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 4); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 4); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 4); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_constraints(), 4); +} + +TEST_P(PrimalDualHybridGradientLPTest, Tiny) { + const int iteration_upperbound = 300; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-5); + params.set_major_iteration_frequency(60); + + SolverResult output = PrimalDualHybridGradient(TinyLp(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({1, 0, 6, 2}, 1.0e-4)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({0.5, 4.0, 0.0}, 1.0e-4)); + EXPECT_THAT(output.reduced_costs, + EigenArrayNear({0.0, 1.5, -3.5, 0.0}, 1.0e-4)); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 4); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 4); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 3); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_constraints(), 3); +} + +TEST_P(PrimalDualHybridGradientLPTest, CorrelationClusteringOne) { + const int iteration_upperbound = 9; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-10); + params.set_major_iteration_frequency(2); + + SolverResult output = + PrimalDualHybridGradient(CorrelationClusteringLp(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({1, 1, 0, 1, 0, 0}, 1.0e-14)); + ASSERT_EQ(output.dual_solution.size(), 3); + // There are multiple optimal dual solutions. + EXPECT_GE(output.dual_solution[0], 0); + EXPECT_GE(output.dual_solution[1], 0); + EXPECT_GE(output.dual_solution[2], 0); + EXPECT_GE(output.dual_solution[0] + output.dual_solution[1], 1 - 1.0e-14); + + const auto& convergence_information = GetConvergenceInformation( + output.solve_log.solution_stats(), output.solve_log.solution_type()); + ASSERT_TRUE(convergence_information.has_value()); + EXPECT_THAT(convergence_information->corrected_dual_objective(), + DoubleNear(1, 1.0e-14)); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 6); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 6); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 3); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_constraints(), 3); +} + +TEST_P(PrimalDualHybridGradientLPTest, CorrelationClusteringStar) { + const int iteration_upperbound = 45; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(5); + + SolverResult output = + PrimalDualHybridGradient(CorrelationClusteringStarLp(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + + EXPECT_THAT(output.primal_solution, + EigenArrayNear({0.5, 0.5, 0.5, 0.0, 0.0, 0.0}, 1.0e-6)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({0.5, 0.5, 0.5}, 1.0e-6)); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 6); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 6); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 3); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_constraints(), 3); +} + +// A double-sided constraint l <= a^T x <= u where neither constraint is tight +// at optimum could cause the trust region solver to malfunction if we picked +// the wrong dual subgradient. This test verifies that we solve an instance with +// such a constraint quickly to high accuracy. +TEST_P(PrimalDualHybridGradientLPTest, InactiveTwoSidedConstraint) { + const int iteration_upperbound = 500; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-8); + params.set_major_iteration_frequency(60); + + QuadraticProgram qp = TestLp(); + // This makes this constraint double-sided and inactive at the optimal + // solution. + qp.constraint_lower_bounds[1] = -10; + SolverResult output = PrimalDualHybridGradient(qp, params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({-1, 8, 1, 2.5}, 1.0e-7)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({-2.0, 0.0, 2.375, 2.0 / 3}, 1.0e-7)); +} + +TEST_P(PrimalDualHybridGradientLPTest, InfeasiblePrimal) { + // This value for iteration_upperbound is particularly necessary for Malistsky + // and Pock. The adaptive rule detects infeasibility in less than 500 + // iterations. + const int iteration_upperbound = 2000; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(5); + params.mutable_termination_criteria()->set_eps_primal_infeasible(1.0e-6); + + SolverResult output = + PrimalDualHybridGradient(SmallPrimalInfeasibleLp(), params); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_PRIMAL_INFEASIBLE); + const auto& dual = output.dual_solution; + // The following two conditions check if the certificate is correct. For this + // problem the set of infeasibility certificates is equal to all the rays of + // the form -alpha * (1, 1) with alpha positive. + EXPECT_THAT(dual[0] / dual[1], DoubleNear(1, 1.0e-6)); + EXPECT_LT(dual[1], 0.0); + // The reduced costs should be approximately zero. However, a small relative + // difference between dual[0] and dual[1] could translate to a large absolute + // difference, and hence large reduced costs. The following test uses the + // the exact formula to make sure we're not adding the objective vector to the + // reduced costs. + EXPECT_THAT(output.reduced_costs, + EigenArrayNear({std::max(dual[1] - dual[0], 0.0), + std::max(dual[0] - dual[1], 0.0)}, + 1.0e-6)); + EXPECT_LE(output.solve_log.iteration_count(), iteration_upperbound); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 2); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 2); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 2); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_constraints(), 2); +} + +TEST_P(PrimalDualHybridGradientLPTest, InfeasibleDual) { + const int iteration_upperbound = 500; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(5); + + SolverResult output = + PrimalDualHybridGradient(SmallDualInfeasibleLp(), params); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_DUAL_INFEASIBLE); + // The following two conditions check if the certificate is correct. For this + // problem the set of infeasibility certificates is equal to all the rays of + // the form alpha * (1, 1) with alpha positive. + EXPECT_THAT(output.primal_solution[0] / output.primal_solution[1], + DoubleNear(1, 1.0e-6)); + EXPECT_GT(output.primal_solution[1], 0.0); + EXPECT_LE(output.solve_log.iteration_count(), iteration_upperbound); +} + +TEST_P(PrimalDualHybridGradientLPTest, InfeasiblePrimalDual) { + const int iteration_upperbound = 600; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + // Adaptive restarts are disabled because they unexpectedly perform worse on + // this instance. + params.set_restart_strategy(PrimalDualHybridGradientParams::NO_RESTARTS); + params.set_major_iteration_frequency(5); + + SolverResult output = + PrimalDualHybridGradient(SmallPrimalDualInfeasibleLp(), params); + EXPECT_THAT(output.solve_log.termination_reason(), + AnyOf(Eq(TERMINATION_REASON_DUAL_INFEASIBLE), + Eq(TERMINATION_REASON_PRIMAL_INFEASIBLE))); + EXPECT_LE(output.solve_log.iteration_count(), iteration_upperbound); +} + +TEST_P(PrimalDualHybridGradientDiagonalQPTest, DiagonalQp1) { + const int iteration_upperbound = 96; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(12); + + SolverResult output = PrimalDualHybridGradient(TestDiagonalQp1(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({1.0, 0.0}, 1.0e-6)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear(Eigen::ArrayXd::Constant(1, -1.0), 1.0e-6)); + EXPECT_THAT(output.reduced_costs, EigenArrayNear({4.0, 0.0}, 1.0e-6)); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 2); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 2); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 1); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_constraints(), 1); +} + +TEST_P(PrimalDualHybridGradientDiagonalQPTest, DiagonalQp2) { + const int iteration_upperbound = 240; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(12); + + SolverResult output = PrimalDualHybridGradient(TestDiagonalQp2(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({3.0, 1.0}, 1.0e-6)); + EXPECT_THAT(output.dual_solution, ElementsAre(DoubleNear(0.0, 1.0e-6))); + EXPECT_THAT(output.reduced_costs, EigenArrayNear({0.0, 0.0}, 1.0e-6)); +} + +TEST_P(PrimalDualHybridGradientDiagonalQPTest, DiagonalQp3) { + const int iteration_upperbound = 300; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(15); + + SolverResult output = PrimalDualHybridGradient(TestDiagonalQp3(), params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({2.0, 0.0, 1.0}, 1.0e-6)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({-1.0, 1.0}, 1.0e-6)); + EXPECT_THAT(output.reduced_costs, EigenArrayNear({0, 0, 0}, 1.0e-6)); +} + +// This is like the previous test except it starts with a near-optimal solution +// and uses a shorter iteration limit. +TEST_P(PrimalDualHybridGradientDiagonalQPTest, QpWarmStart) { + const int iteration_upperbound = 35; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + params.set_major_iteration_frequency(5); + // Disable primal weight updating. In a warm-start situation, the primal + // weight should be carried over. In this test, the initial primal weight of 1 + // is reasonable because the distance from the starting point to the primal + // and dual optimal solutions are about the same. + params.set_primal_weight_update_smoothing(0.0); + + PrimalAndDualSolution initial_solution; + initial_solution.primal_solution.resize(2); + initial_solution.primal_solution << 0.999, 0.001; + initial_solution.dual_solution.resize(1); + initial_solution.dual_solution << -0.999; + SolverResult output = PrimalDualHybridGradient(TestDiagonalQp1(), params, + std::move(initial_solution)); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({1.0, 0.0}, 1.0e-6)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear(Eigen::ArrayXd::Constant(1, -1.0), 1.0e-6)); + EXPECT_THAT(output.reduced_costs, EigenArrayNear({4.0, 0.0}, 1.0e-6)); +} + +// Tests an LP with no constraints. +TEST_P(PrimalDualHybridGradientLPTest, LpWithoutConstraints) { + const int iteration_upperbound = 2; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + + QuadraticProgram qp(3, 0); + qp.variable_lower_bounds << -1, -kInfinity, -2; + qp.variable_upper_bounds << kInfinity, 4, 10; + qp.objective_vector << 1, -1, 2; + + SolverResult output = PrimalDualHybridGradient(qp, params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({-1, 4, -2}, 1.0e-6)); + EXPECT_THAT(output.dual_solution, SizeIs(0)); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 3); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 3); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 0); + EXPECT_EQ(output.solve_log.preprocessed_problem_stats().num_constraints(), 0); +} + +// Tests an LP with no variables. +TEST_P(PrimalDualHybridGradientLPTest, LpWithoutVariables) { + const int iteration_upperbound = 2; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + + QuadraticProgram qp(0, 3); + qp.constraint_lower_bounds << -1, -kInfinity, -2; + qp.constraint_upper_bounds << kInfinity, 4, 10; + + SolverResult output = PrimalDualHybridGradient(qp, params); + VerifyTerminationReasonAndIterationCountForFixture(params, output); + EXPECT_THAT(output.primal_solution, SizeIs(0)); + EXPECT_THAT(output.dual_solution, EigenArrayNear({0, 0, 0}, 1.0e-6)); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 0); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 0); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 3); + EXPECT_EQ(output.solve_log.preprocessed_problem_stats().num_constraints(), 3); +} + +TEST_P(PrimalDualHybridGradientLPTest, InfeasibleLpWithoutVariables) { + const int iteration_upperbound = 2; + PrimalDualHybridGradientParams params = + CreateSolverParamsForFixture(iteration_upperbound, + /*eps_optimal_absolute=*/1.0e-6); + + QuadraticProgram qp(0, 1); + qp.constraint_lower_bounds << -1; + qp.constraint_upper_bounds << -1; + + SolverResult output = PrimalDualHybridGradient(qp, params); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_PRIMAL_INFEASIBLE); + EXPECT_THAT(output.primal_solution, SizeIs(0)); + EXPECT_LT(output.dual_solution[0], 0.0); + EXPECT_EQ(output.solve_log.original_problem_stats().num_variables(), 0); + EXPECT_LE(output.solve_log.preprocessed_problem_stats().num_variables(), 0); + EXPECT_EQ(output.solve_log.original_problem_stats().num_constraints(), 1); + EXPECT_EQ(output.solve_log.preprocessed_problem_stats().num_constraints(), 1); +} + +PrimalDualHybridGradientParams ParamsWithNoLimits() { + PrimalDualHybridGradientParams params; + // This disables the termination limits. A termination criteria must be set + // for the solver to terminate. + params.mutable_termination_criteria()->set_eps_optimal_relative(0.0); + params.mutable_termination_criteria()->set_eps_optimal_absolute(0.0); + params.set_record_iteration_stats(true); + return params; +} + +TEST(PrimalDualHybridGradientTest, ClearsRunningAverage) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(1); + params.set_major_iteration_frequency(major_iteration_frequency); + params.set_restart_strategy(PrimalDualHybridGradientParams::NO_RESTARTS); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + ASSERT_EQ(output.solve_log.iteration_count(), iteration_limit); + // The first entry in iteration_stats corresponds to the starting point. + ASSERT_EQ(output.solve_log.iteration_stats_size(), iteration_limit + 1); + + for (int i = 0; i < output.solve_log.iteration_stats_size(); ++i) { + const auto& stats = output.solve_log.iteration_stats(i); + const int iterations_completed = stats.iteration_number(); + EXPECT_EQ(iterations_completed, i); + if (iterations_completed == 0) { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_NO_RESTART); + } else if (iterations_completed % major_iteration_frequency == 0) { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_WEIGHTED_AVERAGE_RESET) + << "iteration = " << i; + } else { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_NO_RESTART) + << "iteration = " << i; + } + } +} + +TEST(PrimalDualHybridGradientTest, RestartsEveryMajorIteration) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(1); + params.set_major_iteration_frequency(major_iteration_frequency); + params.set_restart_strategy( + PrimalDualHybridGradientParams::EVERY_MAJOR_ITERATION); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + ASSERT_EQ(output.solve_log.iteration_count(), iteration_limit); + // The first entry in iteration_stats corresponds to the starting point. + ASSERT_EQ(output.solve_log.iteration_stats_size(), iteration_limit + 1); + + for (int i = 0; i < output.solve_log.iteration_stats_size(); ++i) { + const auto& stats = output.solve_log.iteration_stats(i); + const int iterations_completed = stats.iteration_number(); + EXPECT_EQ(iterations_completed, i); + if (iterations_completed == 0) { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_NO_RESTART); + } else if (iterations_completed % major_iteration_frequency == 0) { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_RESTART_TO_AVERAGE) + << "iteration = " << i; + } else { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_NO_RESTART) + << "iteration = " << i; + } + } +} + +TEST(PrimalDualHybridGradientTest, SolveLogIncludesNameForNamedQP) { + PrimalDualHybridGradientParams params; + params.mutable_termination_criteria()->set_iteration_limit(1); + + QuadraticProgram test_lp = TestLp(); + test_lp.problem_name = "Test LP"; + + SolverResult output = PrimalDualHybridGradient(test_lp, params); + EXPECT_EQ(output.solve_log.instance_name(), "Test LP"); +} + +TEST(PrimalDualHybridGradientTest, SolveLogOmitsNameForUnnamedQP) { + PrimalDualHybridGradientParams params; + params.mutable_termination_criteria()->set_iteration_limit(1); + + QuadraticProgram unnamed_test_lp = TestLp(); + + SolverResult output = PrimalDualHybridGradient(unnamed_test_lp, params); + EXPECT_FALSE(output.solve_log.has_instance_name()); +} + +TEST(PrimalDualHybridGradientTest, SolveLogIncludesParameters) { + PrimalDualHybridGradientParams params; + params.mutable_termination_criteria()->set_iteration_limit(1); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + EXPECT_EQ(output.solve_log.params().termination_criteria().iteration_limit(), + 1); +} + +TEST(PrimalDualHybridGradientTest, AdaptiveDistanceBasedRestartsWorkOnTestLp) { + PrimalDualHybridGradientParams params; + params.set_major_iteration_frequency(16); + params.mutable_termination_criteria()->set_iteration_limit(128); + params.set_restart_strategy( + PrimalDualHybridGradientParams::ADAPTIVE_DISTANCE_BASED); + // Low restart threshold. + params.set_necessary_reduction_for_restart(0.99); + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({-1, 8, 1, 2.5}, 1.0e-4)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({-2, 0, 2.375, 2.0 / 3}, 1.0e-4)); + const auto convergence_info = GetConvergenceInformation( + output.solve_log.solution_stats(), output.solve_log.solution_type()); + ASSERT_TRUE(convergence_info.has_value()); + EXPECT_THAT(convergence_info->primal_objective(), DoubleNear(-34.0, 1.0e-4)); + EXPECT_THAT(convergence_info->dual_objective(), DoubleNear(-34.0, 1.0e-4)); +} + +TEST(PrimalDualHybridGradientTest, AdaptiveDistanceBasedRestartsWorkOnTestQp) { + PrimalDualHybridGradientParams params; + params.set_major_iteration_frequency(16); + params.mutable_termination_criteria()->set_iteration_limit(128); + params.set_restart_strategy( + PrimalDualHybridGradientParams::ADAPTIVE_DISTANCE_BASED); + // Low restart threshold. + params.set_necessary_reduction_for_restart(0.99); + SolverResult output = PrimalDualHybridGradient(TestDiagonalQp1(), params); + + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); +} + +TEST(PrimalDualHybridGradientTest, AdaptiveDistanceBasedRestartsToAverage) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 13; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(1); + params.set_major_iteration_frequency(major_iteration_frequency); + params.set_restart_strategy( + PrimalDualHybridGradientParams::ADAPTIVE_DISTANCE_BASED); + params.set_necessary_reduction_for_restart(0.75); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + ASSERT_EQ(output.solve_log.iteration_count(), iteration_limit); + // The first entry in iteration_stats corresponds to the starting point. + ASSERT_EQ(output.solve_log.iteration_stats_size(), iteration_limit + 1); + + for (int i = 0; i < output.solve_log.iteration_stats_size(); ++i) { + const auto& stats = output.solve_log.iteration_stats(i); + const int iterations_completed = stats.iteration_number(); + EXPECT_EQ(iterations_completed, i); + if (iterations_completed == 0) { + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_NO_RESTART); + } else if (iterations_completed == major_iteration_frequency) { + // An explicit restart should be triggered at the end of the first major + // iteration. + EXPECT_THAT(stats.restart_used(), + AnyOf(RESTART_CHOICE_RESTART_TO_AVERAGE, + RESTART_CHOICE_WEIGHTED_AVERAGE_RESET)) + << "iteration = " << i; + } else if (iterations_completed % major_iteration_frequency != 0) { + // No restarts should happen outside major iterations. + EXPECT_EQ(stats.restart_used(), RESTART_CHOICE_NO_RESTART); + } + } +} + +TEST(PrimalDualHybridGradientTest, PrimalWeightFrozen) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + const double initial_primal_weight = 1.5; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_major_iteration_frequency(major_iteration_frequency); + params.set_restart_strategy( + PrimalDualHybridGradientParams::EVERY_MAJOR_ITERATION); + params.set_initial_primal_weight(initial_primal_weight); + params.set_primal_weight_update_smoothing(0.0); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + + for (const auto& stats : output.solve_log.iteration_stats()) { + EXPECT_EQ(stats.primal_weight(), initial_primal_weight) + << "iteration = " << stats.iteration_number(); + } +} + +TEST(PrimalDualHybridGradientTest, ConstantStepSize) { + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(1); + params.set_linesearch_rule( + PrimalDualHybridGradientParams::CONSTANT_STEP_SIZE_RULE); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + + ASSERT_FALSE(output.solve_log.iteration_stats().empty()); + const double initial_step_size = + output.solve_log.iteration_stats(0).step_size(); + for (const auto& stats : output.solve_log.iteration_stats()) { + EXPECT_EQ(stats.step_size(), initial_step_size) + << "iteration = " << stats.iteration_number(); + } +} + +TEST(PrimalDualHybridGradientTest, StepSizeScaling) { + const int iteration_limit = 1; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(1); + params.set_linesearch_rule( + PrimalDualHybridGradientParams::CONSTANT_STEP_SIZE_RULE); + + SolverResult unscaled_output = PrimalDualHybridGradient(TestLp(), params); + + ASSERT_FALSE(unscaled_output.solve_log.iteration_stats().empty()); + const double initial_step_size = + unscaled_output.solve_log.iteration_stats(0).step_size(); + + const double kStepSizeScaling = 0.5; + params.set_initial_step_size_scaling(kStepSizeScaling); + SolverResult scaled_output = PrimalDualHybridGradient(TestLp(), params); + + ASSERT_FALSE(scaled_output.solve_log.iteration_stats().empty()); + EXPECT_EQ(scaled_output.solve_log.iteration_stats(0).step_size(), + initial_step_size * kStepSizeScaling); +} + +TEST(PrimalDualHybridGradientTest, + StatsAtEachIterationWithRecordIterationStatsOn) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.set_record_iteration_stats(true); + // This is required for ConvergenceInformation, InfeasibilityInformation, and + // PointMetadata to be generated on each iteration. + params.set_termination_check_frequency(1); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_major_iteration_frequency(major_iteration_frequency); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + EXPECT_EQ(output.solve_log.iteration_stats().size(), iteration_limit + 1); + for (const auto& stats : output.solve_log.iteration_stats()) { + EXPECT_NE(GetConvergenceInformation(stats, POINT_TYPE_CURRENT_ITERATE), + absl::nullopt); + EXPECT_NE(GetInfeasibilityInformation(stats, POINT_TYPE_CURRENT_ITERATE), + absl::nullopt); + EXPECT_NE(GetPointMetadata(stats, POINT_TYPE_CURRENT_ITERATE), + absl::nullopt); + if (stats.iteration_number() > 0) { + EXPECT_NE(GetConvergenceInformation(stats, POINT_TYPE_AVERAGE_ITERATE), + absl::nullopt); + EXPECT_NE(GetInfeasibilityInformation(stats, POINT_TYPE_AVERAGE_ITERATE), + absl::nullopt); + EXPECT_NE(GetPointMetadata(stats, POINT_TYPE_AVERAGE_ITERATE), + absl::nullopt); + + EXPECT_NE( + GetInfeasibilityInformation(stats, POINT_TYPE_ITERATE_DIFFERENCE), + absl::nullopt); + EXPECT_NE(GetPointMetadata(stats, POINT_TYPE_ITERATE_DIFFERENCE), + absl::nullopt); + } + } +} + +TEST(PrimalDualHybridGradientTest, + NoIterationStatsWithRecordIterationStatsOff) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.set_record_iteration_stats(false); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_major_iteration_frequency(major_iteration_frequency); + // Random projection seeds should have no effect when record_iteration_stats + // is false. + params.add_random_projection_seeds(1); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + EXPECT_EQ(output.solve_log.iteration_stats().size(), 0); +} + +TEST(PrimalDualHybridGradientTest, NoRandomProjectionsIfNotRequested) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.set_record_iteration_stats(true); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_major_iteration_frequency(major_iteration_frequency); + params.set_termination_check_frequency(1); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + EXPECT_EQ(output.solve_log.iteration_stats().size(), iteration_limit + 1); + for (const auto& stats : output.solve_log.iteration_stats()) { + EXPECT_THAT(stats.point_metadata(), Not(IsEmpty())); + for (const auto& metadata : stats.point_metadata()) { + EXPECT_THAT(metadata.random_primal_projections(), IsEmpty()); + EXPECT_THAT(metadata.random_dual_projections(), IsEmpty()); + } + } +} + +TEST(PrimalDualHybridGradientTest, HasRandomProjectionsIfRequested) { + // An arbitrarily chosen major iteration frequency. + const int major_iteration_frequency = 17; + const int iteration_limit = 100; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.set_record_iteration_stats(true); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_major_iteration_frequency(major_iteration_frequency); + params.set_termination_check_frequency(1); + params.add_random_projection_seeds(1); + params.add_random_projection_seeds(2); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + EXPECT_EQ(output.solve_log.iteration_stats().size(), iteration_limit + 1); + for (const auto& stats : output.solve_log.iteration_stats()) { + for (const auto& metadata : stats.point_metadata()) { + // There isn't much we can say about the random projection values, so just + // check that the right number are present. + EXPECT_THAT(metadata.random_primal_projections(), SizeIs(2)); + EXPECT_THAT(metadata.random_dual_projections(), SizeIs(2)); + } + } +} + +TEST(PrimalDualHybridGradientTest, ProjectInitialPointPrimalBounds) { + const int iteration_limit = 5; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + + // The default initial solution (zeros) doesn't satisfy the primal variable + // bounds. The solver should project it to a valid primal solution. + SolverResult output = + PrimalDualHybridGradient(SmallInitializationLp(), params); + EXPECT_EQ(output.solve_log.iteration_stats().size(), iteration_limit + 1); + EXPECT_GT(output.primal_solution[0], 0.0); +} + +TEST(PrimalDualHybridGradientTest, ProjectInitialPointDualBounds) { + const int iteration_limit = 5; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + // This initial solution doesn't satisfy the dual variable bounds. The solver + // should project it to a valid dual solution. + PrimalAndDualSolution initial_solution; + initial_solution.primal_solution = Eigen::VectorXd(2); + initial_solution.primal_solution << 1.0, 0.0; + initial_solution.dual_solution = -Eigen::VectorXd::Ones(2); + SolverResult output_nonzero_init = PrimalDualHybridGradient( + SmallInitializationLp(), params, initial_solution); + EXPECT_EQ(output_nonzero_init.solve_log.iteration_stats().size(), + iteration_limit + 1); + EXPECT_LE(output_nonzero_init.dual_solution[0], 0.0); +} + +TEST(PrimalDualHybridGradientTest, DetectsProblemWithInconsistentBounds) { + SolverResult output = PrimalDualHybridGradient( + SmallInvalidProblemLp(), PrimalDualHybridGradientParams()); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_INVALID_PROBLEM); +} + +TEST(PrimalDualHybridGradientTest, DetectsProblemWithInconsistentSizes) { + QuadraticProgram qp = TinyLp(); + qp.objective_vector.resize(0); + SolverResult output = + PrimalDualHybridGradient(qp, PrimalDualHybridGradientParams()); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_INVALID_PROBLEM); +} + +TEST(PrimalDualHybridGradientTest, DetectsUncompressedConstraintMatrix) { + QuadraticProgram qp = TinyLp(); + qp.constraint_matrix.uncompress(); + SolverResult output = + PrimalDualHybridGradient(std::move(qp), PrimalDualHybridGradientParams()); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_INVALID_PROBLEM); +} + +TEST(PrimalDualHybridGradientTest, DetectsInvalidParameters) { + PrimalDualHybridGradientParams params; + params.set_num_threads(0); + SolverResult output = PrimalDualHybridGradient(TinyLp(), params); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_INVALID_PARAMETER); +} + +TEST(PrimalDualHybridGradientTest, ChecksTerminationAtCorrectFrequency) { + // termination_check_frequency is chosen so that it does not divide the + // major_iteration_frequency. + const int major_iteration_frequency = 5; + const int termination_check_frequency = 2; + const int iteration_limit = 16; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(termination_check_frequency); + params.set_major_iteration_frequency(major_iteration_frequency); + + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + ASSERT_EQ(output.solve_log.iteration_count(), iteration_limit); + + std::vector termination_checked_at_iteration; + + for (const auto& stats : output.solve_log.iteration_stats()) { + if (stats.convergence_information_size() > 0) { + termination_checked_at_iteration.push_back(stats.iteration_number()); + } + } + + // Termination is checked on the first iteration, the last iteration, every + // major iteration, and at the termination_check_frequency (with a counter + // reset on every major iteration). + EXPECT_THAT(termination_checked_at_iteration, + ElementsAre(0, 2, 4, 5, 7, 9, 10, 12, 14, 15, 16)); +} + +TEST(PrimalDualHybridGradientTest, CallsCallback) { + // termination_check_frequency is chosen so that it does not divide the + // major_iteration_frequency. + const int major_iteration_frequency = 5; + const int termination_check_frequency = 5; + const int iteration_limit = 16; + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_iteration_limit(iteration_limit); + params.set_termination_check_frequency(termination_check_frequency); + params.set_major_iteration_frequency(major_iteration_frequency); + + int callback_count = 0; + SolverResult output = PrimalDualHybridGradient( + TestLp(), params, + [&callback_count](const IterationCallbackInfo& callback_info) { + ++callback_count; + }); + ASSERT_EQ(output.solve_log.iteration_count(), iteration_limit); + // The callback should be called at every termination check, that is, at + // iterations 0, 5, 10, 15, and 16, and additionally when returning the final + // solution. + CHECK_EQ(callback_count, 6); +} + +// Returns the unique solution of the TinyLp. +PrimalAndDualSolution TinyLpSolution() { + PrimalAndDualSolution solution; + solution.primal_solution.resize(4); + solution.primal_solution << 1.0, 0.0, 6.0, 2.0; + solution.dual_solution.resize(3); + solution.dual_solution << 0.5, 4.0, 0.0; + return solution; +} + +TEST(PrimalDualHybridGradientTest, WarmStartedAtOptimum) { + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + params.mutable_termination_criteria()->set_eps_optimal_absolute(1.0e-10); + + SolverResult output = + PrimalDualHybridGradient(TinyLp(), params, TinyLpSolution()); + // The solver should run a termination check at iteration 0 and determine that + // the initial point is the solution of the problem. + EXPECT_THAT(output.primal_solution, + EigenArrayNear({1, 0, 6, 2}, 1.0e-10)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({0.5, 4.0, 0.0}, 1.0e-10)); + EXPECT_LE(output.solve_log.iteration_count(), 0); + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); +} + +TEST(PrimalDualHybridGradientTest, NoMovementWhenStartedAtOptimum) { + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + // Disable scaling because round-off causes a small amount of movement on the + // first iteration. + params.set_l2_norm_rescaling(false); + params.set_l_inf_ruiz_iterations(0); + + PrimalAndDualSolution initial_solution = TinyLpSolution(); + SolverResult output = + PrimalDualHybridGradient(TinyLp(), params, initial_solution); + EXPECT_THAT(output.primal_solution, ElementsAre(1, 0, 6, 2)); + EXPECT_THAT(output.dual_solution, ElementsAre(0.5, 4.0, 0.0)); + EXPECT_LE(output.solve_log.iteration_count(), 1); + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); +} + +TEST(PrimalDualHybridGradientTest, EmptyQp) { + MPModelProto proto; + absl::StatusOr qp = + QpFromMpModelProto(proto, /*relax_integer_variables=*/false); + ASSERT_TRUE(qp.ok()) << qp.status(); + PrimalDualHybridGradientParams params = ParamsWithNoLimits(); + SolverResult output = PrimalDualHybridGradient(*qp, params); + EXPECT_THAT(output.primal_solution, ElementsAre()); + EXPECT_THAT(output.dual_solution, ElementsAre()); + EXPECT_EQ(output.solve_log.iteration_count(), 0); + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); +} + +MPSolutionResponse GlopSolution(const MPModelProto& proto) { + MPSolver model("test lp", MPSolver::GLOP_LINEAR_PROGRAMMING); + std::string error_string; + CHECK_EQ(model.LoadModelFromProto(proto, &error_string), + MPSOLVER_MODEL_IS_VALID) + << " error " << error_string; + CHECK_EQ(model.Solve(), MPSolver::OPTIMAL); + MPSolutionResponse glop_response; + model.FillSolutionResponseProto(&glop_response); + return glop_response; +} + +TEST(SolveProtoTest, SolvesEasyMinimizationLp) { + // min -2y + // s.t. x + y <= 1 + // x, y >= 0 + const auto proto = ParseTextOrDie(R"pb( + variable { lower_bound: 0 } + variable { lower_bound: 0 objective_coefficient: -2.0 } + constraint { + var_index: 0 + var_index: 1 + coefficient: 1.0 + coefficient: 1.0 + lower_bound: -inf + upper_bound: 1.0 + } + )pb"); + // Using default convergence tolerances and 1 thread. + PrimalDualHybridGradientParams params; + absl::StatusOr result = + SolveMpModelProto(proto, params); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->response.status(), MPSOLVER_OPTIMAL); + EXPECT_DOUBLE_EQ(result->response.objective_value(), -2.0); + EXPECT_THAT(result->response.variable_value(), ElementsAre(0.0, 1.0)); + EXPECT_THAT(result->response.dual_value(), ElementsAre(-2.0)); + EXPECT_THAT(result->response.reduced_cost(), ElementsAre(2.0, 0.0)); + + EXPECT_TRUE(result->solve_log.has_solution_stats()); + EXPECT_GT(result->solve_log.iteration_count(), 0); + + // The signs of the duals are consistent with Glop. + const MPSolutionResponse glop_response = GlopSolution(proto); + EXPECT_THAT(glop_response.dual_value(), + ElementsAreArray(result->response.dual_value())); + EXPECT_THAT(glop_response.reduced_cost(), + ElementsAreArray(result->response.reduced_cost())); +} + +// This test solves an LP that's designed to terminate with +// POINT_TYPE_ITERATE_DIFFERENCE. This yields a solution without any convergence +// information. SolveMpModelProto() should return a proto without +// objective_value set but otherwise function normally. +TEST(SolveProtoTest, SolvesWithDifferenceOfIterates) { + // This test uses the LP min x + y s.t. 2*y >= 0, y >= 5. This is primal + // unbounded and dual infeasible. The solver will start at x=0, y=5 and the + // current iterate won't be a dual infeasibility certificate until x < -5, + // which takes several iterations due to the 2*y >= 0 constraint limiting the + // step size. Difference of iterates detects dual infeasibility immediately. + const auto proto = ParseTextOrDie(R"pb( + variable { objective_coefficient: 1 } + variable { lower_bound: 5 objective_coefficient: 1 } + constraint { var_index: 1 coefficient: 2.0 lower_bound: 0.0 } + )pb"); + PrimalDualHybridGradientParams params; + params.set_linesearch_rule( + PrimalDualHybridGradientParams::CONSTANT_STEP_SIZE_RULE); + params.set_major_iteration_frequency(1); + params.set_l_inf_ruiz_iterations(0); + params.set_l2_norm_rescaling(false); + absl::StatusOr result = + SolveMpModelProto(proto, params); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->response.status(), MPSOLVER_NOT_SOLVED); + EXPECT_THAT(result->response.variable_value(), ElementsAre(-0.4, 0.0)); + EXPECT_THAT(result->response.dual_value(), ElementsAre(0.0)); + EXPECT_THAT(result->response.reduced_cost(), ElementsAre(0.0, 0.0)); + EXPECT_FALSE(result->response.has_objective_value()); + EXPECT_TRUE(result->solve_log.has_solution_stats()); + EXPECT_EQ(result->solve_log.iteration_count(), 1); +} + +TEST_P(SolveMpModelProtoMaybePresolveTest, SolvesEasyMaximizationLp) { + // max 2y + // s.t. x + y <= 1 + // x, y >= 0 + const auto proto = ParseTextOrDie(R"pb( + variable { lower_bound: 0 } + variable { lower_bound: 0 objective_coefficient: 2.0 } + constraint { + var_index: 0 + var_index: 1 + coefficient: 1.0 + coefficient: 1.0 + lower_bound: -inf + upper_bound: 1.0 + } + maximize: true + )pb"); + // Using default convergence tolerances and 1 thread. + PrimalDualHybridGradientParams params; + const bool presolve_on = GetParam(); + params.mutable_presolve_options()->set_use_glop(presolve_on); + + absl::StatusOr result = + SolveMpModelProto(proto, params); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->response.status(), MPSOLVER_OPTIMAL); + EXPECT_DOUBLE_EQ(result->response.objective_value(), 2.0); + EXPECT_THAT(result->response.variable_value(), ElementsAre(0.0, 1.0)); + EXPECT_THAT(result->response.dual_value(), ElementsAre(2.0)); + EXPECT_THAT(result->response.reduced_cost(), ElementsAre(-2.0, 0.0)); + + EXPECT_TRUE(result->solve_log.has_solution_stats()); + if (presolve_on) { + EXPECT_EQ(result->solve_log.iteration_count(), 0); + } else { + EXPECT_GT(result->solve_log.iteration_count(), 0); + } + // The signs of the duals are consistent with Glop. + EXPECT_THAT(result->response.dual_value(), + ElementsAreArray(result->response.dual_value())); + EXPECT_THAT(result->response.reduced_cost(), + ElementsAreArray(result->response.reduced_cost())); +} + +TEST(SolveProtoTest, RelaxesIntegerVariables) { + // max x + // s.t. 0 <= x <= 1, x integer + const auto proto = ParseTextOrDie(R"pb( + variable { + lower_bound: 0 + upper_bound: 1 + is_integer: true + objective_coefficient: 1 + } + maximize: true + )pb"); + // Using default convergence tolerances and 1 thread. + PrimalDualHybridGradientParams params; + absl::StatusOr result = + SolveMpModelProto(proto, params, /*relax_integer_variables=*/true); + ASSERT_TRUE(result.ok()) << result.status(); + EXPECT_EQ(result->response.status(), MPSOLVER_OPTIMAL); + EXPECT_DOUBLE_EQ(result->response.objective_value(), 1.0); + EXPECT_THAT(result->response.variable_value(), ElementsAre(1.0)); + EXPECT_THAT(result->response.reduced_cost(), ElementsAre(1.0)); +} + +TEST(SolveProtoTest, ErrorsOnIntegerVariables) { + // max x + // s.t. 0 <= x <= 1, x integer + const auto proto = ParseTextOrDie(R"pb( + variable { + lower_bound: 0 + upper_bound: 1 + is_integer: true + objective_coefficient: 1 + } + maximize: true + )pb"); + + PrimalDualHybridGradientParams params; + EXPECT_EQ(SolveMpModelProto(proto, PrimalDualHybridGradientParams(), + /*relax_integer_variables=*/false) + .status() + .code(), + absl::StatusCode::kInvalidArgument); +} + +// This is an integration test with MPSolver and a small example. +TEST(SolveProtoTest, SolvesEasyLpWithMPSolver) { + // min -2y + // s.t. x + y <= 1 + // x, y >= 0 + MPSolver model("easy lp", MPSolver::GLOP_LINEAR_PROGRAMMING); + auto* x = + model.MakeVar(0.0, MPSolver::infinity(), /*integer=*/false, /*name=*/""); + auto* y = + model.MakeVar(0.0, MPSolver::infinity(), /*integer=*/false, /*name=*/""); + model.MutableObjective()->SetCoefficient(y, -2.0); + model.MutableObjective()->SetMinimization(); + auto* constraint = model.MakeRowConstraint(-MPSolver::infinity(), 1.0); + constraint->SetCoefficient(x, 1.0); + constraint->SetCoefficient(y, 1.0); + + MPModelProto proto; + model.ExportModelToProto(&proto); + + // Using default convergence tolerances and 1 thread. + PrimalDualHybridGradientParams params; + + absl::StatusOr result = + SolveMpModelProto(proto, params); + ASSERT_TRUE(result.ok()) << result.status(); + const absl::Status load_status = + model.LoadSolutionFromProto(result->response); + ASSERT_TRUE(load_status.ok()) << load_status; + + EXPECT_DOUBLE_EQ(x->solution_value(), 0.0); + EXPECT_DOUBLE_EQ(y->solution_value(), 1.0); + EXPECT_DOUBLE_EQ(x->reduced_cost(), 2.0); + EXPECT_DOUBLE_EQ(y->reduced_cost(), 0.0); + EXPECT_DOUBLE_EQ(constraint->dual_value(), -2.0); + EXPECT_DOUBLE_EQ(model.Objective().Value(), -2.0); +} + +// Verifies that the primal and dual solution satisfy the bounds constraints. +// This function uses ASSERTS rather than EXPECTs because it's used with large +// QPs that would spam the logs otherwise. +void VerifyBoundConstraints(const QuadraticProgram& qp, + const Eigen::VectorXd primal_solution, + const Eigen::VectorXd dual_solution) { + for (int64_t i = 0; i < primal_solution.size(); ++i) { + ASSERT_TRUE(std::isfinite(primal_solution[i])) + << i << " " << primal_solution[i]; + ASSERT_GE(primal_solution[i], qp.variable_lower_bounds[i]); + ASSERT_LE(primal_solution[i], qp.variable_upper_bounds[i]); + } + for (int i = 0; i < dual_solution.size(); ++i) { + ASSERT_TRUE(std::isfinite(dual_solution[i])) + << i << " " << dual_solution[i]; + if (!std::isfinite(qp.constraint_lower_bounds[i])) { + ASSERT_LE(dual_solution[i], 0); + } + if (!std::isfinite(qp.constraint_upper_bounds[i])) { + ASSERT_GE(dual_solution[i], 0); + } + } +} + +TEST(PresolveTest, DetectsProblemWithInconsistentBounds) { + PrimalDualHybridGradientParams params; + params.mutable_presolve_options()->set_use_glop(true); + SolverResult output = + PrimalDualHybridGradient(SmallInvalidProblemLp(), params); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_INVALID_PROBLEM); +} + +TEST(PresolveTest, PresolveSolvesToOptimality) { + PrimalDualHybridGradientParams params; + params.mutable_presolve_options()->set_use_glop(true); + SolverResult output = PrimalDualHybridGradient(TestLp(), params); + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); + EXPECT_EQ(output.solve_log.iteration_count(), 0); + ASSERT_EQ(output.solve_log.solution_type(), POINT_TYPE_PRESOLVER_SOLUTION); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({-1, 8, 1, 2.5}, 1.0e-10)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({-2, 0, 2.375, 2.0 / 3}, 1.0e-10)); + const auto convergence_info = GetConvergenceInformation( + output.solve_log.solution_stats(), POINT_TYPE_PRESOLVER_SOLUTION); + ASSERT_TRUE(convergence_info.has_value()); + EXPECT_EQ(convergence_info->candidate_type(), POINT_TYPE_PRESOLVER_SOLUTION); + EXPECT_DOUBLE_EQ(convergence_info->primal_objective(), -34.0); + EXPECT_DOUBLE_EQ(convergence_info->dual_objective(), -34.0); + EXPECT_DOUBLE_EQ(convergence_info->corrected_dual_objective(), -34.0); + EXPECT_DOUBLE_EQ(convergence_info->l_inf_primal_residual(), 0.0); + EXPECT_DOUBLE_EQ(convergence_info->l2_primal_residual(), 0.0); + EXPECT_DOUBLE_EQ(convergence_info->l_inf_dual_residual(), 0.0); + EXPECT_DOUBLE_EQ(convergence_info->l2_dual_residual(), 0.0); + EXPECT_DOUBLE_EQ(convergence_info->l_inf_primal_variable(), 8.0); + EXPECT_DOUBLE_EQ(convergence_info->l2_primal_variable(), 8.5); + EXPECT_DOUBLE_EQ(convergence_info->l_inf_dual_variable(), 2.375); + EXPECT_THAT(convergence_info->l2_dual_variable(), + DoubleNear(3.17569983538, 1.0e-10)); +} + +TEST(PresolveTest, PresolveInfeasible) { + PrimalDualHybridGradientParams params; + params.mutable_presolve_options()->set_use_glop(true); + SolverResult output = + PrimalDualHybridGradient(SmallPrimalInfeasibleLp(), params); + EXPECT_EQ(output.solve_log.termination_reason(), + TERMINATION_REASON_PRIMAL_OR_DUAL_INFEASIBLE); + EXPECT_EQ(output.solve_log.solution_type(), POINT_TYPE_PRESOLVER_SOLUTION); + EXPECT_EQ(output.solve_log.iteration_count(), 0); +} + +TEST_P(PresolveDualScalingTest, Dualize) { + auto [dualize, negate_and_scale_objective] = GetParam(); + PrimalDualHybridGradientParams params; + params.mutable_presolve_options()->set_use_glop(true); + params.mutable_presolve_options() + ->mutable_glop_parameters() + ->set_solve_dual_problem(dualize ? glop::GlopParameters::ALWAYS_DO + : glop::GlopParameters::NEVER_DO); + params.mutable_termination_criteria()->set_iteration_limit(1000); + QuadraticProgram qp = CorrelationClusteringStarLp(); + if (negate_and_scale_objective) { + qp.objective_scaling_factor = -3; + } + SolverResult output = PrimalDualHybridGradient(qp, params); + EXPECT_EQ(output.solve_log.termination_reason(), TERMINATION_REASON_OPTIMAL); + EXPECT_GT(output.solve_log.iteration_count(), 0); + EXPECT_THAT(output.primal_solution, + EigenArrayNear({0.5, 0.5, 0.5, 0.0, 0.0, 0.0}, 1.0e-10)); + EXPECT_THAT(output.dual_solution, + EigenArrayNear({0.5, 0.5, 0.5}, 1.0e-10)); +} + +TEST(PresolveTest, PresolveParametersAreUsed) { + QuadraticProgram qp = CorrelationClusteringStarLp(); + PrimalDualHybridGradientParams params; + params.mutable_termination_criteria()->set_iteration_limit(0); + params.mutable_presolve_options()->set_use_glop(true); + SolverResult output_1 = PrimalDualHybridGradient(qp, params); + params.mutable_presolve_options() + ->mutable_glop_parameters() + ->set_solve_dual_problem(glop::GlopParameters::ALWAYS_DO); + SolverResult output_2 = PrimalDualHybridGradient(qp, params); + EXPECT_NE(output_1.solve_log.preprocessed_problem_stats().num_variables(), + output_2.solve_log.preprocessed_problem_stats().num_variables()); + EXPECT_NE(output_1.solve_log.preprocessed_problem_stats().num_constraints(), + output_2.solve_log.preprocessed_problem_stats().num_constraints()); +} + +TEST(ComputeStatusesTest, AtOptimum) { + QuadraticProgram lp = TestLp(); + PrimalAndDualSolution solution; + solution.primal_solution.resize(4); + solution.primal_solution << -1, 8, 1, 2.5; + solution.dual_solution.resize(4); + solution.dual_solution << -2, 0, 2.375, 2.0 / 3; + glop::ProblemSolution glop_solution = internal::ComputeStatuses(lp, solution); + EXPECT_THAT( + glop_solution.constraint_statuses, + ElementsAre(ConstraintStatus::FIXED_VALUE, ConstraintStatus::BASIC, + ConstraintStatus::AT_LOWER_BOUND, + ConstraintStatus::AT_LOWER_BOUND)); + EXPECT_THAT( + glop_solution.variable_statuses, + ElementsAre(VariableStatus::BASIC, VariableStatus::BASIC, + VariableStatus::BASIC, VariableStatus::AT_LOWER_BOUND)); +} + +TEST(ComputeStatusesTest, CoverMoreCases) { + QuadraticProgram lp = TestLp(); + lp.variable_upper_bounds[3] = 2.5; + lp.variable_upper_bounds[1] = 8; + PrimalAndDualSolution solution; + solution.primal_solution.resize(4); + solution.primal_solution << -1, 8, 1, 2.5; + solution.dual_solution.resize(4); + solution.dual_solution << -2, 0, 2.375, -1; + glop::ProblemSolution glop_solution = internal::ComputeStatuses(lp, solution); + EXPECT_THAT( + glop_solution.constraint_statuses, + ElementsAre(ConstraintStatus::FIXED_VALUE, ConstraintStatus::BASIC, + ConstraintStatus::AT_LOWER_BOUND, + ConstraintStatus::AT_UPPER_BOUND)); + EXPECT_THAT(glop_solution.variable_statuses, + ElementsAre(VariableStatus::BASIC, VariableStatus::AT_UPPER_BOUND, + VariableStatus::BASIC, VariableStatus::FIXED_VALUE)); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/quadratic_program.cc b/ortools/pdlp/quadratic_program.cc new file mode 100644 index 0000000000..7c9b5fd58d --- /dev/null +++ b/ortools/pdlp/quadratic_program.cc @@ -0,0 +1,366 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/quadratic_program.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/base/status_macros.h" +#include "ortools/linear_solver/linear_solver.pb.h" + +namespace operations_research::pdlp { + +using ::Eigen::VectorXd; + +absl::Status ValidateQuadraticProgramDimensions(const QuadraticProgram& qp) { + const int64_t var_lb_size = qp.variable_lower_bounds.size(); + const int64_t con_lb_size = qp.constraint_lower_bounds.size(); + + if (var_lb_size != qp.variable_upper_bounds.size()) { + return absl::InvalidArgumentError(absl::StrCat( + "Inconsistent dimensions: variable lower bound vector has size ", + var_lb_size, " while variable upper bound vector has size ", + qp.variable_upper_bounds.size())); + } + if (var_lb_size != qp.objective_vector.size()) { + return absl::InvalidArgumentError(absl::StrCat( + "Inconsistent dimensions: variable lower bound vector has size ", + var_lb_size, " while objective vector has size ", + qp.objective_vector.size())); + } + if (var_lb_size != qp.constraint_matrix.cols()) { + return absl::InvalidArgumentError(absl::StrCat( + "Inconsistent dimensions: variable lower bound vector has size ", + var_lb_size, " while constraint matrix has ", + qp.constraint_matrix.cols(), " columns")); + } + if (qp.objective_matrix.has_value() && + var_lb_size != qp.objective_matrix->rows()) { + return absl::InvalidArgumentError(absl::StrCat( + "Inconsistent dimensions: variable lower bound vector has size ", + var_lb_size, " while objective matrix has ", + qp.objective_matrix->rows(), " rows")); + } + if (con_lb_size != qp.constraint_upper_bounds.size()) { + return absl::InvalidArgumentError(absl::StrCat( + "Inconsistent dimensions: constraint lower bound vector has size ", + con_lb_size, " while constraint upper bound vector has size ", + qp.constraint_upper_bounds.size())); + } + if (con_lb_size != qp.constraint_matrix.rows()) { + return absl::InvalidArgumentError(absl::StrCat( + "Inconsistent dimensions: constraint lower bound vector has size ", + con_lb_size, " while constraint matrix has ", + qp.constraint_matrix.rows(), " rows ")); + } + + return absl::OkStatus(); +} + +bool HasValidBounds(const QuadraticProgram& qp) { + const bool constraint_bounds_valid = + (qp.constraint_lower_bounds.array() <= qp.constraint_upper_bounds.array()) + .all(); + const bool variable_bounds_valid = + (qp.variable_lower_bounds.array() <= qp.variable_upper_bounds.array()) + .all(); + return constraint_bounds_valid && variable_bounds_valid; +} + +absl::StatusOr QpFromMpModelProto( + const MPModelProto& proto, bool relax_integer_variables, + bool include_names) { + if (!proto.general_constraint().empty()) { + return absl::InvalidArgumentError("General constraints are not supported."); + } + const int primal_size = proto.variable_size(); + const int dual_size = proto.constraint_size(); + QuadraticProgram qp(primal_size, dual_size); + if (include_names) { + qp.problem_name = proto.name(); + qp.variable_names = std::vector(primal_size); + qp.constraint_names = std::vector(dual_size); + } + for (int i = 0; i < primal_size; ++i) { + const auto& var = proto.variable(i); + qp.variable_lower_bounds[i] = var.lower_bound(); + qp.variable_upper_bounds[i] = var.upper_bound(); + qp.objective_vector[i] = var.objective_coefficient(); + if (var.is_integer() && !relax_integer_variables) { + return absl::InvalidArgumentError( + "Integer variable encountered with relax_integer_variables == false"); + } + if (include_names) { + (*qp.variable_names)[i] = var.name(); + } + } + std::vector nonzeros_by_column(primal_size); + for (int i = 0; i < dual_size; ++i) { + const auto& con = proto.constraint(i); + for (int j = 0; j < con.var_index_size(); ++j) { + if (con.var_index(j) < 0 || con.var_index(j) >= primal_size) { + return absl::InvalidArgumentError(absl::StrCat( + "Variable index of ", i, "th constraint's ", j, "th nonzero is ", + con.var_index(j), " which is not in the allowed range [0, ", + primal_size, ")")); + } + nonzeros_by_column[con.var_index(j)]++; + } + qp.constraint_lower_bounds[i] = con.lower_bound(); + qp.constraint_upper_bounds[i] = con.upper_bound(); + if (include_names) { + (*qp.constraint_names)[i] = con.name(); + } + } + // To reduce peak RAM usage we construct the constraint matrix in-place. + // According to the documentation of SparseMatrix::insert() it's effecient to + // construct a matrix with insert()s as long as reserve() is called first and + // the non-zeros are inserted in increasing order of inner index. + // The non-zeros in each input constraint may not be sorted so this is only + // efficient with Column major format. + static_assert(qp.constraint_matrix.IsRowMajor == 0, "See comment."); + qp.constraint_matrix.reserve(nonzeros_by_column); + for (int i = 0; i < dual_size; ++i) { + const auto& con = proto.constraint(i); + CHECK_EQ(con.var_index_size(), con.coefficient_size()) + << " in " << i << "th constraint"; + if (con.var_index_size() != con.coefficient_size()) { + return absl::InvalidArgumentError( + absl::StrCat(i, "th constraint has ", con.coefficient_size(), + " coefficients, expected ", con.var_index_size())); + } + + for (int j = 0; j < con.var_index_size(); ++j) { + qp.constraint_matrix.insert(i, con.var_index(j)) = con.coefficient(j); + } + } + if (qp.constraint_matrix.outerSize() > 0) { + qp.constraint_matrix.makeCompressed(); + } + // We use triplets-based initialization for the objective matrix because the + // objective non-zeros may be in arbitrary order in the input. + std::vector> triplets; + const auto& quadratic = proto.quadratic_objective(); + if (quadratic.qvar1_index_size() != quadratic.qvar2_index_size() || + quadratic.qvar1_index_size() != quadratic.coefficient_size()) { + return absl::InvalidArgumentError(absl::StrCat( + "The quadratic objective has ", quadratic.qvar1_index_size(), + " qvar1_indices, ", quadratic.qvar2_index_size(), + " qvar2_indices, and ", quadratic.coefficient_size(), + " coefficients, expected equal numbers.")); + } + if (quadratic.qvar1_index_size() > 0) { + qp.objective_matrix.emplace(); + qp.objective_matrix->setZero(primal_size); + } + + for (int i = 0; i < quadratic.qvar1_index_size(); ++i) { + const int index1 = quadratic.qvar1_index(i); + const int index2 = quadratic.qvar2_index(i); + if (index1 < 0 || index2 < 0 || index1 >= primal_size || + index2 >= primal_size) { + return absl::InvalidArgumentError(absl::StrCat( + "The quadratic objective's ", i, "th nonzero has indices ", index1, + " and ", index2, ", which are not both in the expected range [0, ", + primal_size, ")")); + } + if (index1 != index2) { + return absl::InvalidArgumentError(absl::StrCat( + "The quadratic objective's ", i, + "th nonzero has off-diagonal element at (", index1, ", ", index2, + "). Only diagonal objective matrices are supported.")); + } + // Note: QuadraticProgram has an implicit "1/2" in front of the quadratic + // term. + qp.objective_matrix->diagonal()[index1] = 2 * quadratic.coefficient(i); + } + qp.objective_offset = proto.objective_offset(); + if (proto.maximize()) { + qp.objective_offset *= -1; + qp.objective_vector *= -1; + if (qp.objective_matrix.has_value()) { + qp.objective_matrix->diagonal() *= -1; + } + qp.objective_scaling_factor = -1; + } + return std::move(qp); +} + +absl::Status CanFitInMpModelProto(const QuadraticProgram& qp) { + return internal::TestableCanFitInMpModelProto( + qp, std::numeric_limits::max()); +} + +namespace internal { +absl::Status TestableCanFitInMpModelProto(const QuadraticProgram& qp, + const int64_t largest_ok_size) { + const int64_t primal_size = qp.variable_lower_bounds.size(); + const int64_t dual_size = qp.constraint_lower_bounds.size(); + bool primal_too_big = primal_size > largest_ok_size; + if (primal_too_big) { + return absl::InvalidArgumentError(absl::StrCat( + "Too many variables (", primal_size, ") to index with an int32_t.")); + } + bool dual_too_big = dual_size > largest_ok_size; + if (dual_too_big) { + return absl::InvalidArgumentError(absl::StrCat( + "Too many constraints (", dual_size, ") to index with an int32_t.")); + } + return absl::OkStatus(); +} +} // namespace internal + +absl::StatusOr QpToMpModelProto(const QuadraticProgram& qp) { + RETURN_IF_ERROR(CanFitInMpModelProto(qp)); + if (qp.objective_scaling_factor == 0) { + return absl::InvalidArgumentError( + "objective_scaling_factor cannot be zero."); + } + const int64_t primal_size = qp.variable_lower_bounds.size(); + const int64_t dual_size = qp.constraint_lower_bounds.size(); + MPModelProto proto; + if (qp.problem_name.has_value() && !qp.problem_name->empty()) { + proto.set_name(*qp.problem_name); + } + proto.set_objective_offset(qp.objective_scaling_factor * qp.objective_offset); + if (qp.objective_scaling_factor < 0) { + proto.set_maximize(true); + } else { + proto.set_maximize(false); + } + + proto.mutable_variable()->Reserve(primal_size); + for (int64_t i = 0; i < primal_size; ++i) { + auto* var = proto.add_variable(); + var->set_lower_bound(qp.variable_lower_bounds[i]); + var->set_upper_bound(qp.variable_upper_bounds[i]); + var->set_objective_coefficient(qp.objective_scaling_factor * + qp.objective_vector[i]); + if (qp.variable_names.has_value() && i < qp.variable_names->size()) { + const std::string& name = (*qp.variable_names)[i]; + if (!name.empty()) { + var->set_name(name); + } + } + } + + proto.mutable_constraint()->Reserve(dual_size); + for (int64_t i = 0; i < dual_size; ++i) { + auto* con = proto.add_constraint(); + con->set_lower_bound(qp.constraint_lower_bounds[i]); + con->set_upper_bound(qp.constraint_upper_bounds[i]); + if (qp.constraint_names.has_value() && i < qp.constraint_names->size()) { + const std::string& name = (*qp.constraint_names)[i]; + if (!name.empty()) { + con->set_name(name); + } + } + } + + using InnerIterator = + ::Eigen::SparseMatrix::InnerIterator; + for (int64_t col = 0; col < qp.constraint_matrix.cols(); ++col) { + for (InnerIterator iter(qp.constraint_matrix, col); iter; ++iter) { + auto* con = proto.mutable_constraint(iter.row()); + // To avoid reallocs during the inserts, we could count the nonzeros + // and reserve() before filling. + con->add_var_index(iter.col()); + con->add_coefficient(iter.value()); + } + } + + // Some OR tools decide the objective is quadratic based on + // has_quadratic_objective() rather than on quadratic_objective_size() == 0, + // so don't create the quadratic objective for linear programs. + if (!IsLinearProgram(qp)) { + auto* quadratic_objective = proto.mutable_quadratic_objective(); + const auto& diagonal = qp.objective_matrix->diagonal(); + for (int64_t i = 0; i < diagonal.size(); ++i) { + if (diagonal[i] != 0.0) { + quadratic_objective->add_qvar1_index(i); + quadratic_objective->add_qvar2_index(i); + // Undo the implicit (1/2) term in QuadraticProgram's objective. + quadratic_objective->add_coefficient(qp.objective_scaling_factor * + diagonal[i] / 2.0); + } + } + } + + return proto; +} + +void SetEigenMatrixFromTriplets( + std::vector> triplets, + Eigen::SparseMatrix& matrix) { + using Triplet = Eigen::Triplet; + std::sort(triplets.begin(), triplets.end(), + [](const Triplet& lhs, const Triplet& rhs) { + return std::tie(lhs.col(), lhs.row()) < + std::tie(rhs.col(), rhs.row()); + }); + + // The triplets are allowed to contain duplicate entries (and intentionally + // do for the diagonals of the objective matrix). For efficiency of insert + // and reserve, merge the duplicates first. + internal::CombineRepeatedTripletsInPlace(triplets); + + std::vector num_column_entries(matrix.cols()); + for (const Triplet& triplet : triplets) { + ++num_column_entries[triplet.col()]; + } + // NOTE: reserve() takes column counts because matrix is in column major + // order. + matrix.reserve(num_column_entries); + for (const Triplet& triplet : triplets) { + matrix.insert(triplet.row(), triplet.col()) = triplet.value(); + } + if (matrix.outerSize() > 0) { + matrix.makeCompressed(); + } +} + +namespace internal { +void CombineRepeatedTripletsInPlace( + std::vector>& triplets) { + if (triplets.empty()) return; + auto output_iter = triplets.begin(); + for (auto p = output_iter + 1; p != triplets.end(); ++p) { + if (output_iter->row() == p->row() && output_iter->col() == p->col()) { + *output_iter = {output_iter->row(), output_iter->col(), + output_iter->value() + p->value()}; + } else { + ++output_iter; + if (output_iter != p) { // Small optimization - skip no-op copies. + *output_iter = *p; + } + } + } + // *output_iter is the last output value, so erase everything after that. + triplets.erase(output_iter + 1, triplets.end()); +} +} // namespace internal +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/quadratic_program.h b/ortools/pdlp/quadratic_program.h new file mode 100644 index 0000000000..e690e42386 --- /dev/null +++ b/ortools/pdlp/quadratic_program.h @@ -0,0 +1,216 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_QUADRATIC_PROGRAM_H_ +#define PDLP_QUADRATIC_PROGRAM_H_ + +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/linear_solver/linear_solver.pb.h" + +namespace operations_research::pdlp { + +// Represents the quadratic program (QP): +// min_x (objective_vector^T x + (1/2) x^T objective_matrix x) s.t. +// constraint_lower_bounds <= constraint_matrix x <= constraint_upper_bounds +// variable_lower_bounds <= x <= variable_upper_bounds +// +// constraint_lower_bounds and variable_lower_bounds may include negative +// infinities. constraint_upper_bounds and variable_upper_bounds may +// contain positive infinities. Other than that all entries of all fields must +// be finite. The objective_matrix must be symmetric and positive semi-definite. +// +// For convenience, the struct also stores a scaling factor and objective +// offset. These factors can be used to transform objective values based on the +// problem definition above into objective values that are meaningful for the +// user. See ApplyObjectiveScalingAndOffset. +// +// This struct is also intended for use with linear programs (LPs), which are +// QPs with a zero objective_matrix. +// +// The dual is documented at +// https://developers.google.com/optimization/lp/pdlp_math. +struct QuadraticProgram { + QuadraticProgram(int64_t num_variables, int64_t num_constraints) { + ResizeAndInitialize(num_variables, num_constraints); + } + QuadraticProgram() : QuadraticProgram(0, 0) {} + + // QuadraticPrograms may be copied or moved. Eigen::SparseMatrix doesn't + // have move operations so we use custom implementations based on swap. + QuadraticProgram(const QuadraticProgram& other) = default; + QuadraticProgram(QuadraticProgram&& other) noexcept + : objective_vector(std::move(other.objective_vector)), + objective_matrix(std::move(other.objective_matrix)), + constraint_lower_bounds(std::move(other.constraint_lower_bounds)), + constraint_upper_bounds(std::move(other.constraint_upper_bounds)), + variable_lower_bounds(std::move(other.variable_lower_bounds)), + variable_upper_bounds(std::move(other.variable_upper_bounds)), + problem_name(std::move(other.problem_name)), + variable_names(std::move(other.variable_names)), + constraint_names(std::move(other.constraint_names)), + objective_offset(other.objective_offset), + objective_scaling_factor(other.objective_scaling_factor) { + constraint_matrix.swap(other.constraint_matrix); + } + QuadraticProgram& operator=(const QuadraticProgram& other) = default; + QuadraticProgram& operator=(QuadraticProgram&& other) { + objective_vector = std::move(other.objective_vector); + objective_matrix = std::move(other.objective_matrix); + constraint_matrix.swap(other.constraint_matrix); + constraint_lower_bounds = std::move(other.constraint_lower_bounds); + constraint_upper_bounds = std::move(other.constraint_upper_bounds); + variable_lower_bounds = std::move(other.variable_lower_bounds); + variable_upper_bounds = std::move(other.variable_upper_bounds); + problem_name = std::move(other.problem_name); + variable_names = std::move(other.variable_names); + constraint_names = std::move(other.constraint_names); + objective_offset = other.objective_offset; + objective_scaling_factor = other.objective_scaling_factor; + return *this; + } + + // Initializes the quadratic program with num_variables variables and + // num_constraints constraints. Lower and upper bounds are set to to negative + // and positive infinity, repectively. The objective matrix is cleared. All + // other matrices and vectors are set to zero. Resets the optional names + // (program_name, variable_names, and constraint_names). objective_offset is + // set to 0 and objective_scaling_factor is set to 1. + void ResizeAndInitialize(int64_t num_variables, int64_t num_constraints) { + constexpr double kInfinity = std::numeric_limits::infinity(); + objective_vector = Eigen::VectorXd::Zero(num_variables); + objective_matrix.reset(); + constraint_matrix.resize(num_constraints, num_variables); + constraint_lower_bounds = + Eigen::VectorXd::Constant(num_constraints, -kInfinity); + constraint_upper_bounds = + Eigen::VectorXd::Constant(num_constraints, kInfinity); + variable_lower_bounds = + Eigen::VectorXd::Constant(num_variables, -kInfinity); + variable_upper_bounds = Eigen::VectorXd::Constant(num_variables, kInfinity); + problem_name.reset(); + variable_names.reset(); + constraint_names.reset(); + objective_offset = 0.0; + objective_scaling_factor = 1.0; + } + + // Returns objective_scaling_factor * (objective + objective_offset). + // objective_scaling_factor is useful for modeling maximization problems. + // For example, max c'x = -1 * min (-c)'x. objective_offset can be a + // by-product of presolve transformations that eliminate variables. + double ApplyObjectiveScalingAndOffset(double objective) const { + return objective_scaling_factor * (objective + objective_offset); + } + + Eigen::VectorXd objective_vector; + // If this field isn't set, the objective matrix is interpreted to be zero, + // i.e., this is a linear programming problem. + std::optional> objective_matrix; + Eigen::SparseMatrix constraint_matrix; + Eigen::VectorXd constraint_lower_bounds, constraint_upper_bounds; + Eigen::VectorXd variable_lower_bounds, variable_upper_bounds; + // The problem, constraint, and variable names are optional. + absl::optional problem_name; + absl::optional> variable_names; + absl::optional> constraint_names; + + // These fields are provided for convenience; they don't change the + // mathematical definition of the problem, but they could change the objective + // values reported to the user. + double objective_offset; + double objective_scaling_factor; +}; + +// Returns InvalidArgument if vector or matrix dimensions are inconsistent. +// Returns OkStatus otherwise. +absl::Status ValidateQuadraticProgramDimensions(const QuadraticProgram& qp); + +inline bool IsLinearProgram(const QuadraticProgram& qp) { + return !qp.objective_matrix.has_value(); +} + +// Checks if the lower and upper bounds of the problem are consistent, i.e. for +// each variable and constraint bound we have lower_bound <= upper_bound. If +// the input is consistent the method returns true, otherwise it returns false. +// See also HasValidBounds(const ShardedQuadraticProgram&). +bool HasValidBounds(const QuadraticProgram& qp); + +// Converts an MPModelProto into a QuadraticProgram. +// Returns an error if general constraints are present. +// If relax_integer_variables is true integer variables are relaxed to +// continuous; otherwise integer variables are an error. +// If include_names is true (the default is false), the problem, constraint, +// and variable names are included in the QuadraticProgram; otherwise they are +// left empty. +// Maximization problems are converted to minimization by negating the +// objective and setting objective_scaling_factor to -1, which preserves the +// reported objective values. +absl::StatusOr QpFromMpModelProto( + const MPModelProto& proto, bool relax_integer_variables, + bool include_names = false); + +// Returns InvalidArgument if the given quadratic program is too large to +// convert to MPModelProto and OkStatus otherwise. +absl::Status CanFitInMpModelProto(const QuadraticProgram& qp); + +// Converts a QuadraticProgram into an MPModelProto. To preserve objective +// values in the conversion, the objective vector, objective matrix, and +// objective offset are scaled by objective_scaling_factor, and if +// objective_scaling_factor is negative, then the proto is a maximization +// problem (otherwise it's a minimization problem). Returns InvalidArgumentError +// if objective_scaling_factor is zero or if CanFitInMpModelProto() fails. +absl::StatusOr QpToMpModelProto(const QuadraticProgram& qp); + +// Like matrix.setFromTriplets(triplets), except that setFromTriplets results +// in having three copies of the nonzeros in memory at the same time, because it +// first fills one matrix from triplets, and then transposes it into another. +// This avoids having the third copy in memory by sorting the triplets, +// reserving space in the matrix, and then inserting in sorted order. +// Compresses the matrix (SparseMatrix.makeCompressed()) after loading it. +// NOTE: This intentionally passes triplets by copy, because it modifies them. +// To avoid the copy, pass a move reference. +void SetEigenMatrixFromTriplets( + std::vector> triplets, + Eigen::SparseMatrix& matrix); + +// Utility functions for internal use only. +namespace internal { +// Like CanFitInMpModelProto() but has an extra argument for the largest number +// of variables, constraints, or objective non-zeros that should be counted as +// convertible. CanFitInMpModelProto() passes 2^31 - 1 for this argument and +// unit tests pass small values. +absl::Status TestableCanFitInMpModelProto(const QuadraticProgram& qp, + int64_t largest_ok_size); + +// Modifies a vector of Eigen::Triplets in place, combining consecutive entries +// with the same row and column, summing their values. This is most effective +// if the triplets are sorted by row and column, so that multiple entries for +// the same entry will be consecutive. +void CombineRepeatedTripletsInPlace( + std::vector>& triplets); +} // namespace internal +} // namespace operations_research::pdlp + +#endif // PDLP_QUADRATIC_PROGRAM_H_ diff --git a/ortools/pdlp/quadratic_program_io.cc b/ortools/pdlp/quadratic_program_io.cc new file mode 100644 index 0000000000..fdb697cf5e --- /dev/null +++ b/ortools/pdlp/quadratic_program_io.cc @@ -0,0 +1,114 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/quadratic_program_io.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/base/casts.h" +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/match.h" +#include "absl/types/optional.h" +#include "ortools/base/basictypes.h" +#include "ortools/base/file.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/base/status_macros.h" +#include "ortools/linear_solver/linear_solver.pb.h" +#include "ortools/linear_solver/model_exporter.h" +#include "ortools/lp_data/mps_reader.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/util/file_util.h" + +namespace operations_research::pdlp { + +// TODO(user): Update internal helper functions to use references instead of +// pointers. + +using ::operations_research::glop::MPSReader; + +QuadraticProgram ReadQuadraticProgramOrDie(const std::string& filename, + bool include_names) { + if (absl::EndsWith(filename, ".mps") || absl::EndsWith(filename, ".mps.gz") || + absl::EndsWith(filename, ".mps.bz2")) { + return ReadMpsLinearProgramOrDie(filename, include_names); + } + if (absl::EndsWith(filename, ".pb") || + absl::EndsWith(filename, ".textproto") || + absl::EndsWith(filename, ".json") || + absl::EndsWith(filename, ".json.gz")) { + return ReadMPModelProtoFileOrDie(filename, include_names); + } + LOG(QFATAL) << "Invalid filename suffix in " << filename + << ". Valid suffixes are .mps, .mps.gz, .pb, .textproto," + << ".json, and .json.gz"; +} + +QuadraticProgram ReadMpsLinearProgramOrDie(const std::string& lp_file, + bool include_names) { + MPModelProto lp_proto; + LOG(INFO) << "Reading " << lp_file; + QCHECK_OK(MPSReader().ParseFile(lp_file, &lp_proto)); + // MPSReader sometimes fails silently if the file isn't read properly. + QCHECK_GT(lp_proto.variable_size(), 0) + << "No variables in LP. Error reading file? " << lp_file; + auto result = QpFromMpModelProto(lp_proto, /*relax_integer_variables=*/true, + include_names); + QCHECK_OK(result); + return *std::move(result); +} + +QuadraticProgram ReadMPModelProtoFileOrDie( + const std::string& mpmodel_proto_file, bool include_names) { + MPModelProto lp_proto; + QCHECK(ReadFileToProto(mpmodel_proto_file, &lp_proto)) << mpmodel_proto_file; + auto result = QpFromMpModelProto(lp_proto, /*relax_integer_variables=*/true, + include_names); + QCHECK_OK(result); + return *std::move(result); +} + +absl::Status WriteLinearProgramToMps(const QuadraticProgram& linear_program, + const std::string& mps_file) { + if (!IsLinearProgram(linear_program)) { + return absl::InvalidArgumentError( + "'linear_program' has a quadratic objective"); + } + ASSIGN_OR_RETURN(MPModelProto proto, QpToMpModelProto(linear_program)); + ASSIGN_OR_RETURN(std::string mps_export, ExportModelAsMpsFormat(proto)); + File* file; + RETURN_IF_ERROR(file::Open(mps_file, "w", &file, file::Defaults())); + auto status = file::WriteString(file, mps_export, file::Defaults()); + status.Update(file->Close(file::Defaults())); + return status; +} + +absl::Status WriteQuadraticProgramToMPModelProto( + const QuadraticProgram& quadratic_program, + const std::string& mpmodel_proto_file) { + ASSIGN_OR_RETURN(MPModelProto proto, QpToMpModelProto(quadratic_program)); + + return file::SetBinaryProto(mpmodel_proto_file, proto, file::Defaults()); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/quadratic_program_io.h b/ortools/pdlp/quadratic_program_io.h new file mode 100644 index 0000000000..e2d16961fe --- /dev/null +++ b/ortools/pdlp/quadratic_program_io.h @@ -0,0 +1,51 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_QUADRATIC_PROGRAM_IO_H_ +#define PDLP_QUADRATIC_PROGRAM_IO_H_ + +#include +#include +#include + +#include "absl/container/flat_hash_map.h" +#include "absl/status/status.h" +#include "ortools/pdlp/quadratic_program.h" + +namespace operations_research::pdlp { + +// Reads a quadratic program, determining the type based on the filename's +// suffix: +// *.mps, *.mps.gz, *.mps.bz2 -> ReadMpsLinearProgramOrDie +// *.pb, *.textproto, *.json, *.json.gz -> ReadMPModelProtoFileOrDie +// otherwise CHECK-fails. +QuadraticProgram ReadQuadraticProgramOrDie(const std::string& filename, + bool include_names = false); + +QuadraticProgram ReadMpsLinearProgramOrDie(const std::string& lp_file, + bool include_names = false); +// The input may be MPModelProto in text format, binary format, or JSON, +// possibly gzipped. +QuadraticProgram ReadMPModelProtoFileOrDie( + const std::string& mpmodel_proto_file, bool include_names = false); + +// NOTE: This will fail if 'linear_program' is actually a quadratic program +// (that is, has a non-empty quadratic objective term). +absl::Status WriteLinearProgramToMps(const QuadraticProgram& linear_program, + const std::string& mps_file); +absl::Status WriteQuadraticProgramToMPModelProto( + const QuadraticProgram& quadratic_program, + const std::string& mpmodel_proto_file); + +} // namespace operations_research::pdlp +#endif // PDLP_QUADRATIC_PROGRAM_IO_H_ diff --git a/ortools/pdlp/quadratic_program_test.cc b/ortools/pdlp/quadratic_program_test.cc new file mode 100644 index 0000000000..7fdf659c4e --- /dev/null +++ b/ortools/pdlp/quadratic_program_test.cc @@ -0,0 +1,488 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/quadratic_program.h" + +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/types/optional.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/protobuf_util.h" +#include "ortools/base/status_macros.h" +#include "ortools/linear_solver/linear_solver.pb.h" +#include "ortools/pdlp/test_util.h" + +namespace operations_research::pdlp { +namespace { + +using ::google::protobuf::util::ParseTextOrDie; +using ::operations_research::pdlp::internal::CombineRepeatedTripletsInPlace; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::HasSubstr; +using ::testing::IsEmpty; +using ::testing::Optional; +using ::testing::PrintToString; +const double kInfinity = std::numeric_limits::infinity(); + +TEST(QuadraticProgram, DefaultConstructorWorks) { QuadraticProgram qp; } + +TEST(QuadraticProgram, MoveConstructor) { + QuadraticProgram qp1 = TestDiagonalQp1(); + QuadraticProgram qp2(std::move(qp1)); + VerifyTestQp(qp2); +} + +TEST(QuadraticProgram, MoveAssignment) { + QuadraticProgram qp1 = TestDiagonalQp1(); + QuadraticProgram qp2; + qp2 = std::move(qp1); + VerifyTestQp(qp2); +} + +TEST(ValidateQuadraticProgramDimensions, ValidProblem) { + const absl::Status status = + ValidateQuadraticProgramDimensions(TestDiagonalQp1()); + EXPECT_TRUE(status.ok()) << status; +} + +TEST(ValidateQuadraticProgramDimensions, ConstraintLowerBoundsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.constraint_lower_bounds.resize(10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, ConstraintUpperBoundsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.constraint_upper_bounds.resize(10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, ObjectiveVectorInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.objective_vector.resize(10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, VariableLowerBoundsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.variable_lower_bounds.resize(10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, VariableUpperBoundsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.variable_upper_bounds.resize(10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, ConstraintMatrixRowsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.constraint_matrix.resize(10, 2); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, ConstraintMatrixColsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.constraint_matrix.resize(2, 10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(ValidateQuadraticProgramDimensions, ObjectiveMatrixRowsInconsistent) { + QuadraticProgram qp; + qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); + qp.objective_matrix.emplace(); + qp.objective_matrix->resize(10); + EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(HasValidBoundsTest, InconsistentConstraintBounds) { + QuadraticProgram invalid_lp = SmallInvalidProblemLp(); + EXPECT_FALSE(HasValidBounds(invalid_lp)); +} + +TEST(HasValidBoundsTest, InconsistentVariableBounds) { + QuadraticProgram invalid_lp = SmallInconsistentVariableBoundsLp(); + EXPECT_FALSE(HasValidBounds(invalid_lp)); +} + +TEST(HasValidBoundsTest, SmallValidLp) { + QuadraticProgram valid_lp = SmallPrimalInfeasibleLp(); + EXPECT_TRUE(HasValidBounds(valid_lp)); +} + +class ConvertQpMpModelProtoTest : public testing::TestWithParam {}; + +// The LP: +// optimize 5.5 x_0 + 2 x_1 - x_2 + x_3 - 14 s.t. +// 2 x_0 + x_1 + x_2 + 2 x_3 = 12 +// x_0 + x_2 >= 7 +// 3.5 x_0 <= -4 +// -1 <= 1.5 x_2 - x_3 <= 1 +// -infinity <= x_0 <= infinity +// -2 <= x_1 <= infinity +// -infinity <= x_2 <= 6 +// 2.5 <= x_3 <= 3.5 +MPModelProto TestLpProto(bool maximize) { + auto proto = ParseTextOrDie(R"pb(variable { + lower_bound: -inf + upper_bound: inf + objective_coefficient: 5.5 + } + variable { + lower_bound: -2 + upper_bound: inf + objective_coefficient: -2 + } + variable { + lower_bound: -inf + upper_bound: 6 + objective_coefficient: -1 + } + variable { + lower_bound: 2.5 + upper_bound: 3.5 + objective_coefficient: 1 + } + constraint { + lower_bound: 12 + upper_bound: 12 + var_index: [ 0, 1, 2, 3 ] + coefficient: [ 2, 1, 1, 2 ] + } + constraint { + lower_bound: -inf + upper_bound: 7 + var_index: [ 0, 2 ] + coefficient: [ 1, 1 ] + } + constraint { + lower_bound: -4 + upper_bound: inf + var_index: [ 0 ] + coefficient: [ 4 ] + } + constraint { + lower_bound: -1 + upper_bound: 1 + var_index: [ 2, 3 ] + coefficient: [ 1.5, -1 ] + } + objective_offset: -14)pb"); + proto.set_maximize(maximize); + return proto; +} + +// This is tested for both minimization and maximization. +TEST_P(ConvertQpMpModelProtoTest, LpFromMpModelProto) { + const bool maximize = GetParam(); + MPModelProto proto = TestLpProto(maximize); + const auto lp = QpFromMpModelProto(proto, /*relax_integer_variables=*/false); + ASSERT_TRUE(lp.ok()) << lp.status(); + + VerifyTestLp(*lp, maximize); +} + +// The QP: +// optimize x_0^2 + x_1^2 + 3 x_0 - 4 s.t. +// x_0 + x_1 <= 42 +// -1 <= x_0 <= 2 +// -2 <= x_1 <= 3 +MPModelProto TestQpProto(bool maximize) { + auto proto = ParseTextOrDie( + R"pb(variable { lower_bound: -1 upper_bound: 2 objective_coefficient: 3 } + variable { lower_bound: -2 upper_bound: 3 objective_coefficient: 0 } + constraint { + lower_bound: -inf + upper_bound: 42 + var_index: [ 0, 1 ] + coefficient: [ 1, 1 ] + } + objective_offset: -4 + quadratic_objective { + qvar1_index: [ 0, 1 ] + qvar2_index: [ 0, 1 ] + coefficient: [ 1, 1 ] + } + )pb"); + proto.set_maximize(maximize); + return proto; +} + +// This is tested for both minimization and maximization. +TEST_P(ConvertQpMpModelProtoTest, QpFromMpModelProto) { + const bool maximize = GetParam(); + MPModelProto proto = TestQpProto(maximize); + const auto qp = QpFromMpModelProto(proto, /*relax_integer_variables=*/false); + ASSERT_TRUE(qp.ok()) << qp.status(); + + EXPECT_THAT(qp->constraint_lower_bounds, ElementsAre(-kInfinity)); + EXPECT_THAT(qp->constraint_upper_bounds, ElementsAre(42)); + EXPECT_THAT(qp->variable_lower_bounds, ElementsAre(-1, -2)); + EXPECT_THAT(qp->variable_upper_bounds, ElementsAre(2, 3)); + EXPECT_THAT(ToDense(qp->constraint_matrix), EigenArrayEq({{1, 1}})); + EXPECT_TRUE(qp->constraint_matrix.isCompressed()); + + double sign = maximize ? -1 : 1; + EXPECT_EQ(sign * qp->objective_offset, -4); + EXPECT_EQ(qp->objective_scaling_factor, sign); + EXPECT_THAT(sign * qp->objective_vector, ElementsAre(3, 0)); + EXPECT_THAT(sign * (qp->objective_matrix->diagonal()), + EigenArrayEq({2, 2})); +} + +TEST(QpFromMpModelProto, ErrorsOnOffDiagonalTerms) { + auto proto = ParseTextOrDie( + R"pb(variable { lower_bound: -1 upper_bound: 2 objective_coefficient: 3 } + variable { lower_bound: -2 upper_bound: 3 objective_coefficient: 0 } + constraint { + lower_bound: -inf + upper_bound: 42 + var_index: [ 0, 1 ] + coefficient: [ 1, 1 ] + } + objective_offset: -4 + quadratic_objective { + qvar1_index: [ 0 ] + qvar2_index: [ 1 ] + coefficient: [ 1 ] + } + )pb"); + EXPECT_EQ(QpFromMpModelProto(proto, /*relax_integer_variables=*/false) + .status() + .code(), + absl::StatusCode::kInvalidArgument); +} + +TEST(CanFitInMpModelProto, SmallQpOk) { + // QpFromMpModelProtoTest verifies that qp is as expected. + const auto qp = QpFromMpModelProto(TestQpProto(/*maximize=*/false), + /*relax_integer_variables=*/false); + ASSERT_TRUE(qp.ok()) << qp.status(); + EXPECT_TRUE(CanFitInMpModelProto(*qp).ok()); +} + +// The ILP: +// optimize x_0 + 2 * x_1 s.t. +// x_0 + x_1 <= 1 +// -1 <= x_0 <= 2 +// -2 <= x_1 <= 3 +// x_1 integer +// This is tested for both minimization and maximization. +TEST_P(ConvertQpMpModelProtoTest, IntegerVariablesFromMpModelProto) { + const bool maximize = GetParam(); + auto proto = ParseTextOrDie( + R"pb(variable { lower_bound: -1 upper_bound: 2 objective_coefficient: 1 } + variable { + lower_bound: -2 + upper_bound: 3 + objective_coefficient: 2 + is_integer: true + } + constraint { + lower_bound: -inf + upper_bound: 1 + var_index: [ 0, 1 ] + coefficient: [ 1, 1 ] + } + )pb"); + proto.set_maximize(maximize); + EXPECT_EQ(QpFromMpModelProto(proto, /*relax_integer_variables=*/false) + .status() + .code(), + absl::StatusCode::kInvalidArgument); + const auto lp = QpFromMpModelProto(proto, /*relax_integer_variables=*/true); + ASSERT_TRUE(lp.ok()) << lp.status(); + + EXPECT_THAT(lp->constraint_lower_bounds, ElementsAre(-kInfinity)); + EXPECT_THAT(lp->constraint_upper_bounds, ElementsAre(1)); + EXPECT_THAT(lp->variable_lower_bounds, ElementsAre(-1, -2)); + EXPECT_THAT(lp->variable_upper_bounds, ElementsAre(2, 3)); + EXPECT_THAT(ToDense(lp->constraint_matrix), EigenArrayEq({{1, 1}})); + EXPECT_TRUE(lp->constraint_matrix.isCompressed()); + + double sign = maximize ? -1 : 1; + EXPECT_EQ(lp->objective_offset, 0); + EXPECT_THAT(sign * lp->objective_vector, ElementsAre(1, 2)); + EXPECT_FALSE(lp->objective_matrix.has_value()); +} + +MPModelProto TinyModelWithNames() { + return ParseTextOrDie( + R"pb(name: "problem" + variable { + name: "x_0" + lower_bound: -1 + upper_bound: 2 + objective_coefficient: 1 + } + variable { + name: "x_1" + lower_bound: -2 + upper_bound: 3 + objective_coefficient: 2 + } + constraint { + name: "c_0" + lower_bound: -inf + upper_bound: 1 + var_index: [ 0, 1 ] + coefficient: [ 1, 1 ] + } + )pb"); +} + +TEST(QpFromMpModelProtoTest, EmptyQp) { + MPModelProto proto; + const auto qp = QpFromMpModelProto(proto, /*relax_integer_variables=*/false); + ASSERT_TRUE(qp.ok()) << qp.status(); + + EXPECT_THAT(qp->constraint_lower_bounds, ElementsAre()); + EXPECT_THAT(qp->constraint_upper_bounds, ElementsAre()); + EXPECT_THAT(qp->variable_lower_bounds, ElementsAre()); + EXPECT_THAT(qp->variable_upper_bounds, ElementsAre()); + EXPECT_EQ(qp->constraint_matrix.cols(), 0); + EXPECT_EQ(qp->constraint_matrix.rows(), 0); + EXPECT_EQ(qp->objective_offset, 0); + EXPECT_EQ(qp->objective_scaling_factor, 1); + EXPECT_FALSE(qp->objective_matrix.has_value()); + EXPECT_THAT(qp->objective_vector, ElementsAre()); +} + +TEST(QpFromMpModelProtoTest, DoesNotIncludeNames) { + const auto lp = + QpFromMpModelProto(TinyModelWithNames(), /*relax_integer_variables=*/true, + /*include_names=*/false); + ASSERT_TRUE(lp.ok()) << lp.status(); + EXPECT_EQ(lp->problem_name, absl::nullopt); + EXPECT_EQ(lp->variable_names, absl::nullopt); + EXPECT_EQ(lp->constraint_names, absl::nullopt); +} + +TEST(QpFromMpModelProtoTest, IncludesNames) { + const auto lp = + QpFromMpModelProto(TinyModelWithNames(), /*relax_integer_variables=*/true, + /*include_names=*/true); + ASSERT_TRUE(lp.ok()) << lp.status(); + EXPECT_THAT(lp->problem_name, Optional(Eq("problem"))); + EXPECT_THAT(lp->variable_names, Optional(ElementsAre("x_0", "x_1"))); + EXPECT_THAT(lp->constraint_names, Optional(ElementsAre("c_0"))); +} + +INSTANTIATE_TEST_SUITE_P( + ConvertQpMpModelProtoTests, ConvertQpMpModelProtoTest, testing::Bool(), + [](const testing::TestParamInfo& + info) { + if (info.param) { + return "maximize"; + } else { + return "minimize"; + } + }); + +// A matcher for Eigen Triplets. +MATCHER_P3(IsEigenTriplet, row, col, value, + std::string(negation ? "isn't" : "is") + " the triplet " + + PrintToString(row) + "," + PrintToString(col) + "=" + + PrintToString(value)) { + return arg.row() == row && arg.col() == col && arg.value() == value; +} + +TEST(CombineRepeatedTripletsInPlace, HandlesEmptyTriplets) { + std::vector> triplets; + CombineRepeatedTripletsInPlace(triplets); + EXPECT_THAT(triplets, IsEmpty()); +} + +TEST(CombineRepeatedTripletsInPlace, CorrectForSingleTriplet) { + std::vector> triplets = {{1, 2, 3.0}}; + CombineRepeatedTripletsInPlace(triplets); + EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 3.0))); +} + +TEST(CombineRepeatedTripletsInPlace, CorrectForDistinctTriplets) { + std::vector> triplets = { + {1, 2, 3.0}, {2, 1, 1.0}, {1, 1, 0.0}}; + CombineRepeatedTripletsInPlace(triplets); + EXPECT_THAT(triplets, + ElementsAre(IsEigenTriplet(1, 2, 3.0), IsEigenTriplet(2, 1, 1.0), + IsEigenTriplet(1, 1, 0.0))); +} + +TEST(CombineRepeatedTripletsInPlace, CombinesDuplicatesAtStart) { + std::vector> triplets = { + {1, 2, 3.0}, {1, 2, -1.0}, {1, 1, 0.0}}; + CombineRepeatedTripletsInPlace(triplets); + EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 2.0), + IsEigenTriplet(1, 1, 0.0))); +} + +TEST(CombineRepeatedTripletsInPlace, CombinesDuplicatesAtEnd) { + std::vector> triplets = { + {1, 2, 3.0}, {2, 1, 1.0}, {2, 1, 1.0}}; + CombineRepeatedTripletsInPlace(triplets); + EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 3.0), + IsEigenTriplet(2, 1, 2.0))); +} + +TEST(CombineRepeatedTripletsInPlace, CombinesToSingleton) { + std::vector> triplets = { + {1, 2, 3.0}, {1, 2, 1.0}, {1, 2, 2.0}}; + CombineRepeatedTripletsInPlace(triplets); + EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 6.0))); +} + +TEST(SetEigenMatrixFromTriplets, HandlesEmptyMatrix) { + std::vector> triplets; + Eigen::SparseMatrix matrix(2, 2); + SetEigenMatrixFromTriplets(std::move(triplets), matrix); + EXPECT_THAT(ToDense(matrix), EigenArrayEq({{0, 0}, // + {0, 0}})); +} + +TEST(SetEigenMatrixFromTriplets, CorrectForTinyMatrix) { + std::vector> triplets = { + {0, 0, 1.0}, {1, 0, -1.0}, {0, 0, 0.0}, {1, 1, 1.0}, {0, 0, 1.0}}; + Eigen::SparseMatrix matrix(2, 2); + SetEigenMatrixFromTriplets(std::move(triplets), matrix); + EXPECT_THAT(ToDense(matrix), EigenArrayEq({{2, 0}, // + {-1, 1}})); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/sharded_optimization_utils.cc b/ortools/pdlp/sharded_optimization_utils.cc new file mode 100644 index 0000000000..f73f91d41b --- /dev/null +++ b/ortools/pdlp/sharded_optimization_utils.cc @@ -0,0 +1,797 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/sharded_optimization_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/algorithm/container.h" +#include "absl/random/distributions.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" + +namespace operations_research::pdlp { + +constexpr double kInfinity = std::numeric_limits::infinity(); +using ::Eigen::ColMajor; +using ::Eigen::SparseMatrix; +using ::Eigen::VectorXd; +using ::Eigen::VectorXi; + +ShardedWeightedAverage::ShardedWeightedAverage(const Sharder* sharder) + : sharder_(sharder) { + average_ = VectorXd::Zero(sharder->NumElements()); +} + +// We considered the five averaging algorithms M_* listed on the first page of +// https://www.jstor.org/stable/2286154 and the Kahan summation algorithm +// (https://en.wikipedia.org/wiki/Kahan_summation_algorithm). Of these only M_14 +// satisfies our desired property that a constant sequence is averaged without +// roundoff while requiring only a single vector be stored. We therefore use +// M_14 (actually a natural weighted generalization, see below). +void ShardedWeightedAverage::Add(const VectorXd& datapoint, double weight) { + CHECK_GE(weight, 0.0); + CHECK_EQ(datapoint.size(), average_.size()); + const double weight_ratio = weight / (sum_weights_ + weight); + sharder_->ParallelForEachShard([&](const Sharder::Shard& shard) { + shard(average_) += weight_ratio * (shard(datapoint) - shard(average_)); + }); + sum_weights_ += weight; + ++num_terms_; +} + +void ShardedWeightedAverage::Clear() { + // TODO(user): There may be a performance gain from using the sharder to + // zero-out the vectors. + average_.setZero(); + sum_weights_ = 0.0; + num_terms_ = 0; +} + +VectorXd ShardedWeightedAverage::ComputeAverage() const { + VectorXd result; + // TODO(user): consider returning a reference to avoid this copy. + AssignVector(average_, *sharder_, result); + return result; +} + +namespace { + +double CombineBounds(const double v1, const double v2, + const double infinite_bound_threshold) { + double max = 0.0; + if (std::abs(v1) < infinite_bound_threshold) { + max = std::abs(v1); + } + if (std::abs(v2) < infinite_bound_threshold) { + max = std::max(max, std::abs(v2)); + } + return max; +} + +struct VectorInfo { + int64_t num_finite = 0; + int64_t num_nonzero = 0; + double largest = 0.0; + double smallest = 0.0; + double average = 0.0; + double l2_norm = 0.0; +}; + +struct InfNormInfo { + double max_col_norm; + double min_col_norm; + double max_row_norm; + double min_row_norm; +}; + +// The functions below are used to generate default values for +// QuadraticProgramStats when the underlying program is empty or has no +// constraints. + +double MaxOrZero(const VectorXd& vec) { + if (vec.size() == 0) { + return 0.0; + } else if (std::isinf(vec.maxCoeff())) { + return 0.0; + } else { + return vec.maxCoeff(); + } +} + +double MinOrZero(const VectorXd& vec) { + if (vec.size() == 0) { + return 0.0; + } else if (std::isinf(vec.minCoeff())) { + return 0.0; + } else { + return vec.minCoeff(); + } +} + +VectorInfo ComputeVectorInfo(const Eigen::Ref& vec, + const Sharder& sharder) { + VectorXd local_max(sharder.NumShards()); + VectorXd local_min(sharder.NumShards()); + VectorXd local_sum(sharder.NumShards()); + VectorXd local_sum_squared(sharder.NumShards()); + std::vector local_num_nonzero(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const VectorXd shard_abs = shard(vec).cwiseAbs(); + local_max[shard.Index()] = shard_abs.maxCoeff(); + local_min[shard.Index()] = shard_abs.minCoeff(); + local_sum[shard.Index()] = shard_abs.sum(); + local_sum_squared[shard.Index()] = shard_abs.squaredNorm(); + for (double element : shard_abs) { + if (element != 0) { + local_num_nonzero[shard.Index()] += 1; + } + } + }); + const int64_t num_elements = vec.size(); + return VectorInfo{ + .num_finite = num_elements, + .num_nonzero = std::accumulate(local_num_nonzero.begin(), + local_num_nonzero.end(), int64_t{0}), + .largest = MaxOrZero(local_max), + .smallest = MinOrZero(local_min), + .average = (num_elements > 0) ? local_sum.sum() / num_elements : NAN, + .l2_norm = (num_elements > 0) ? std::sqrt(local_sum_squared.sum()) : 0.0}; +} + +VectorInfo VariableBoundGapInfo(const VectorXd& lower_bounds, + const VectorXd& upper_bounds, + const Sharder& sharder) { + VectorXd local_max(sharder.NumShards()); + VectorXd local_min(sharder.NumShards()); + VectorXd local_sum(sharder.NumShards()); + std::vector local_num_finite(sharder.NumShards()); + std::vector local_num_nonzero(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const auto gap_shard = shard(upper_bounds) - shard(lower_bounds); + double max = -kInfinity; + double min = kInfinity; + double sum = 0.0; + int64_t num_finite = 0; + int64_t num_nonzero = 0; + for (int64_t i = 0; i < gap_shard.size(); ++i) { + if (std::isfinite(gap_shard[i])) { + max = std::max(max, gap_shard[i]); + min = std::min(min, gap_shard[i]); + sum += gap_shard[i]; + num_finite += 1; + if (gap_shard[i] != 0) { + num_nonzero += 1; + } + } + } + local_max[shard.Index()] = max; + local_min[shard.Index()] = min; + local_sum[shard.Index()] = sum; + local_num_finite[shard.Index()] = num_finite; + local_num_nonzero[shard.Index()] = num_nonzero; + }); + // If an empty model was given, local_sum could be an empty vector, + // in which case calling .sum() directly would crash. + const int64_t num_finite = std::accumulate( + local_num_finite.begin(), local_num_finite.end(), int64_t{0}); + return VectorInfo{ + .num_finite = num_finite, + .num_nonzero = std::accumulate(local_num_nonzero.begin(), + local_num_nonzero.end(), int64_t{0}), + .largest = MaxOrZero(local_max), + .smallest = MinOrZero(local_min), + .average = (num_finite > 0) ? local_sum.sum() / num_finite : NAN}; +} + +VectorInfo MatrixAbsElementInfo( + const SparseMatrix& matrix, + const Sharder& sharder) { + VectorXd local_max(sharder.NumShards()); + VectorXd local_min(sharder.NumShards()); + VectorXd local_sum(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const auto matrix_shard = shard(matrix); + double max = -kInfinity; + double min = kInfinity; + double sum = 0.0; + for (int64_t col_idx = 0; col_idx < matrix_shard.outerSize(); ++col_idx) { + for (decltype(matrix_shard)::InnerIterator it(matrix_shard, col_idx); it; + ++it) { + max = std::max(max, std::abs(it.value())); + min = std::min(min, std::abs(it.value())); + sum += std::abs(it.value()); + } + } + local_max[shard.Index()] = max; + local_min[shard.Index()] = min; + local_sum[shard.Index()] = sum; + }); + const int64_t num_nonzeros = matrix.nonZeros(); + return VectorInfo{ + .num_finite = num_nonzeros, + .largest = MaxOrZero(local_max), + .smallest = MinOrZero(local_min), + .average = (num_nonzeros > 0) ? local_sum.sum() / num_nonzeros : NAN}; +} + +VectorInfo CombinedBoundsInfo(const VectorXd& rhs_upper_bounds, + const VectorXd& rhs_lower_bounds, + const Sharder& sharder, + const double infinite_bound_threshold = + std::numeric_limits::infinity()) { + VectorXd local_max(sharder.NumShards()); + VectorXd local_min(sharder.NumShards()); + VectorXd local_sum(sharder.NumShards()); + VectorXd local_sum_squared(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const auto lb_shard = shard(rhs_lower_bounds); + const auto ub_shard = shard(rhs_upper_bounds); + double max = -kInfinity; + double min = kInfinity; + double sum = 0.0; + double sum_squared = 0.0; + for (int64_t i = 0; i < lb_shard.size(); ++i) { + const double combined = + CombineBounds(ub_shard[i], lb_shard[i], infinite_bound_threshold); + max = std::max(max, combined); + min = std::min(min, combined); + sum += combined; + sum_squared += combined * combined; + } + local_max[shard.Index()] = max; + local_min[shard.Index()] = min; + local_sum[shard.Index()] = sum; + local_sum_squared[shard.Index()] = sum_squared; + }); + const int num_constraints = rhs_lower_bounds.size(); + return VectorInfo{ + .num_finite = num_constraints, + .largest = MaxOrZero(local_max), + .smallest = MinOrZero(local_min), + .average = + (num_constraints > 0) ? local_sum.sum() / num_constraints : NAN, + .l2_norm = + (num_constraints > 0) ? std::sqrt(local_sum_squared.sum()) : 0.0}; +} + +InfNormInfo ConstraintMatrixRowColInfo( + const SparseMatrix& constraint_matrix, + const SparseMatrix& constraint_matrix_transpose, + const Sharder& matrix_sharder, const Sharder& matrix_transpose_sharder) { + VectorXd col_norms = ScaledColLInfNorm( + constraint_matrix, + /*row_scaling_vec=*/VectorXd::Ones(constraint_matrix.rows()), + /*col_scaling_vec=*/VectorXd::Ones(constraint_matrix.cols()), + matrix_sharder); + VectorXd row_norms = + ScaledColLInfNorm(constraint_matrix_transpose, + VectorXd::Ones(constraint_matrix_transpose.rows()), + VectorXd::Ones(constraint_matrix_transpose.cols()), + matrix_transpose_sharder); + return InfNormInfo{.max_col_norm = MaxOrZero(col_norms), + .min_col_norm = MinOrZero(col_norms), + .max_row_norm = MaxOrZero(row_norms), + .min_row_norm = MinOrZero(row_norms)}; +} +} // namespace + +QuadraticProgramStats ComputeStats( + const ShardedQuadraticProgram& qp, + const double infinite_constraint_bound_threshold) { + // Caution: if the constraint matrix is empty, elementwise operations + // (like .coeffs().maxCoeff() or .minCoeff()) will fail. + InfNormInfo cons_matrix_norm_info = ConstraintMatrixRowColInfo( + qp.Qp().constraint_matrix, qp.TransposedConstraintMatrix(), + qp.ConstraintMatrixSharder(), qp.TransposedConstraintMatrixSharder()); + VectorInfo cons_matrix_info = MatrixAbsElementInfo( + qp.Qp().constraint_matrix, qp.ConstraintMatrixSharder()); + VectorInfo combined_bounds_info = CombinedBoundsInfo( + qp.Qp().constraint_lower_bounds, qp.Qp().constraint_upper_bounds, + qp.DualSharder(), infinite_constraint_bound_threshold); + VectorInfo obj_vec_info = + ComputeVectorInfo(qp.Qp().objective_vector, qp.PrimalSharder()); + VectorInfo gaps_info = + VariableBoundGapInfo(qp.Qp().variable_lower_bounds, + qp.Qp().variable_upper_bounds, qp.PrimalSharder()); + QuadraticProgramStats program_stats; + program_stats.set_num_variables(qp.PrimalSize()); + program_stats.set_num_constraints(qp.DualSize()); + program_stats.set_constraint_matrix_col_min_l_inf_norm( + cons_matrix_norm_info.min_col_norm); + program_stats.set_constraint_matrix_row_min_l_inf_norm( + cons_matrix_norm_info.min_row_norm); + program_stats.set_constraint_matrix_num_nonzeros(cons_matrix_info.num_finite); + program_stats.set_constraint_matrix_abs_max(cons_matrix_info.largest); + program_stats.set_constraint_matrix_abs_min(cons_matrix_info.smallest); + program_stats.set_constraint_matrix_abs_avg(cons_matrix_info.average); + program_stats.set_combined_bounds_max(combined_bounds_info.largest); + program_stats.set_combined_bounds_min(combined_bounds_info.smallest); + program_stats.set_combined_bounds_avg(combined_bounds_info.average); + program_stats.set_combined_bounds_l2_norm(combined_bounds_info.l2_norm); + program_stats.set_variable_bound_gaps_num_finite(gaps_info.num_finite); + program_stats.set_variable_bound_gaps_max(gaps_info.largest); + program_stats.set_variable_bound_gaps_min(gaps_info.smallest); + program_stats.set_variable_bound_gaps_avg(gaps_info.average); + program_stats.set_objective_vector_abs_max(obj_vec_info.largest); + program_stats.set_objective_vector_abs_min(obj_vec_info.smallest); + program_stats.set_objective_vector_abs_avg(obj_vec_info.average); + program_stats.set_objective_vector_l2_norm(obj_vec_info.l2_norm); + if (IsLinearProgram(qp.Qp())) { + program_stats.set_objective_matrix_num_nonzeros(0); + program_stats.set_objective_matrix_abs_max(0); + program_stats.set_objective_matrix_abs_min(0); + program_stats.set_objective_matrix_abs_avg(NAN); + } else { + VectorInfo obj_matrix_info = ComputeVectorInfo( + qp.Qp().objective_matrix->diagonal(), qp.PrimalSharder()); + program_stats.set_objective_matrix_num_nonzeros( + obj_matrix_info.num_nonzero); + program_stats.set_objective_matrix_abs_max(obj_matrix_info.largest); + program_stats.set_objective_matrix_abs_min(obj_matrix_info.smallest); + program_stats.set_objective_matrix_abs_avg(obj_matrix_info.average); + } + return program_stats; +} + +namespace { + +enum class ScalingNorm { kL2, kLInf }; + +// Divides the vector (componentwise) by the square root of the divisor, +// updating the vector in-place. If a component of the divisor is equal to zero, +// leaves the component of the vector unchanged. The Sharder should have the +// same size as the vector. For best performance the Sharder should have been +// created with the Sharder(int64_t, int, ThreadPool*) constructor. +void DivideBySquareRootOfDivisor(const VectorXd& divisor, + const Sharder& sharder, VectorXd& vector) { + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + auto vec_shard = shard(vector); + auto divisor_shard = shard(divisor); + for (int64_t index = 0; index < vec_shard.size(); ++index) { + if (divisor_shard[index] != 0) { + vec_shard[index] /= std::sqrt(divisor_shard[index]); + } + } + }); +} + +void ApplyScalingIterationsForNorm(const ShardedQuadraticProgram& sharded_qp, + const int num_iterations, + const ScalingNorm norm, + VectorXd& row_scaling_vec, + VectorXd& col_scaling_vec) { + const QuadraticProgram& qp = sharded_qp.Qp(); + const int64_t num_col = qp.constraint_matrix.cols(); + const int64_t num_row = qp.constraint_matrix.rows(); + CHECK_EQ(num_col, col_scaling_vec.size()); + CHECK_EQ(num_row, row_scaling_vec.size()); + for (int i = 0; i < num_iterations; ++i) { + VectorXd col_norm; + VectorXd row_norm; + switch (norm) { + case ScalingNorm::kL2: { + col_norm = ScaledColL2Norm(qp.constraint_matrix, row_scaling_vec, + col_scaling_vec, + sharded_qp.ConstraintMatrixSharder()); + row_norm = ScaledColL2Norm( + sharded_qp.TransposedConstraintMatrix(), col_scaling_vec, + row_scaling_vec, sharded_qp.TransposedConstraintMatrixSharder()); + break; + } + case ScalingNorm::kLInf: { + col_norm = ScaledColLInfNorm(qp.constraint_matrix, row_scaling_vec, + col_scaling_vec, + sharded_qp.ConstraintMatrixSharder()); + row_norm = ScaledColLInfNorm( + sharded_qp.TransposedConstraintMatrix(), col_scaling_vec, + row_scaling_vec, sharded_qp.TransposedConstraintMatrixSharder()); + break; + } + } + DivideBySquareRootOfDivisor(col_norm, sharded_qp.PrimalSharder(), + col_scaling_vec); + DivideBySquareRootOfDivisor(row_norm, sharded_qp.DualSharder(), + row_scaling_vec); + } +} + +} // namespace + +void LInfRuizRescaling(const ShardedQuadraticProgram& sharded_qp, + const int num_iterations, VectorXd& row_scaling_vec, + VectorXd& col_scaling_vec) { + ApplyScalingIterationsForNorm(sharded_qp, num_iterations, ScalingNorm::kLInf, + row_scaling_vec, col_scaling_vec); +} + +void L2NormRescaling(const ShardedQuadraticProgram& sharded_qp, + VectorXd& row_scaling_vec, VectorXd& col_scaling_vec) { + ApplyScalingIterationsForNorm(sharded_qp, /*num_iterations=*/1, + ScalingNorm::kL2, row_scaling_vec, + col_scaling_vec); +} + +ScalingVectors ApplyRescaling(const RescalingOptions& rescaling_options, + ShardedQuadraticProgram& sharded_qp) { + ScalingVectors scaling{ + .row_scaling_vec = VectorXd::Ones(sharded_qp.DualSize()), + .col_scaling_vec = VectorXd::Ones(sharded_qp.PrimalSize())}; + bool do_rescale = false; + if (rescaling_options.l_inf_ruiz_iterations > 0) { + do_rescale = true; + LInfRuizRescaling(sharded_qp, rescaling_options.l_inf_ruiz_iterations, + scaling.row_scaling_vec, scaling.col_scaling_vec); + } + if (rescaling_options.l2_norm_rescaling) { + do_rescale = true; + L2NormRescaling(sharded_qp, scaling.row_scaling_vec, + scaling.col_scaling_vec); + } + if (do_rescale) { + sharded_qp.RescaleQuadraticProgram(scaling.col_scaling_vec, + scaling.row_scaling_vec); + } + return scaling; +} + +LagrangianPart ComputePrimalGradient(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution, + const VectorXd& dual_product) { + LagrangianPart result{.gradient = VectorXd(sharded_qp.PrimalSize())}; + const QuadraticProgram& qp = sharded_qp.Qp(); + VectorXd value_parts(sharded_qp.PrimalSharder().NumShards()); + sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + if (IsLinearProgram(qp)) { + shard(result.gradient) = + shard(qp.objective_vector) - shard(dual_product); + value_parts[shard.Index()] = + shard(primal_solution).dot(shard(result.gradient)); + } else { + // Note: using auto instead of VectorXd for the type of + // objective_product causes eigen to defer the matrix product until it + // is used (twice). + const VectorXd objective_product = + shard(*qp.objective_matrix) * shard(primal_solution); + shard(result.gradient) = shard(qp.objective_vector) + + objective_product - shard(dual_product); + value_parts[shard.Index()] = + shard(primal_solution) + .dot(shard(result.gradient) - 0.5 * objective_product); + } + }); + result.value = value_parts.sum(); + return result; +} + +namespace { + +// Returns a subderivative of the concave dual penalty function that appears in +// the Lagrangian: -p(dual; -constraint_upper_bound, -constraint_lower_bound) = +// { constraint_upper_bound * dual when dual < 0, 0 when dual == 0, and +// constraint_lower_bound * dual when dual > 0} +// (as defined at https://developers.google.com/optimization/lp/pdlp_math). +// The subderivative is not necessarily unique when dual == 0. In this case, if +// only one of the bounds is finite, we return that one. If both are finite, we +// return the primal product projected onto the bounds, which causes the dual +// Lagrangian gradient to be zero when the constraint is not violated. If both +// are infinite, we return zero. The value returned is valid only when the +// function is finite-valued. +double DualSubgradientCoefficient(const double constraint_lower_bound, + const double constraint_upper_bound, + const double dual, + const double primal_product) { + if (dual < 0.0) { + return constraint_upper_bound; + } else if (dual > 0.0) { + return constraint_lower_bound; + } else if (std::isfinite(constraint_lower_bound) && + std::isfinite(constraint_upper_bound)) { + if (primal_product < constraint_lower_bound) { + return constraint_lower_bound; + } else if (primal_product > constraint_upper_bound) { + return constraint_upper_bound; + } else { + return primal_product; + } + } else if (std::isfinite(constraint_lower_bound)) { + return constraint_lower_bound; + } else if (std::isfinite(constraint_upper_bound)) { + return constraint_upper_bound; + } else { + return 0.0; + } +} + +} // namespace + +LagrangianPart ComputeDualGradient(const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& dual_solution, + const Eigen::VectorXd& primal_product) { + LagrangianPart result{.gradient = VectorXd(sharded_qp.DualSize())}; + const QuadraticProgram& qp = sharded_qp.Qp(); + VectorXd value_parts(sharded_qp.DualSharder().NumShards()); + sharded_qp.DualSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + auto constraint_lower_bounds = shard(qp.constraint_lower_bounds); + auto constraint_upper_bounds = shard(qp.constraint_upper_bounds); + auto dual_solution_shard = shard(dual_solution); + auto dual_gradient_shard = shard(result.gradient); + auto primal_product_shard = shard(primal_product); + double value_sum = 0.0; + for (int64_t i = 0; i < dual_gradient_shard.size(); ++i) { + dual_gradient_shard[i] = DualSubgradientCoefficient( + constraint_lower_bounds[i], constraint_upper_bounds[i], + dual_solution_shard[i], primal_product_shard[i]); + value_sum += dual_gradient_shard[i] * dual_solution_shard[i]; + } + value_parts[shard.Index()] = value_sum; + dual_gradient_shard -= primal_product_shard; + }); + result.value = value_parts.sum(); + return result; +} + +namespace { + +using ::Eigen::ColMajor; +using ::Eigen::SparseMatrix; + +// Scales a vector (in-place) to have norm 1, unless it has norm 0 (in which +// case it is left unscaled). Returns the norm of the input vector. +double NormalizeVector(const Sharder& sharder, VectorXd& vector) { + const double norm = Norm(vector, sharder); + if (norm != 0.0) { + sharder.ParallelForEachShard( + [&](const Sharder::Shard& shard) { shard(vector) /= norm; }); + } + return norm; +} + +// Estimates the probability that the power method, after k iterations, has +// relative error > epsilon. This is based on Theorem 4.1(a) (on page 13) from +// "Estimating the Largest Eigenvalue by the Power and Lanczos Algorithms with a +// Random Start" +// https://pdfs.semanticscholar.org/2b2e/a941e55e5fa2ee9d8f4ff393c14482051143.pdf +double PowerMethodFailureProbability(int64_t dimension, double epsilon, int k) { + if (k < 2 || epsilon <= 0.0) { + // The theorem requires epsilon > 0 and k >= 2. + return 1.0; + } + return std::min(0.824, 0.354 / (epsilon * (k - 1))) * std::sqrt(dimension) * + std::pow(1.0 - epsilon, k - 0.5); +} + +SingularValueAndIterations EstimateMaximumSingularValue( + const SparseMatrix& matrix, + const SparseMatrix& matrix_transpose, + const absl::optional& active_set_indicator, + const absl::optional& transpose_active_set_indicator, + const Sharder& matrix_sharder, const Sharder& matrix_transpose_sharder, + const Sharder& primal_vector_sharder, const Sharder& dual_vector_sharder, + const double desired_relative_error, const double failure_probability, + std::mt19937& mt_generator) { + // Easy case: matrix is diagonal. + if (IsDiagonal(matrix, matrix_sharder)) { + VectorXd local_max(matrix_sharder.NumShards()); + matrix_sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const auto matrix_shard = shard(matrix); + local_max[shard.Index()] = + (matrix_shard * + VectorXd::Ones(matrix_sharder.ShardSize(shard.Index()))) + .lpNorm(); + }); + return {.singular_value = local_max.lpNorm(), + .num_iterations = 0, + .estimated_relative_error = 0.0}; + } + const int64_t dimension = matrix.cols(); + VectorXd eigenvector(dimension); + // Even though it will be slower, we initialize eigenvector sequentially so + // that the result doesn't depend on the number of threads. + for (double& entry : eigenvector) { + entry = absl::Gaussian(mt_generator); + } + if (active_set_indicator.has_value()) { + CoefficientWiseProductInPlace(*active_set_indicator, primal_vector_sharder, + eigenvector); + } + NormalizeVector(primal_vector_sharder, eigenvector); + double eigenvalue_estimate = 0.0; + + int num_iterations = 0; + // The maximum singular value of A is the square root of the maximum + // eigenvalue of A^T A. epsilon is the relative error needed for the maximum + // eigenvalue of A^T A that gives desired_relative_error for the maximum + // singular value of A. + const double epsilon = 1.0 - MathUtil::Square(1.0 - desired_relative_error); + while (PowerMethodFailureProbability(dimension, epsilon, num_iterations) > + failure_probability) { + VectorXd dual_eigenvector = TransposedMatrixVectorProduct( + matrix_transpose, eigenvector, matrix_transpose_sharder); + if (transpose_active_set_indicator.has_value()) { + CoefficientWiseProductInPlace(*transpose_active_set_indicator, + dual_vector_sharder, dual_eigenvector); + } + VectorXd next_eigenvector = + TransposedMatrixVectorProduct(matrix, dual_eigenvector, matrix_sharder); + if (active_set_indicator.has_value()) { + CoefficientWiseProductInPlace(*active_set_indicator, + primal_vector_sharder, next_eigenvector); + } + eigenvalue_estimate = + Dot(eigenvector, next_eigenvector, primal_vector_sharder); + eigenvector = std::move(next_eigenvector); + ++num_iterations; + const double primal_norm = + NormalizeVector(primal_vector_sharder, eigenvector); + + VLOG(1) << "Iteration " << num_iterations << " singular value estimate " + << std::sqrt(eigenvalue_estimate) << " primal norm " << primal_norm; + } + return SingularValueAndIterations{ + .singular_value = std::sqrt(eigenvalue_estimate), + .num_iterations = num_iterations, + .estimated_relative_error = desired_relative_error}; +} + +// Given a primal solution, compute a {0, 1}-valued vector that is nonzero in +// all the coordinates that are not saturating the primal variable bounds. +VectorXd ComputePrimalActiveSetIndicator( + const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution) { + VectorXd indicator(sharded_qp.PrimalSize()); + sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const auto lower_bound_shard = + shard(sharded_qp.Qp().variable_lower_bounds); + const auto upper_bound_shard = + shard(sharded_qp.Qp().variable_upper_bounds); + const auto primal_solution_shard = shard(primal_solution); + auto indicator_shard = shard(indicator); + const int64_t shard_size = + sharded_qp.PrimalSharder().ShardSize(shard.Index()); + for (int64_t i = 0; i < shard_size; ++i) { + if ((primal_solution_shard[i] == lower_bound_shard[i]) || + (primal_solution_shard[i] == upper_bound_shard[i])) { + indicator_shard[i] = 0.0; + } else { + indicator_shard[i] = 1.0; + } + } + }); + return indicator; +} + +// Like ComputePrimalActiveSetIndicator(sharded_qp, primal_solution), but this +// time using the implicit bounds on the dual variable. +VectorXd ComputeDualActiveSetIndicator( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& dual_solution) { + VectorXd indicator(sharded_qp.DualSize()); + sharded_qp.DualSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const auto lower_bound_shard = + shard(sharded_qp.Qp().constraint_lower_bounds); + const auto upper_bound_shard = + shard(sharded_qp.Qp().constraint_upper_bounds); + const auto dual_solution_shard = shard(dual_solution); + auto indicator_shard = shard(indicator); + const int64_t shard_size = + sharded_qp.DualSharder().ShardSize(shard.Index()); + for (int64_t i = 0; i < shard_size; ++i) { + if (dual_solution_shard[i] == 0.0 && + (std::isinf(lower_bound_shard[i]) || + std::isinf(upper_bound_shard[i]))) { + indicator_shard[i] = 0.0; + } else { + indicator_shard[i] = 1.0; + } + } + }); + return indicator; +} + +} // namespace + +SingularValueAndIterations EstimateMaximumSingularValueOfConstraintMatrix( + const ShardedQuadraticProgram& sharded_qp, + const absl::optional& primal_solution, + const absl::optional& dual_solution, + const double desired_relative_error, const double failure_probability, + std::mt19937& mt_generator) { + absl::optional primal_active_set_indicator; + absl::optional dual_active_set_indicator; + if (primal_solution.has_value()) { + primal_active_set_indicator = + ComputePrimalActiveSetIndicator(sharded_qp, *primal_solution); + } + if (dual_solution.has_value()) { + dual_active_set_indicator = + ComputeDualActiveSetIndicator(sharded_qp, *dual_solution); + } + return EstimateMaximumSingularValue( + sharded_qp.Qp().constraint_matrix, + sharded_qp.TransposedConstraintMatrix(), primal_active_set_indicator, + dual_active_set_indicator, sharded_qp.ConstraintMatrixSharder(), + sharded_qp.TransposedConstraintMatrixSharder(), + sharded_qp.PrimalSharder(), sharded_qp.DualSharder(), + desired_relative_error, failure_probability, mt_generator); +} + +bool HasValidBounds(const ShardedQuadraticProgram& sharded_qp) { + const QuadraticProgram& qp = sharded_qp.Qp(); + const bool constraint_bounds_valid = + sharded_qp.DualSharder().ParallelTrueForAllShards( + [&](const Sharder::Shard& shard) { + return (shard(qp.constraint_lower_bounds).array() <= + shard(qp.constraint_upper_bounds).array()) + .all(); + }); + const bool variable_bounds_valid = + sharded_qp.PrimalSharder().ParallelTrueForAllShards( + [&](const Sharder::Shard& shard) { + return (shard(qp.variable_lower_bounds).array() <= + shard(qp.variable_upper_bounds).array()) + .all(); + }); + + return constraint_bounds_valid && variable_bounds_valid; +} + +void ProjectToPrimalVariableBounds(const ShardedQuadraticProgram& sharded_qp, + VectorXd& primal) { + sharded_qp.PrimalSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const QuadraticProgram& qp = sharded_qp.Qp(); + shard(primal) = shard(primal) + .cwiseMin(shard(qp.variable_upper_bounds)) + .cwiseMax(shard(qp.variable_lower_bounds)); + }); +} + +void ProjectToDualVariableBounds(const ShardedQuadraticProgram& sharded_qp, + VectorXd& dual) { + const QuadraticProgram& qp = sharded_qp.Qp(); + sharded_qp.DualSharder().ParallelForEachShard( + [&](const Sharder::Shard& shard) { + const auto lower_bound_shard = shard(qp.constraint_lower_bounds); + const auto upper_bound_shard = shard(qp.constraint_upper_bounds); + auto dual_shard = shard(dual); + + for (int64_t i = 0; i < dual_shard.size(); ++i) { + if (!std::isfinite(upper_bound_shard[i])) { + dual_shard[i] = std::max(dual_shard[i], 0.0); + } + if (!std::isfinite(lower_bound_shard[i])) { + dual_shard[i] = std::min(dual_shard[i], 0.0); + } + } + }); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/sharded_optimization_utils.h b/ortools/pdlp/sharded_optimization_utils.h new file mode 100644 index 0000000000..09384bcbe8 --- /dev/null +++ b/ortools/pdlp/sharded_optimization_utils.h @@ -0,0 +1,191 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// These are internal helper functions and classes that implicitly or explicitly +// operate on a ShardedQuadraticProgram. Utilities that are purely linear +// algebra operations (e.g., norms) should be defined in sharder.h instead. + +#ifndef PDLP_SHARDED_OPTIMIZATION_UTILS_H_ +#define PDLP_SHARDED_OPTIMIZATION_UTILS_H_ + +#include +#include + +#include "Eigen/Core" +#include "absl/types/optional.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" +#include "ortools/pdlp/solve_log.pb.h" + +namespace operations_research::pdlp { + +// This computes weighted averages of vectors. +// It satisfies the following: if all the averaged vectors have component i +// equal to x then the average has component i exactly equal to x, without any +// floating-point roundoff. In practice the above is probably still true with +// "equal to x" replaced with "at least x" or "at most x". However unrealistic +// counter examples probably exist involving a new item with weight 10^15 times +// greater than the total weight so far. +class ShardedWeightedAverage { + public: + // Initializes the weighted average by creating a vector sized according to + // the number of elements in the sharder. Retains the pointer to sharder, so + // the sharder must outlive this object. + explicit ShardedWeightedAverage(const Sharder* sharder); + + ShardedWeightedAverage(ShardedWeightedAverage&&) = default; + ShardedWeightedAverage& operator=(ShardedWeightedAverage&&) = default; + + // Adds the datapoint to the average with the given weight. CHECK-fails if + // the weight is negative. + void Add(const Eigen::VectorXd& datapoint, double weight); + + // Clears the sum to zero, i.e., just constructed. + void Clear(); + + // Returns true if there is at least one term in the average with a positive + // weight. + bool HasNonzeroWeight() const { return sum_weights_ > 0.0; } + + // Returns the sum of the weights of the datapoints added so far. + double Weight() const { return sum_weights_; } + + // Computes the weighted average of the datapoints added so far, i.e., + // sum_i weight[i] * datapoint[i] / sum_i weight[i]. The results are set to + // zero if HasNonzeroWeight() is false. + Eigen::VectorXd ComputeAverage() const; + + int NumTerms() const { return num_terms_; } + + private: + Eigen::VectorXd average_; + double sum_weights_ = 0.0; + int num_terms_ = 0; + const Sharder* sharder_; +}; + +// Returns a QuadraticProgramStats object for a ShardedQuadraticProgram. +QuadraticProgramStats ComputeStats(const ShardedQuadraticProgram& qp, + double infinite_constraint_bound_threshold = + std::numeric_limits::infinity()); + +// LInfRuizRescaling and L2NormRescaling rescale the (scaled) constraint matrix +// of the LP by updating the scaling vectors in-place. More specifically, the +// (scaled) constraint matrix always has the format: diag(row_scaling_vec) * +// sharded_qp.Qp().constraint_matrix * diag(col_scaling_vec), and +// row_scaling_vec and col_scaling_vec are updated in-place. On input, +// row_scaling_vec and col_scaling_vec provide the initial scaling. + +// With each iteration of LInfRuizRescaling scaling, row_scaling_vec +// (col_scaling_vec) is divided by the sqrt of the row (col) LInf norm of the +// current (scaled) constraint matrix. The (scaled) constraint matrix approaches +// having all row and column LInf norms equal to 1 as the number of iterations +// goes to infinity. This convergence is fast (linear). More details of Ruiz +// rescaling algorithm can be found at: +// http://www.numerical.rl.ac.uk/reports/drRAL2001034.pdf. +void LInfRuizRescaling(const ShardedQuadraticProgram& sharded_qp, + const int num_iterations, + Eigen::VectorXd& row_scaling_vec, + Eigen::VectorXd& col_scaling_vec); + +// L2NormRescaling divides row_scaling_vec (col_scaling_vec) by the sqrt of the +// row (col) L2 norm of the current (scaled) constraint matrix. Unlike +// LInfRescaling, this function does only one iteration because the scaling +// procedure does not converge in general. This is not Ruiz rescaling for the L2 +// norm. +void L2NormRescaling(const ShardedQuadraticProgram& sharded_qp, + Eigen::VectorXd& row_scaling_vec, + Eigen::VectorXd& col_scaling_vec); + +struct RescalingOptions { + int l_inf_ruiz_iterations; + bool l2_norm_rescaling; +}; + +struct ScalingVectors { + Eigen::VectorXd row_scaling_vec; + Eigen::VectorXd col_scaling_vec; +}; + +// Applies the rescaling specified by rescaling_options to sharded_qp (in +// place). Returns the scaling vectors that were applied. +ScalingVectors ApplyRescaling(const RescalingOptions& rescaling_options, + ShardedQuadraticProgram& sharded_qp); + +struct LagrangianPart { + double value = 0.0; + Eigen::VectorXd gradient; +}; + +// Computes the value of the primal part of the Lagrangian function defined at +// https://developers.google.com/optimization/lp/pdlp_math, i.e., +// c'x + (1/2) x'Qx - y'Ax and its gradient with respect to the primal variables +// x, i.e., c + Qx - A'y. The dual_product argument is A'y. Note: The objective +// constant is omitted. The result is undefined and invalid if any primal bounds +// are violated. +LagrangianPart ComputePrimalGradient(const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_product); + +// Computes the value of the dual part of the Lagrangian function defined at +// https://developers.google.com/optimization/lp/pdlp_math, i.e., -h^*(y) and +// the gradient of the Lagrangian with respect to the dual variables y, i.e., +// -Ax - \grad_y h^*(y). Note the asymmetry with ComputePrimalGradient: the term +// -y'Ax is not part of the value. Because h^*(y) is piece-wise linear, a +// subgradient is returned at a point of non- smoothness. The primal_product +// argument is Ax. The result is undefined and invalid if any duals violate +// their bounds. +LagrangianPart ComputeDualGradient(const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& dual_solution, + const Eigen::VectorXd& primal_product); + +struct SingularValueAndIterations { + double singular_value; + int num_iterations; + double estimated_relative_error; +}; + +// Estimates the maximum singular value of A by applying the method of power +// iteration to A^T A. If a primal and/or dual solution is provided, restricts +// to the "active" part of A, that is, the columns (rows) for variables that are +// not at their bounds in the solution. The estimate will have +// desired_relative_error with probability at least 1 - failure_probability. +// The number of iterations will be approximately +// log(primal_size / failure_probability^2)/(2 * desired_relative_error). +// Uses a mersenne-twister portable random number generator to generate the +// starting point for the power method, in order to have deterministic results. +SingularValueAndIterations EstimateMaximumSingularValueOfConstraintMatrix( + const ShardedQuadraticProgram& sharded_qp, + const absl::optional& primal_solution, + const absl::optional& dual_solution, + const double desired_relative_error, const double failure_probability, + std::mt19937& mt_generator); + +// Checks if the lower and upper bounds of the problem are consistent, i.e. for +// each variable and constraint bound we have lower_bound <= upper_bound. If +// the input is consistent the method returns true, otherwise it returns false. +// See also HasValidBounds(const QuadraticProgram&). +bool HasValidBounds(const ShardedQuadraticProgram& sharded_qp); + +// Projects a primal vector onto the variable bounds constraints. +void ProjectToPrimalVariableBounds(const ShardedQuadraticProgram& sharded_qp, + Eigen::VectorXd& primal); + +// Projects the dual vector to the dual variable bounds; see +// https://developers.google.com/optimization/lp/pdlp_math#dual_variable_bounds. +void ProjectToDualVariableBounds(const ShardedQuadraticProgram& sharded_qp, + Eigen::VectorXd& dual); + +} // namespace operations_research::pdlp + +#endif // PDLP_SHARDED_OPTIMIZATION_UTILS_H_ diff --git a/ortools/pdlp/sharded_optimization_utils_test.cc b/ortools/pdlp/sharded_optimization_utils_test.cc new file mode 100644 index 0000000000..caac8b670a --- /dev/null +++ b/ortools/pdlp/sharded_optimization_utils_test.cc @@ -0,0 +1,616 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/sharded_optimization_utils.h" + +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/types/optional.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/test_util.h" + +namespace operations_research::pdlp { +namespace { + +using ::Eigen::VectorXd; +using ::testing::ElementsAre; +using ::testing::ElementsAreArray; +using ::testing::IsNan; + +TEST(ShardedWeightedAverageTest, SimpleAverage) { + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, + /*thread_pool=*/nullptr); + Eigen::VectorXd vec1(2), vec2(2); + vec1 << 4, 1; + vec2 << 1, 7; + + ShardedWeightedAverage average(&sharder); + average.Add(vec1, 1.0); + average.Add(vec2, 2.0); + + ASSERT_TRUE(average.HasNonzeroWeight()); + EXPECT_EQ(average.NumTerms(), 2); + + EXPECT_THAT(average.ComputeAverage(), ElementsAre(2.0, 5.0)); + + average.Clear(); + EXPECT_FALSE(average.HasNonzeroWeight()); + EXPECT_EQ(average.NumTerms(), 0); +} + +TEST(ShardedWeightedAverageTest, MoveConstruction) { + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, + /*thread_pool=*/nullptr); + Eigen::VectorXd vec(2); + vec << 4, 1; + + ShardedWeightedAverage average(&sharder); + average.Add(vec, 2.0); + + ShardedWeightedAverage average2(std::move(average)); + EXPECT_THAT(average2.ComputeAverage(), ElementsAre(4.0, 1.0)); +} + +TEST(ShardedWeightedAverageTest, MoveAssignment) { + Sharder sharder1(/*num_elements=*/2, /*num_shards=*/2, + /*thread_pool=*/nullptr); + Sharder sharder2(/*num_elements=*/3, /*num_shards=*/2, + /*thread_pool=*/nullptr); + Eigen::VectorXd vec1(2), vec2(2); + vec1 << 4, 1; + vec2 << 0, 3; + + ShardedWeightedAverage average1(&sharder1); + average1.Add(vec1, 2.0); + + ShardedWeightedAverage average2(&sharder2); + + average2 = std::move(average1); + average2.Add(vec2, 2.0); + EXPECT_THAT(average2.ComputeAverage(), ElementsAre(2.0, 2.0)); +} + +TEST(ShardedWeightedAverageTest, ZeroAverage) { + Sharder sharder(/*num_elements=*/1, /*num_shards=*/1, + /*thread_pool=*/nullptr); + + ShardedWeightedAverage average(&sharder); + ASSERT_FALSE(average.HasNonzeroWeight()); + + EXPECT_THAT(average.ComputeAverage(), ElementsAre(0.0)); +} + +// This test verifies that if we average an identical vector repeatedly the +// average is exactly that vector, with no roundoff. +TEST(ShardedWeightedAverageTest, AveragesEqualWithoutRoundoff) { + Sharder sharder(/*num_elements=*/4, /*num_shards=*/1, + /*thread_pool=*/nullptr); + ShardedWeightedAverage average(&sharder); + EXPECT_THAT(average.ComputeAverage(), ElementsAre(0, 0, 0, 0)); + VectorXd data(4); + data << 1.0, 1.0 / 3, 3.0 / 7, 3.14159; + average.Add(data, 341.45); + EXPECT_THAT(average.ComputeAverage(), ElementsAreArray(data)); + average.Add(data, 1.4134); + EXPECT_THAT(average.ComputeAverage(), ElementsAreArray(data)); + average.Add(data, 7.23); + EXPECT_THAT(average.ComputeAverage(), ElementsAreArray(data)); +} + +// The combined bounds vector for TestLp() is [12, 7, 4, 1]. +// L_inf norm: 12.0 +// L_2 norm: sqrt(210.0) ≈ 14.49 + +TEST(ProblemStatsTest, TestLp) { + ShardedQuadraticProgram lp(TestLp(), 2, 2); + const QuadraticProgramStats stats = ComputeStats(lp); + + EXPECT_EQ(stats.num_variables(), 4); + EXPECT_EQ(stats.num_constraints(), 4); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_col_min_l_inf_norm(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_row_min_l_inf_norm(), 1.0); + EXPECT_EQ(stats.constraint_matrix_num_nonzeros(), 9); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_max(), 4.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_avg(), 14.5 / 9.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_max(), 5.5); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_avg(), 2.375); + EXPECT_DOUBLE_EQ(stats.objective_vector_l2_norm(), std::sqrt(36.25)); + EXPECT_EQ(stats.objective_matrix_num_nonzeros(), 0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_min(), 0.0); + EXPECT_THAT(stats.objective_matrix_abs_avg(), IsNan()); + EXPECT_EQ(stats.variable_bound_gaps_num_finite(), 1); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_max(), 1.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_avg(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_max(), 12.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_avg(), 6.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_l2_norm(), std::sqrt(210.0)); +} + +TEST(ProblemStatsTest, TinyLp) { + ShardedQuadraticProgram lp(TinyLp(), 2, 2); + const QuadraticProgramStats stats = ComputeStats(lp); + + EXPECT_EQ(stats.num_variables(), 4); + EXPECT_EQ(stats.num_constraints(), 3); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_col_min_l_inf_norm(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_row_min_l_inf_norm(), 1.0); + EXPECT_EQ(stats.constraint_matrix_num_nonzeros(), 8); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_max(), 2.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_avg(), 1.25); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_max(), 5.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_avg(), 2.25); + EXPECT_DOUBLE_EQ(stats.objective_vector_l2_norm(), std::sqrt(31.0)); + EXPECT_EQ(stats.objective_matrix_num_nonzeros(), 0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_min(), 0.0); + EXPECT_THAT(stats.objective_matrix_abs_avg(), IsNan()); + EXPECT_EQ(stats.variable_bound_gaps_num_finite(), 4); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_max(), 6.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_min(), 2.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_avg(), 3.75); + EXPECT_DOUBLE_EQ(stats.combined_bounds_max(), 12.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_avg(), 20.0 / 3.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_l2_norm(), std::sqrt(194.0)); +} + +TEST(ProblemStatsTest, TestDiagonalQp1) { + ShardedQuadraticProgram qp(TestDiagonalQp1(), 2, 2); + const QuadraticProgramStats stats = ComputeStats(qp); + + EXPECT_EQ(stats.num_variables(), 2); + EXPECT_EQ(stats.num_constraints(), 1); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_col_min_l_inf_norm(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_row_min_l_inf_norm(), 1.0); + EXPECT_EQ(stats.constraint_matrix_num_nonzeros(), 2); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_max(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_avg(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_max(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_avg(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_l2_norm(), std::sqrt(2.0)); + EXPECT_EQ(stats.objective_matrix_num_nonzeros(), 2); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_max(), 4.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_avg(), 2.5); + EXPECT_EQ(stats.variable_bound_gaps_num_finite(), 2); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_max(), 6.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_avg(), 3.5); + EXPECT_DOUBLE_EQ(stats.combined_bounds_max(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_avg(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_l2_norm(), 1.0); +} + +TEST(ProblemStatsTest, ModifiedTestDiagonalQp1) { + QuadraticProgram orig_qp = TestDiagonalQp1(); + // A case where objective_matrix_num_nonzeros doesn't match the dimension. + orig_qp.objective_matrix->diagonal() << 2.0, 0.0; + ShardedQuadraticProgram qp(orig_qp, 2, 2); + const QuadraticProgramStats stats = ComputeStats(qp); + + EXPECT_EQ(stats.objective_matrix_num_nonzeros(), 1); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_max(), 2.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_min(), 0.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_avg(), 1.0); +} + +// This is like SmallLp, except that an infinite_bound_threshold of 10 treats +// the first bound as infinite, leaving [7, 4, 1] as the combined bounds vector. +TEST(ProblemStatsTest, TestLpWithInfiniteConstraintBoundThreshold) { + ShardedQuadraticProgram lp(TestLp(), 2, 2); + const QuadraticProgramStats stats = + ComputeStats(lp, /*infinite_constraint_bound_threshold=*/10); + + EXPECT_EQ(stats.num_variables(), 4); + EXPECT_EQ(stats.num_constraints(), 4); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_col_min_l_inf_norm(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_row_min_l_inf_norm(), 1.0); + EXPECT_EQ(stats.constraint_matrix_num_nonzeros(), 9); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_max(), 4.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_avg(), 14.5 / 9.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_max(), 5.5); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_avg(), 2.375); + EXPECT_DOUBLE_EQ(stats.objective_vector_l2_norm(), std::sqrt(36.25)); + EXPECT_EQ(stats.objective_matrix_num_nonzeros(), 0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_min(), 0.0); + EXPECT_THAT(stats.objective_matrix_abs_avg(), IsNan()); + EXPECT_EQ(stats.variable_bound_gaps_num_finite(), 1); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_max(), 1.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_min(), 1.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_avg(), 1.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_max(), 7.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_min(), 0.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_avg(), 3.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_l2_norm(), std::sqrt(66.0)); +} + +TEST(ProblemStatsTest, NoFiniteGaps) { + ShardedQuadraticProgram lp(SmallInvalidProblemLp(), 2, 2); + const QuadraticProgramStats stats = ComputeStats(lp); + // Ensure max/min/avg take their default values when no finite gaps exist. + EXPECT_EQ(stats.variable_bound_gaps_num_finite(), 0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_min(), 0.0); + EXPECT_THAT(stats.variable_bound_gaps_avg(), IsNan()); +} + +TEST(ProblemStatsTest, LpWithoutConstraints) { + ShardedQuadraticProgram lp(LpWithoutConstraints(), 2, 2); + const QuadraticProgramStats stats = ComputeStats(lp); + // When there are no constraints, max/min absolute values and infinity norms + // are assigned 0 by convention. The same is true for the combined bounds. + EXPECT_EQ(stats.constraint_matrix_num_nonzeros(), 0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_min(), 0.0); + EXPECT_THAT(stats.constraint_matrix_abs_avg(), IsNan()); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_col_min_l_inf_norm(), 0.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_row_min_l_inf_norm(), 0.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_min(), 0.0); + EXPECT_THAT(stats.combined_bounds_avg(), IsNan()); +} + +TEST(ProblemStatsTest, EmptyLp) { + ShardedQuadraticProgram lp(QuadraticProgram(0, 0), 2, 2); + const QuadraticProgramStats stats = ComputeStats(lp); + // When LP is empty, everything except averages should be 0 and averages + // should be NaN + EXPECT_EQ(stats.num_variables(), 0); + EXPECT_EQ(stats.num_constraints(), 0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_col_min_l_inf_norm(), 0.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_row_min_l_inf_norm(), 0.0); + EXPECT_EQ(stats.constraint_matrix_num_nonzeros(), 0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.constraint_matrix_abs_min(), 0.0); + EXPECT_THAT(stats.constraint_matrix_abs_avg(), IsNan()); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.objective_vector_abs_min(), 0.0); + EXPECT_THAT(stats.objective_vector_abs_avg(), IsNan()); + EXPECT_DOUBLE_EQ(stats.objective_vector_l2_norm(), 0.0); + EXPECT_EQ(stats.objective_matrix_num_nonzeros(), 0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.objective_matrix_abs_min(), 0.0); + EXPECT_THAT(stats.objective_matrix_abs_avg(), IsNan()); + EXPECT_EQ(stats.variable_bound_gaps_num_finite(), 0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.variable_bound_gaps_min(), 0.0); + EXPECT_THAT(stats.variable_bound_gaps_avg(), IsNan()); + EXPECT_DOUBLE_EQ(stats.combined_bounds_max(), 0.0); + EXPECT_DOUBLE_EQ(stats.combined_bounds_min(), 0.0); + EXPECT_THAT(stats.combined_bounds_avg(), IsNan()); + EXPECT_DOUBLE_EQ(stats.combined_bounds_l2_norm(), 0.0); +} + +// The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1], +// the scaled matrix is [ 0 1 2 -2; 0 0 4 0; 0 0 0 0; 0 0 9 3], +// so the row LInf norms are [2 4 0 9] and the column LInf norms are [0 1 9 3]. +// Rescaling divides the scaling vectors by sqrt(norms). +TEST(LInfRuizRescaling, OneIteration) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + VectorXd row_scaling_vec(4), col_scaling_vec(4); + row_scaling_vec << 1, 2, 1, 3; + col_scaling_vec << 0, 1, 2, -1; + LInfRuizRescaling(lp, /*num_iterations=*/1, row_scaling_vec, col_scaling_vec); + EXPECT_THAT(row_scaling_vec, ElementsAre(1 / std::sqrt(2), 1.0, 1.0, 1.0)); + EXPECT_THAT(col_scaling_vec, + ElementsAre(0.0, 1.0, 2.0 / 3.0, -1.0 / std::sqrt(3.0))); +} + +// The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1], +// the scaled matrix is [ 0 1 2 -2; 0 0 4 0; 0 0 0 0; 0 0 9 3], +// so the row L2 norms are [3 4 0 sqrt(90)] and the column L2 norms are [0 1 +// sqrt(101) sqrt(13)]. Rescaling divides the scaling vectors by sqrt(norms). +TEST(L2RuizRescaling, OneIteration) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + VectorXd row_scaling_vec(4), col_scaling_vec(4); + row_scaling_vec << 1, 2, 1, 3; + col_scaling_vec << 0, 1, 2, -1; + L2NormRescaling(lp, row_scaling_vec, col_scaling_vec); + EXPECT_THAT(row_scaling_vec, ElementsAre(1.0 / std::pow(3.0, 0.5), 1.0, 1.0, + 3.0 / std::pow(90.0, 0.25))); + EXPECT_THAT(col_scaling_vec, ElementsAre(0.0, 1.0, 2.0 / std::pow(101, 0.25), + -1.0 / std::pow(13.0, 0.25))); +} + +// The test matrix is [2 3], so the row L2 norms are [sqrt(13)] and the column +// L2 norms are [2 3]. Rescaling divides the scaling vectors by sqrt(norms). +TEST(L2RuizRescaling, OneIterationNonSquare) { + QuadraticProgram test_lp(/*num_variables=*/2, /*num_constraints=*/1); + std::vector> triplets = {{0, 0, 2.0}, + {0, 1, 3.0}}; + test_lp.constraint_matrix.setFromTriplets(triplets.begin(), triplets.end()); + ShardedQuadraticProgram lp(std::move(test_lp), /*num_threads=*/2, + /*num_shards=*/2); + VectorXd row_scaling_vec = VectorXd::Ones(1); + VectorXd col_scaling_vec = VectorXd::Ones(2); + L2NormRescaling(lp, row_scaling_vec, col_scaling_vec); + EXPECT_THAT(row_scaling_vec, ElementsAre(1.0 / std::pow(13.0, 0.25))); + EXPECT_THAT(col_scaling_vec, + ElementsAre(1.0 / std::sqrt(2.0), 1.0 / std::sqrt(3.0))); +} + +// With many iterations of LInfRuizRescaling, the scaled matrix should converge +// to have col LInf norm 1 and row LInf norm 1. +TEST(LInfRuizRescaling, Convergence) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + VectorXd row_scaling_vec(4), col_scaling_vec(4); + VectorXd col_norm(4), row_norm(4); + row_scaling_vec << 1, 1, 1, 1; + col_scaling_vec << 1, 1, 1, 1; + LInfRuizRescaling(lp, /*num_iterations=*/20, row_scaling_vec, + col_scaling_vec); + col_norm = ScaledColLInfNorm(lp.Qp().constraint_matrix, row_scaling_vec, + col_scaling_vec, lp.ConstraintMatrixSharder()); + row_norm = ScaledColLInfNorm(lp.TransposedConstraintMatrix(), col_scaling_vec, + row_scaling_vec, + lp.TransposedConstraintMatrixSharder()); + EXPECT_THAT(row_norm, EigenArrayNear({1.0, 1.0, 1.0, 1.0}, 1.0e-4)); + EXPECT_THAT(col_norm, EigenArrayNear({1.0, 1.0, 1.0, 1.0}, 1.0e-4)); +} + +// This applies one round of l_inf and one round of L2 rescaling. +// The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1], +// so the row LInf norms are [2 1 4 1.5] and column LInf norms are [4 1 1.5 2]. +// l_inf divides by sqrt(norms), giving +// [0.7071 0.7071 0.5773 1; 0.5 0 0.8165 0; 1 0 0 0; 0 0 1 -0.5773] +// which has row L2 norms [1.5275 0.957429 1 1.1547] and col L2 norms +// [1.3229 0.7071 1.4142 1.1547]. The resulting scaling vectors are +// 1/sqrt((l_inf norms).*(l2 norms)). +TEST(ApplyRescaling, ApplyRescalingWorksForTestLp) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + ScalingVectors scaling = ApplyRescaling( + RescalingOptions{.l_inf_ruiz_iterations = 1, .l2_norm_rescaling = true}, + lp); + EXPECT_THAT(scaling.row_scaling_vec, + EigenArrayNear( + {1.0 / sqrt(2.0 * 1.5275), 1.0 / sqrt(1.0 * 0.9574), + 1.0 / sqrt(4.0 * 1.0), 1.0 / sqrt(1.5 * 1.1547)}, + 1.0e-4)); + EXPECT_THAT(scaling.col_scaling_vec, + EigenArrayNear( + {1.0 / sqrt(4.0 * 1.3229), 1.0 / sqrt(1.0 * 0.7071), + 1.0 / sqrt(1.5 * 1.4142), 1.0 / sqrt(2.0 * 1.1547)}, + 1.0e-4)); +} + +TEST(ComputePrimalGradientTest, CorrectForLp) { + // The choice of two shards is intentional, to help catch bugs in the sharded + // computations. + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution(4), dual_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + dual_solution << -1.0, 0.0, 1.0, 1.0; + + const LagrangianPart primal_part = ComputePrimalGradient( + lp, primal_solution, lp.TransposedConstraintMatrix() * dual_solution); + // Using notation consistent with + // https://developers.google.com/optimization/lp/pdlp_math. + // c - A'y + EXPECT_THAT(primal_part.gradient, + ElementsAre(5.5 - 2.0, -2.0 + 1.0, -1.0 - 0.5, 1.0 + 3.0)); + // c'x - y'Ax. + EXPECT_DOUBLE_EQ(primal_part.value, 3.0 + 9.0); +} + +TEST(ComputeDualGradientTest, CorrectForLp) { + // The choice of two shards is intentional, to help catch bugs in the sharded + // computations. + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution(4), dual_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + dual_solution << -1.0, 0.0, 1.0, 1.0; + + const LagrangianPart dual_part = ComputeDualGradient( + lp, dual_solution, lp.Qp().constraint_matrix * primal_solution); + // Using notation consistent with + // https://developers.google.com/optimization/lp/pdlp_math. + // active_constraint_right_hand_side - Ax + EXPECT_THAT(dual_part.gradient, + ElementsAre(12.0 - 6.0, 7.0, -4.0, -1.0 + 3.0)); + // y'active_constraint_right_hand_side + EXPECT_DOUBLE_EQ(dual_part.value, 12.0 * -1.0 + -4.0 * 1.0 + -1.0 * 1.0); +} + +TEST(ComputeDualGradientTest, CorrectOnTwoSidedConstraints) { + QuadraticProgram qp = TestLp(); + // Makes the constraints all two-sided. The primal solution is feasible in + // the first constraint, below the lower bound of the second constraint, and + // above the upper bound of the third constraint. + qp.constraint_lower_bounds[0] = 4; + qp.constraint_lower_bounds[1] = 5; + qp.constraint_upper_bounds[2] = -1; + ShardedQuadraticProgram sharded_qp(std::move(qp), /*num_threads=*/2, + /*num_shards=*/2); + + VectorXd primal_solution(4), dual_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + dual_solution << 0.0, 0.0, 0.0, -1.0; + + const LagrangianPart dual_part = + ComputeDualGradient(sharded_qp, dual_solution, + sharded_qp.Qp().constraint_matrix * primal_solution); + // Using notation consistent with + // https://developers.google.com/optimization/lp/pdlp_math. + // active_constraint_right_hand_side - Ax + EXPECT_THAT(dual_part.gradient, + ElementsAre(0.0, 5.0 - 0.0, -1.0 - 0.0, 1.0 + 3.0)); + // y'active_constraint_right_hand_side + EXPECT_DOUBLE_EQ(dual_part.value, 1.0 * -1.0); +} + +TEST(HasValidBoundsTest, SmallInvalidLp) { + ShardedQuadraticProgram lp(SmallInvalidProblemLp(), /*num_threads=*/2, + /*num_shards=*/2); + + bool is_valid = HasValidBounds(lp); + EXPECT_FALSE(is_valid); +} + +TEST(HasValidBoundsTest, SmallValidLp) { + ShardedQuadraticProgram lp(SmallPrimalInfeasibleLp(), /*num_threads=*/2, + /*num_shards=*/2); + + bool is_valid = HasValidBounds(lp); + EXPECT_TRUE(is_valid); +} + +TEST(ComputePrimalGradientTest, CorrectForQp) { + ShardedQuadraticProgram qp(TestDiagonalQp1(), /*num_threads=*/2, + /*num_shards=*/2); + + VectorXd primal_solution(2), dual_solution(1); + primal_solution << 1.0, 2.0; + dual_solution << -2.0; + + const LagrangianPart primal_part = ComputePrimalGradient( + qp, primal_solution, qp.TransposedConstraintMatrix() * dual_solution); + + // Using notation consistent with + // https://developers.google.com/optimization/lp/pdlp_math. + // c - A'y + Qx + EXPECT_THAT(primal_part.gradient, + ElementsAre(-1.0 + 2.0 + 4.0, -1.0 + 2.0 + 2.0)); + // (1/2) x'Qx + c'x - y'Ax. + EXPECT_DOUBLE_EQ(primal_part.value, 4.0 - 3.0 + 2.0 * 3.0); +} + +TEST(ComputeDualGradientTest, CorrectForQp) { + ShardedQuadraticProgram qp(TestDiagonalQp1(), /*num_threads=*/2, + /*num_shards=*/2); + + VectorXd primal_solution(2), dual_solution(1); + primal_solution << 1.0, 2.0; + dual_solution << -2.0; + + const LagrangianPart dual_part = ComputeDualGradient( + qp, dual_solution, qp.Qp().constraint_matrix * primal_solution); + + // Using notation consistent with + // https://developers.google.com/optimization/lp/pdlp_math. + // active_constraint_right_hand_side - Ax + EXPECT_THAT(dual_part.gradient, ElementsAre(1.0 - (1.0 + 2.0))); + // y'active_constraint_right_hand_side + EXPECT_DOUBLE_EQ(dual_part.value, -2.0); +} + +TEST(EstimateSingularValuesTest, CorrectForTestLp) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + // The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1]. + std::mt19937 random(1); + auto result = EstimateMaximumSingularValueOfConstraintMatrix( + lp, absl::nullopt, absl::nullopt, + /*desired_relative_error=*/0.01, + /*failure_probability=*/0.001, random); + EXPECT_NEAR(result.singular_value, 4.76945, 0.01); + EXPECT_LT(result.num_iterations, 300); +} + +TEST(EstimateSingularValuesTest, CorrectForTestLpWithActivePrimalSubspace) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution(4); + // Chosen so x_1 is at its bound, and all other variables are not at bounds. + primal_solution << 0.0, -2.0, 0.0, 3.0; + // The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1], + // so the projected matrix is [ 2 1 2; 1 1 0; 4 0 0; 0 1.5 -1]. + std::mt19937 random(1); + auto result = EstimateMaximumSingularValueOfConstraintMatrix( + lp, primal_solution, absl::nullopt, /*desired_relative_error=*/0.01, + /*failure_probability=*/0.001, random); + EXPECT_NEAR(result.singular_value, 4.73818, 0.01); + EXPECT_LT(result.num_iterations, 300); +} + +TEST(EstimateSingularValuesTest, CorrectForTestLpWithActiveDualSubspace) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd dual_solution(4); + // Chosen so the second dual is at its bound, and all other duals are not at + // bounds. + dual_solution << 1.0, 0.0, 1.0, 3.0; + // The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1], + // so the projected matrix is [ 2 1 1 2; 4 0 0 0; 0 0 1.5 -1]. + std::mt19937 random(1); + auto result = EstimateMaximumSingularValueOfConstraintMatrix( + lp, absl::nullopt, dual_solution, /*desired_relative_error=*/0.01, + /*failure_probability=*/0.001, random); + EXPECT_NEAR(result.singular_value, 4.64203, 0.01); + EXPECT_LT(result.num_iterations, 300); +} + +TEST(EstimateSingularValuesTest, CorrectForTestLpWithBothActiveSubspaces) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution(4), dual_solution(4); + // Chosen so x_1 is at its bound, and all other variables are not at bounds. + primal_solution << 0.0, -2.0, 0.0, 3.0; + // Chosen so the second dual is at its bound, and all other duals are not at + // bounds. + dual_solution << 1.0, 0.0, 1.0, 3.0; + // The test_lp matrix is [ 2 1 1 2; 1 0 1 0; 4 0 0 0; 0 0 1.5 -1], + // so the projected matrix is [ 2 1 2; 4 0 0; 0 1.5 -1]. + std::mt19937 random(1); + auto result = EstimateMaximumSingularValueOfConstraintMatrix( + lp, primal_solution, dual_solution, /*desired_relative_error=*/0.01, + /*failure_probability=*/0.001, random); + EXPECT_NEAR(result.singular_value, 4.60829, 0.01); + EXPECT_LT(result.num_iterations, 300); +} + +TEST(ProjectToPrimalVariableBoundsTest, TestLp) { + ShardedQuadraticProgram qp(TestLp(), /*num_threads=*/2, + /*num_shards=*/2); + VectorXd primal(4); + primal << -3, -3, 5, 5; + ProjectToPrimalVariableBounds(qp, primal); + EXPECT_THAT(primal, ElementsAre(-3, -2, 5, 3.5)); +} + +TEST(ProjectToDualVariableBoundsTest, TestLp) { + ShardedQuadraticProgram qp(TestLp(), /*num_threads=*/2, + /*num_shards=*/2); + VectorXd dual(4); + dual << 1, 1, -1, -1; + ProjectToDualVariableBounds(qp, dual); + EXPECT_THAT(dual, ElementsAre(1, 0, 0, -1)); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/sharded_quadratic_program.cc b/ortools/pdlp/sharded_quadratic_program.cc new file mode 100644 index 0000000000..13d01ef342 --- /dev/null +++ b/ortools/pdlp/sharded_quadratic_program.cc @@ -0,0 +1,155 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/sharded_quadratic_program.h" + +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/memory/memory.h" +#include "absl/strings/string_view.h" +#include "ortools/base/logging.h" +#include "ortools/base/threadpool.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharder.h" + +namespace operations_research::pdlp { + +namespace { + +// Logs a warning if the given matrix has more than density_limit non-zeros in +// a single column. +void WarnIfMatrixUnbalanced( + const Eigen::SparseMatrix& matrix, + absl::string_view matrix_name, int64_t density_limit) { + if (matrix.cols() == 0) return; + int64_t worst_column = 0; + for (int64_t col = 0; col < matrix.cols(); ++col) { + if (matrix.col(col).nonZeros() > matrix.col(worst_column).nonZeros()) { + worst_column = col; + } + } + if (matrix.col(worst_column).nonZeros() > density_limit) { + // TODO(user): fix this automatically in presolve instead of asking the + // user to do it. + LOG(WARNING) + << "The " << matrix_name << " has " + << matrix.col(worst_column).nonZeros() << " non-zeros in its " + << worst_column + << "th column. For best parallelization all rows and columns should " + "have at most order " + << density_limit + << " non-zeros. Consider rewriting the QP to split the corresponding " + "variable or constraint."; + } +} + +} // namespace + +ShardedQuadraticProgram::ShardedQuadraticProgram(QuadraticProgram qp, + const int num_threads, + const int num_shards) + : qp_(std::move(qp)), + transposed_constraint_matrix_(qp_.constraint_matrix.transpose()), + thread_pool_(num_threads == 1 + ? nullptr + : absl::make_unique("PDLP", num_threads)), + constraint_matrix_sharder_(qp_.constraint_matrix, num_shards, + thread_pool_.get()), + transposed_constraint_matrix_sharder_(transposed_constraint_matrix_, + num_shards, thread_pool_.get()), + primal_sharder_(qp_.variable_lower_bounds.size(), num_shards, + thread_pool_.get()), + dual_sharder_(qp_.constraint_lower_bounds.size(), num_shards, + thread_pool_.get()) { + CHECK_GE(num_threads, 1); + CHECK_GE(num_shards, num_threads); + if (num_threads > 1) { + thread_pool_->StartWorkers(); + const int64_t work_per_iteration = qp_.constraint_matrix.nonZeros() + + qp_.variable_lower_bounds.size() + + qp_.constraint_lower_bounds.size(); + const int64_t column_density_limit = work_per_iteration / num_threads; + WarnIfMatrixUnbalanced(qp_.constraint_matrix, "constraint matrix", + column_density_limit); + WarnIfMatrixUnbalanced(transposed_constraint_matrix_, + "transposed constraint matrix", + column_density_limit); + } +} + +namespace { + +// Multiply each entry of the matrix by the corresponding element of +// row_scaling_vec and col_scaling_vec, i.e., matrix[i,j] *= row_scaling_vec[i] +// * col_scaling_vec[j]. +void ScaleMatrix( + const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec, const Sharder& sharder, + Eigen::SparseMatrix& matrix) { + CHECK_EQ(matrix.cols(), col_scaling_vec.size()); + CHECK_EQ(matrix.rows(), row_scaling_vec.size()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + auto matrix_shard = shard(matrix); + auto col_scaling_vec_shard = shard(col_scaling_vec); + for (int64_t col_num = 0; col_num < shard(matrix).outerSize(); ++col_num) { + for (decltype(matrix_shard)::InnerIterator it(matrix_shard, col_num); it; + ++it) { + it.valueRef() *= + row_scaling_vec[it.row()] * col_scaling_vec_shard[it.col()]; + } + } + }); +} + +} // namespace + +void ShardedQuadraticProgram::RescaleQuadraticProgram( + const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec) { + CHECK_EQ(PrimalSize(), col_scaling_vec.size()); + CHECK_EQ(DualSize(), row_scaling_vec.size()); + primal_sharder_.ParallelForEachShard([&](const Sharder::Shard& shard) { + CHECK((shard(col_scaling_vec).array() > 0.0).all()); + shard(qp_.objective_vector) = + shard(qp_.objective_vector).cwiseProduct(shard(col_scaling_vec)); + shard(qp_.variable_lower_bounds) = + shard(qp_.variable_lower_bounds).cwiseQuotient(shard(col_scaling_vec)); + shard(qp_.variable_upper_bounds) = + shard(qp_.variable_upper_bounds).cwiseQuotient(shard(col_scaling_vec)); + if (!IsLinearProgram(qp_)) { + shard(qp_.objective_matrix->diagonal()) = + shard(qp_.objective_matrix->diagonal()) + .cwiseProduct( + shard(col_scaling_vec).cwiseProduct(shard(col_scaling_vec))); + } + }); + dual_sharder_.ParallelForEachShard([&](const Sharder::Shard& shard) { + CHECK((shard(row_scaling_vec).array() > 0.0).all()); + shard(qp_.constraint_lower_bounds) = + shard(qp_.constraint_lower_bounds).cwiseProduct(shard(row_scaling_vec)); + shard(qp_.constraint_upper_bounds) = + shard(qp_.constraint_upper_bounds).cwiseProduct(shard(row_scaling_vec)); + }); + + ScaleMatrix(col_scaling_vec, row_scaling_vec, constraint_matrix_sharder_, + qp_.constraint_matrix); + ScaleMatrix(row_scaling_vec, col_scaling_vec, + transposed_constraint_matrix_sharder_, + transposed_constraint_matrix_); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/sharded_quadratic_program.h b/ortools/pdlp/sharded_quadratic_program.h new file mode 100644 index 0000000000..daccd31b44 --- /dev/null +++ b/ortools/pdlp/sharded_quadratic_program.h @@ -0,0 +1,91 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_SHARDED_QUADRATIC_PROGRAM_H_ +#define PDLP_SHARDED_QUADRATIC_PROGRAM_H_ + +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "ortools/base/threadpool.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharder.h" + +namespace operations_research::pdlp { + +// This class stores: +// - A QuadraticProgram (QP) +// - A transposed version of the QP's constraint matrix +// - A thread pool +// - Various Sharder objects for doing sharded matrix and vector computations. +class ShardedQuadraticProgram { + public: + // Requires num_shards >= num_threads >= 1. + // Note that the qp is intentionally passed by value. + ShardedQuadraticProgram(QuadraticProgram qp, int num_threads, int num_shards); + + // Movable but not copyable. + ShardedQuadraticProgram(const ShardedQuadraticProgram&) = delete; + ShardedQuadraticProgram& operator=(const ShardedQuadraticProgram&) = delete; + ShardedQuadraticProgram(ShardedQuadraticProgram&&) = default; + ShardedQuadraticProgram& operator=(ShardedQuadraticProgram&&) = default; + + const QuadraticProgram& Qp() const { return qp_; } + + // Returns a reference to the transpose of the QP's constraint matrix. + const Eigen::SparseMatrix& + TransposedConstraintMatrix() const { + return transposed_constraint_matrix_; + } + + // Returns a Sharder intended for the columns of the QP's constraint matrix. + const Sharder& ConstraintMatrixSharder() const { + return constraint_matrix_sharder_; + } + // Returns a Sharder intended for the rows of the QP's constraint matrix. + const Sharder& TransposedConstraintMatrixSharder() const { + return transposed_constraint_matrix_sharder_; + } + // Returns a Sharder intended for primal vectors. + const Sharder& PrimalSharder() const { return primal_sharder_; } + // Returns a Sharder intended for dual vectors. + const Sharder& DualSharder() const { return dual_sharder_; } + + int64_t PrimalSize() const { return qp_.variable_lower_bounds.size(); } + int64_t DualSize() const { return qp_.constraint_lower_bounds.size(); } + + // Rescale the QP (including objective, variable bounds, constraint bounds, + // constraint matrix, and transposed constraint matrix) based on + // col_scaling_vec and row_scaling_vec. That is, rescale the problem so that + // each variable is rescaled as variable[i] <- variable[i] / + // col_scaling_vec[i], and the j-th constraint is multiplied by + // row_scaling_vec[j]. col_scaling_vec and row_scaling_vec must be positive. + void RescaleQuadraticProgram(const Eigen::VectorXd& col_scaling_vec, + const Eigen::VectorXd& row_scaling_vec); + + private: + QuadraticProgram qp_; + Eigen::SparseMatrix + transposed_constraint_matrix_; + std::unique_ptr thread_pool_; + Sharder constraint_matrix_sharder_; + Sharder transposed_constraint_matrix_sharder_; + Sharder primal_sharder_; + Sharder dual_sharder_; +}; + +} // namespace operations_research::pdlp + +#endif // PDLP_SHARDED_QUADRATIC_PROGRAM_H_ diff --git a/ortools/pdlp/sharded_quadratic_program_test.cc b/ortools/pdlp/sharded_quadratic_program_test.cc new file mode 100644 index 0000000000..1ac9041feb --- /dev/null +++ b/ortools/pdlp/sharded_quadratic_program_test.cc @@ -0,0 +1,84 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/sharded_quadratic_program.h" + +#include + +#include "Eigen/Core" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharder.h" +#include "ortools/pdlp/test_util.h" + +namespace operations_research::pdlp { +namespace { + +using ::testing::ElementsAre; + +const double kInfinity = std::numeric_limits::infinity(); + +TEST(ShardedQuadraticProgramTest, BasicTest) { + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestDiagonalQp1(), num_threads, + num_shards); + const int primal_size = 2; + const int dual_size = 1; + EXPECT_EQ(sharded_qp.DualSize(), dual_size); + EXPECT_EQ(sharded_qp.PrimalSize(), primal_size); + EXPECT_THAT(ToDense(sharded_qp.TransposedConstraintMatrix()), + EigenArrayEq({{1}, {1}})); + EXPECT_EQ(sharded_qp.ConstraintMatrixSharder().NumElements(), primal_size); + EXPECT_EQ(sharded_qp.DualSharder().NumElements(), dual_size); + EXPECT_EQ(sharded_qp.PrimalSharder().NumElements(), primal_size); + EXPECT_EQ(sharded_qp.TransposedConstraintMatrixSharder().NumElements(), + dual_size); +} + +TEST(RescaleProblem, BasicTest) { + // The original qp is: + // min 4x_1^2 + x_2^2 - x_1 - x_2 +5 + // s.t. -inf <= x_1 + x_2 <=1, + // 1<=x_1<=2, -2<=x_2<=4. + // After rescaling with row_scaling_vec [0.5] and col_scaling_vec [1,0.5], + // the new qp becomes: + // min 4x_1^2 + 0.25x_2^2 - x_1 - 0.5x_2 +5 + // s.t. -inf <= 0.5x_1 + 0.25x_2 <=1, + // 1<=x_1<=2, -4<=x_2<=8. + const int num_threads = 2; + const int num_shards = 10; + ShardedQuadraticProgram sharded_qp(TestDiagonalQp1(), num_threads, + num_shards); + Eigen::VectorXd col_scaling_vec(2); + Eigen::VectorXd row_scaling_vec(1); + col_scaling_vec << 1, 0.5; + row_scaling_vec << 0.5; + sharded_qp.RescaleQuadraticProgram(col_scaling_vec, row_scaling_vec); + + EXPECT_THAT(sharded_qp.Qp().constraint_lower_bounds, ElementsAre(-kInfinity)); + EXPECT_THAT(sharded_qp.Qp().constraint_upper_bounds, ElementsAre(0.5)); + EXPECT_THAT(sharded_qp.Qp().variable_lower_bounds, ElementsAre(1, -4)); + EXPECT_THAT(sharded_qp.Qp().variable_upper_bounds, ElementsAre(2, 8)); + EXPECT_THAT(sharded_qp.Qp().objective_vector, ElementsAre(-1, -0.5)); + EXPECT_THAT(ToDense(sharded_qp.Qp().constraint_matrix), + EigenArrayEq({{0.5, 0.25}})); + EXPECT_THAT(ToDense(sharded_qp.TransposedConstraintMatrix()), + EigenArrayEq({{0.5}, {0.25}})); + EXPECT_THAT(sharded_qp.Qp().objective_matrix->diagonal(), + EigenArrayEq({4, 0.25})); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/sharder.cc b/ortools/pdlp/sharder.cc new file mode 100644 index 0000000000..a083d51144 --- /dev/null +++ b/ortools/pdlp/sharder.cc @@ -0,0 +1,320 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/sharder.h" + +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/synchronization/blocking_counter.h" +#include "absl/time/time.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/base/threadpool.h" +#include "ortools/base/timer.h" + +namespace operations_research::pdlp { + +using ::Eigen::VectorXd; + +Sharder::Sharder(const int64_t num_elements, const int num_shards, + ThreadPool* const thread_pool, + const std::function& element_mass) + : thread_pool_(thread_pool) { + CHECK_GE(num_elements, 0); + if (num_elements == 0) { + shard_starts_.push_back(0); + return; + } + CHECK_GE(num_shards, 1); + int64_t overall_mass = 0; + for (int64_t elem = 0; elem < num_elements; ++elem) { + overall_mass += element_mass(elem); + } + shard_starts_.push_back(0); + int64_t this_shard_mass = element_mass(0); + for (int64_t elem = 1; elem < num_elements; ++elem) { + int64_t this_elem_mass = element_mass(elem); + if (this_shard_mass + (this_elem_mass / 2) >= overall_mass / num_shards) { + // this elem starts a new shard + shard_masses_.push_back(this_shard_mass); + shard_starts_.push_back(elem); + this_shard_mass = this_elem_mass; + } else { + this_shard_mass += this_elem_mass; + } + } + shard_starts_.push_back(num_elements); + shard_masses_.push_back(this_shard_mass); + CHECK_EQ(NumShards(), shard_masses_.size()); +} + +Sharder::Sharder(const int64_t num_elements, const int num_shards, + ThreadPool* const thread_pool) + : thread_pool_(thread_pool) { + CHECK_GE(num_elements, 0); + if (num_elements == 0) { + shard_starts_.push_back(0); + return; + } + CHECK_GE(num_shards, 1); + shard_starts_.reserve(num_shards + 1); + shard_masses_.reserve(num_shards); + for (int shard = 0; shard < num_shards; ++shard) { + const int64_t this_shard_start = ((num_elements * shard) / num_shards); + const int64_t next_shard_start = + ((num_elements * (shard + 1)) / num_shards); + if (next_shard_start - this_shard_start > 0) { + shard_starts_.push_back(this_shard_start); + shard_masses_.push_back(next_shard_start - this_shard_start); + } + } + shard_starts_.push_back(num_elements); + CHECK_EQ(NumShards(), shard_masses_.size()); +} + +Sharder::Sharder(const Sharder& other_sharder, const int64_t num_elements) + // The std::max() protects against other_sharder.NumShards() == 0, which + // will happen if other_sharder had num_elements == 0. + : Sharder(num_elements, std::max(1, other_sharder.NumShards()), + other_sharder.thread_pool_) {} + +void Sharder::ParallelForEachShard( + const std::function& func) const { + if (thread_pool_) { + absl::BlockingCounter counter(NumShards()); + VLOG(2) << "Starting ParallelForEachShard()"; + for (int shard_num = 0; shard_num < NumShards(); ++shard_num) { + thread_pool_->Schedule([&, shard_num]() { + WallTimer timer; + if (VLOG_IS_ON(2)) { + timer.Start(); + } + func(Shard(shard_num, this)); + if (VLOG_IS_ON(2)) { + timer.Stop(); + VLOG(2) << "Shard " << shard_num << " with " << ShardSize(shard_num) + << " elements and " << ShardMass(shard_num) + << " mass finished with " + << ShardMass(shard_num) / + std::max(int64_t{1}, absl::ToInt64Microseconds( + timer.GetDuration())) + << " mass/usec."; + } + counter.DecrementCount(); + }); + } + counter.Wait(); + VLOG(2) << "Done ParallelForEachShard()"; + } else { + for (int shard_num = 0; shard_num < NumShards(); ++shard_num) { + func(Shard(shard_num, this)); + } + } +} + +double Sharder::ParallelSumOverShards( + const std::function& func) const { + VectorXd local_sums(NumShards()); + ParallelForEachShard([&](const Sharder::Shard& shard) { + local_sums[shard.Index()] = func(shard); + }); + return local_sums.sum(); +} + +bool Sharder::ParallelTrueForAllShards( + const std::function& func) const { + // Recall std::vector is not thread-safe. + std::vector local_result(NumShards()); + ParallelForEachShard([&](const Sharder::Shard& shard) { + local_result[shard.Index()] = static_cast(func(shard)); + }); + return std::all_of(local_result.begin(), local_result.end(), + [](const int v) { return static_cast(v); }); +} + +VectorXd TransposedMatrixVectorProduct( + const Eigen::SparseMatrix& matrix, + const VectorXd& vector, const Sharder& sharder) { + CHECK_EQ(vector.size(), matrix.rows()); + VectorXd answer(matrix.cols()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + shard(answer) = shard(matrix).transpose() * vector; + }); + return answer; +} + +void AddScaledVector(const double scale, const VectorXd& increment, + const Sharder& sharder, VectorXd& dest) { + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + shard(dest) += scale * shard(increment); + }); +} + +void AssignVector(const VectorXd& vec, const Sharder& sharder, VectorXd& dest) { + dest.resize(vec.size()); + sharder.ParallelForEachShard( + [&](const Sharder::Shard& shard) { shard(dest) = shard(vec); }); +} + +VectorXd CloneVector(const VectorXd& vec, const Sharder& sharder) { + VectorXd dest; + AssignVector(vec, sharder, dest); + return dest; +} + +// Like vector = vector.cwiseProduct(scale). +void CoefficientWiseProductInPlace(const VectorXd& scale, + const Sharder& sharder, VectorXd& dest) { + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + shard(dest) = shard(dest).cwiseProduct(shard(scale)); + }); +} + +// Like vector = vector.cwiseQuotient(scale). +void CoefficientWiseQuotientInPlace(const VectorXd& scale, + const Sharder& sharder, VectorXd& dest) { + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + shard(dest) = shard(dest).cwiseQuotient(shard(scale)); + }); +} + +double Dot(const VectorXd& v1, const VectorXd& v2, const Sharder& sharder) { + return sharder.ParallelSumOverShards( + [&](const Sharder::Shard& shard) { return shard(v1).dot(shard(v2)); }); +} + +double LInfNorm(const VectorXd& vector, const Sharder& sharder) { + VectorXd local_max(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + local_max[shard.Index()] = shard(vector).lpNorm(); + }); + return local_max.lpNorm(); +} + +double L1Norm(const VectorXd& vector, const Sharder& sharder) { + return sharder.ParallelSumOverShards( + [&](const Sharder::Shard& shard) { return shard(vector).lpNorm<1>(); }); +} + +double SquaredNorm(const VectorXd& vector, const Sharder& sharder) { + return sharder.ParallelSumOverShards( + [&](const Sharder::Shard& shard) { return shard(vector).squaredNorm(); }); +} + +double Norm(const VectorXd& vector, const Sharder& sharder) { + return std::sqrt(SquaredNorm(vector, sharder)); +} + +double SquaredDistance(const VectorXd& vector1, const VectorXd& vector2, + const Sharder& sharder) { + return sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + return (shard(vector1) - shard(vector2)).squaredNorm(); + }); +} + +double Distance(const VectorXd& vector1, const VectorXd& vector2, + const Sharder& sharder) { + return std::sqrt(SquaredDistance(vector1, vector2, sharder)); +} + +double ScaledLInfNorm(const VectorXd& vector, const VectorXd& scale, + const Sharder& sharder) { + VectorXd local_max(sharder.NumShards()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + local_max[shard.Index()] = + shard(vector).cwiseProduct(shard(scale)).lpNorm(); + }); + return local_max.lpNorm(); +} + +double ScaledSquaredNorm(const VectorXd& vector, const VectorXd& scale, + const Sharder& sharder) { + return sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + return shard(vector).cwiseProduct(shard(scale)).squaredNorm(); + }); +} + +double ScaledNorm(const VectorXd& vector, const VectorXd& scale, + const Sharder& sharder) { + return std::sqrt(ScaledSquaredNorm(vector, scale, sharder)); +} + +VectorXd ScaledColLInfNorm( + const Eigen::SparseMatrix& matrix, + const VectorXd& row_scaling_vec, const VectorXd& col_scaling_vec, + const Sharder& sharder) { + CHECK_EQ(matrix.cols(), col_scaling_vec.size()); + CHECK_EQ(matrix.rows(), row_scaling_vec.size()); + VectorXd answer(matrix.cols()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + auto matrix_shard = shard(matrix); + auto col_scaling_shard = shard(col_scaling_vec); + for (int64_t col_num = 0; col_num < shard(matrix).outerSize(); ++col_num) { + double max = 0.0; + for (decltype(matrix_shard)::InnerIterator it(matrix_shard, col_num); it; + ++it) { + max = std::max(max, std::abs(it.value() * row_scaling_vec[it.row()])); + } + shard(answer)[col_num] = max * std::abs(col_scaling_shard[col_num]); + } + }); + return answer; +} + +VectorXd ScaledColL2Norm( + const Eigen::SparseMatrix& matrix, + const VectorXd& row_scaling_vec, const VectorXd& col_scaling_vec, + const Sharder& sharder) { + CHECK_EQ(matrix.cols(), col_scaling_vec.size()); + CHECK_EQ(matrix.rows(), row_scaling_vec.size()); + VectorXd answer(matrix.cols()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + auto matrix_shard = shard(matrix); + auto col_scaling_shard = shard(col_scaling_vec); + for (int64_t col_num = 0; col_num < shard(matrix).outerSize(); ++col_num) { + double sum_of_squares = 0.0; + for (decltype(matrix_shard)::InnerIterator it(matrix_shard, col_num); it; + ++it) { + sum_of_squares += + MathUtil::Square(it.value() * row_scaling_vec[it.row()]); + } + shard(answer)[col_num] = + std::sqrt(sum_of_squares) * std::abs(col_scaling_shard[col_num]); + } + }); + return answer; +} + +bool IsDiagonal( + const Eigen::SparseMatrix& matrix, + const Sharder& sharder) { + return sharder.ParallelTrueForAllShards([&](const Sharder::Shard& shard) { + auto matrix_shard = shard(matrix); + const int64_t col_offset = sharder.ShardStart(shard.Index()); + for (int64_t col_idx = 0; col_idx < matrix_shard.outerSize(); ++col_idx) { + for (decltype(matrix_shard)::InnerIterator it(matrix_shard, col_idx); it; + ++it) { + if (it.row() != (col_offset + it.col())) return false; + } + } + return true; + }); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/sharder.h b/ortools/pdlp/sharder.h new file mode 100644 index 0000000000..c0ef1b2404 --- /dev/null +++ b/ortools/pdlp/sharder.h @@ -0,0 +1,330 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_SHARDER_H_ +#define PDLP_SHARDER_H_ + +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "ortools/base/logging.h" +#include "ortools/base/threadpool.h" + +namespace operations_research::pdlp { + +// This class represents a way to shard elements for multi-threading. The +// elements may be entries of a vector or the columns of a (column-major) +// matrix. The shards are selected to have roughly the same mass, where the mass +// of an entry depends on the constructor used. See the free functions below and +// in the .cc file for example usage. +class Sharder { + public: + // These are public aliases for convenience. They will change only if there + // are breaking changes in Eigen. + using ConstSparseColumnBlock = ::Eigen::Block< + const Eigen::SparseMatrix, + /*BlockRows=*/Eigen::Dynamic, /*BlockCols=*/Eigen::Dynamic, + /*InnerPanel=*/true>; + using SparseColumnBlock = + ::Eigen::Block, + /*BlockRows=*/Eigen::Dynamic, /*BlockCols=*/Eigen::Dynamic, + /*InnerPanel=*/true>; + + // This class extracts a particular shard of vectors or matrices passed to it. + // See ParallelForEachShard(). + // Caution: Like absl::Span, Shard::operator() returns mutable or immutable + // views into the vector or matrix argument. The underlying object must + // outlive the view. + // Extra Caution: The const& arguments for the immutable views can bind to + // temporary objects, e.g., shard(3*a) will create a view into the "3*a" + // object that will be destroyed immediately after the shard is created. + class Shard { + public: + // Returns this shard of the given vector. + Eigen::VectorBlock operator()( + const Eigen::VectorXd& vector) const { + CHECK_EQ(vector.size(), parent_.NumElements()); + return vector.segment(parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)); + } + // Returns this shard of the given vector in mutable form. + Eigen::VectorBlock operator()( + Eigen::VectorXd& vector) const { + CHECK_EQ(vector.size(), parent_.NumElements()); + return vector.segment(parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)); + } + // Returns this shard of the given VectorBlock. + Eigen::VectorBlock operator()( + Eigen::VectorBlock vector) const { + CHECK_EQ(vector.size(), parent_.NumElements()); + return Eigen::VectorBlock( + vector.nestedExpression(), + vector.startRow() + parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)); + } + // Returns this shard of the given VectorBlock in mutable form. + Eigen::VectorBlock operator()( + Eigen::VectorBlock vector) const { + CHECK_EQ(vector.size(), parent_.NumElements()); + return Eigen::VectorBlock( + vector.nestedExpression(), + vector.startRow() + parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)); + } + // Returns this shard of the given DiagonalMatrix. Note that the shard is + // a *square* diagonal matrix, not a block of columns of original length. + auto operator()(const Eigen::DiagonalMatrix& diag) + const -> decltype(diag.diagonal().segment(0, 0).asDiagonal()) { + CHECK_EQ(diag.diagonal().size(), parent_.NumElements()); + return diag.diagonal() + .segment(parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)) + .asDiagonal(); + } + // Returns this shard of the columns of the given matrix. + ConstSparseColumnBlock operator()( + const Eigen::SparseMatrix& matrix) + const { + CHECK_EQ(matrix.cols(), parent_.NumElements()); + auto result = matrix.middleCols(parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)); + // This is a guard against implicit conversions, because the return type + // of middleCols is not 100% clear from the documentation. + static_assert( + std::is_same::value, + "The return type of middleCols changed!"); + return result; + } + // Returns this shard of the columns of the given matrix in mutable form. + SparseColumnBlock operator()( + Eigen::SparseMatrix& matrix) const { + CHECK_EQ(matrix.cols(), parent_.NumElements()); + auto result = matrix.middleCols(parent_.ShardStart(shard_num_), + parent_.ShardSize(shard_num_)); + // This is a guard against implicit conversions, because the return type + // of middleCols is not 100% clear from the documentation. + static_assert(std::is_same::value, + "The return type of middleCols changed!"); + return result; + } + // A non-negative identifier for this shard, less than the parent Sharder's + // NumShards(). + int Index() const { return shard_num_; } + + private: + friend class Sharder; + Shard(int shard_num, const Sharder* parent) + : shard_num_(shard_num), parent_(*parent) { + CHECK_NE(parent, nullptr); + CHECK_GE(shard_num, 0); + CHECK_LT(shard_num, parent->NumShards()); + } + const int shard_num_; + const Sharder& parent_; + }; + + // Creates a Sharder for problems with `num_elements` elements and mass of + // each element given by `element_mass`. Each shard will have roughly the same + // mass. The number of shards in the resulting Sharder will be approximately + // `num_shards` but may differ. The `thread_pool` will be used for parallel + // operations executed by e.g. ParallelForEachShard(). The `thread_pool` may + // be nullptr, which means work will be executed in the same thread. If + // `thread_pool` is not nullptr, the underlying object is not owned and must + // outlive the Sharder. + Sharder(int64_t num_elements, int num_shards, ThreadPool* thread_pool, + const std::function& element_mass); + + // Creates a Sharder for problems with `num_elements` elements and unit mass. + // This constructor exploits having all element mass equal to 1 to take time + // proportional to num_shards instead of num_elements. Also see the comments + // above the first constructor. + Sharder(int64_t num_elements, int num_shards, ThreadPool* thread_pool); + + // Creates a Sharder for processing the given matrix. The elements correspond + // to columns of the matrix and have mass linear in the number of non-zeros. + // Also see the comments above the first constructor. + Sharder(const Eigen::SparseMatrix& matrix, + int num_shards, ThreadPool* thread_pool) + : Sharder(matrix.cols(), num_shards, thread_pool, [&matrix](int64_t col) { + return 1 + 1 * matrix.col(col).nonZeros(); + }) {} + + // Constructs a Sharder with the same thread pool as `other_sharder`, for + // problems with `num_elements` elements and unit mass. The number of shards + // will be approximately the same as that of `other_sharder`. Also see the + // comments on the first constructor. + Sharder(const Sharder& other_sharder, int64_t num_elements); + + // Sharders may be copied or moved. + // Moved-from objects may be in an invalid state. The only methods that may be + // called on a moved-from object are the destructor or operator=. + Sharder(const Sharder& other) = default; + Sharder(Sharder&& other) = default; + Sharder& operator=(const Sharder& other) = default; + Sharder& operator=(Sharder&& other) = default; + + int NumShards() const { return static_cast(shard_starts_.size()) - 1; } + + // The number of elements that are split into shards. + int64_t NumElements() const { return shard_starts_.back(); } + + int64_t ShardSize(int shard) const { + CHECK_GE(shard, 0); + CHECK_LT(shard, NumShards()); + return shard_starts_[shard + 1] - shard_starts_[shard]; + } + + int64_t ShardStart(int shard) const { + CHECK_GE(shard, 0); + CHECK_LT(shard, NumShards()); + return shard_starts_[shard]; + } + + int64_t ShardMass(int shard) const { + CHECK_GE(shard, 0); + CHECK_LT(shard, NumShards()); + return shard_masses_[shard]; + } + + // Runs a functor on each of the shards. + void ParallelForEachShard( + const std::function& func) const; + + // Runs a functor on each of the shards and sums the results. + double ParallelSumOverShards( + const std::function& func) const; + + // Runs a functor on each of the shards. Returns true iff all shards returned + // true. + bool ParallelTrueForAllShards( + const std::function& func) const; + + // Public for testing only. + const std::vector& ShardStartsForTesting() const { + return shard_starts_; + } + + private: + // Size: NumShards() + 1. The first entry is 0 and the last entry is + // NumElements(). The entries are sorted in increasing order and are unique. + // Note that {0} is valid and indicates zero elements split into zero shards. + std::vector shard_starts_; + // Size: NumShards(). The mass of each shard. + std::vector shard_masses_; + // NOT owned. May be nullptr. + ThreadPool* thread_pool_; +}; + +// Like matrix.transpose() * vector but executed in parallel using the given +// Sharder. The Sharder's size must match the matrix's number of columns. To +// ensure good parallelization the matrix passed here should have (roughly) the +// same location of non-zeros as the matrix passed to the Sharder's +// constructor. +Eigen::VectorXd TransposedMatrixVectorProduct( + const Eigen::SparseMatrix& matrix, + const Eigen::VectorXd& vector, const Sharder& Sharder); + +//////////////////////////////////////////////////////////////////////////////// +// The following functions use a Sharder to compute a vector operation in +// parallel. The Sharder should have the same size as the vector(s). For best +// performance the Sharder should have been created with the Sharder(int64_t, +// int, ThreadPool*) constructor. +//////////////////////////////////////////////////////////////////////////////// + +// Like dest += scale * increment +void AddScaledVector(double scale, const Eigen::VectorXd& increment, + const Sharder& sharder, Eigen::VectorXd& dest); + +// Like dest = vec. dest is resized if needed. +void AssignVector(const Eigen::VectorXd& vec, const Sharder& sharder, + Eigen::VectorXd& dest); + +// Returns a copy of vec. +Eigen::VectorXd CloneVector(const Eigen::VectorXd& vec, const Sharder& sharder); + +// Like dest = dest.cwiseProduct(scale). +void CoefficientWiseProductInPlace(const Eigen::VectorXd& scale, + const Sharder& sharder, + Eigen::VectorXd& dest); + +// Like dest = dest.cwiseQuotient(scale). +void CoefficientWiseQuotientInPlace(const Eigen::VectorXd& scale, + const Sharder& sharder, + Eigen::VectorXd& dest); + +// Like v1.dot(v2) +double Dot(const Eigen::VectorXd& v1, const Eigen::VectorXd& v2, + const Sharder& sharder); +// Like vector.lpNorm(), a.k.a. LInf norm. +double LInfNorm(const Eigen::VectorXd& vector, const Sharder& sharder); +// Like vector.lpNorm<1>(), a.k.a. L_1 norm. +double L1Norm(const Eigen::VectorXd& vector, const Sharder& sharder); +// Like vector.squaredNorm() +double SquaredNorm(const Eigen::VectorXd& vector, const Sharder& sharder); +// Like vector.norm() +double Norm(const Eigen::VectorXd& vector, const Sharder& sharder); + +// Like (vector1 - vector2).squaredNorm() +double SquaredDistance(const Eigen::VectorXd& vector1, + const Eigen::VectorXd& vector2, const Sharder& sharder); +// Like (vector1 - vector2).norm() +double Distance(const Eigen::VectorXd& vector1, const Eigen::VectorXd& vector2, + const Sharder& sharder); + +// ScaledL1Norm is omitted because it's not needed (yet). + +// LInf norm of a rescaled vector, i.e., +// vector.cwiseProduct(scale).lpNorm(). +double ScaledLInfNorm(const Eigen::VectorXd& vector, + const Eigen::VectorXd& scale, const Sharder& sharder); +// Squared L2 norm of a rescaled vector, i.e., +// vector.cwiseProduct(scale).squaredNorm(). +double ScaledSquaredNorm(const Eigen::VectorXd& vector, + const Eigen::VectorXd& scale, const Sharder& sharder); +// L2 norm of a rescaled vector, i.e., vector.cwiseProduct(scale).norm(). +double ScaledNorm(const Eigen::VectorXd& vector, const Eigen::VectorXd& scale, + const Sharder& sharder); + +//////////////////////////////////////////////////////////////////////////////// +// The functions below compute norms of the columns of a scaled matrix. The +// (i,j) entry of the scaled matrix equals matrix[i,j] * row_scaling_vec[i] * +// col_scaling_vec[j]. To ensure good parallelization the matrix passed here +// should have (roughly) the same location of non-zeros as the matrix passed to +// the Sharder's constructor. The Sharder's size must match the matrix's number +// of columns. +//////////////////////////////////////////////////////////////////////////////// + +// Computes the LInf norm of each column of a scaled matrix. +Eigen::VectorXd ScaledColLInfNorm( + const Eigen::SparseMatrix& matrix, + const Eigen::VectorXd& row_scaling_vec, + const Eigen::VectorXd& col_scaling_vec, const Sharder& sharder); +// Computes the L2 norm of each column of a scaled matrix. +Eigen::VectorXd ScaledColL2Norm( + const Eigen::SparseMatrix& matrix, + const Eigen::VectorXd& row_scaling_vec, + const Eigen::VectorXd& col_scaling_vec, const Sharder& sharder); + +// Checks if a matrix is diagonal. +bool IsDiagonal( + const Eigen::SparseMatrix& matrix, + const Sharder& sharder); + +} // namespace operations_research::pdlp + +#endif // PDLP_SHARDER_H_ diff --git a/ortools/pdlp/sharder_test.cc b/ortools/pdlp/sharder_test.cc new file mode 100644 index 0000000000..d9c7cdcd07 --- /dev/null +++ b/ortools/pdlp/sharder_test.cc @@ -0,0 +1,496 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/sharder.h" + +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/random/distributions.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/base/threadpool.h" + +namespace operations_research::pdlp { +namespace { + +using ::Eigen::DiagonalMatrix; +using ::Eigen::VectorXd; +using ::testing::DoubleNear; +using ::testing::ElementsAre; +using ::testing::Test; +using Shard = Sharder::Shard; + +// Returns a sparse representation of the matrix +// 7 -0.5 . . +// 1 . 3 2 +// -1 . . 5 +Eigen::SparseMatrix TestSparseMatrix() { + Eigen::SparseMatrix mat(3, 4); + mat.coeffRef(0, 0) = 7; + mat.coeffRef(0, 1) = -0.5; + mat.coeffRef(1, 0) = 1; + mat.coeffRef(1, 2) = 3; + mat.coeffRef(1, 3) = 2; + mat.coeffRef(2, 0) = -1; + mat.coeffRef(2, 3) = 5; + mat.makeCompressed(); + return mat; +} + +// A random matrix with a power law distribution of non-zeros per col. +// Specifically col i has order n/(i+1) non-zeros in expectation. +Eigen::SparseMatrix LargeSparseMatrix( + const int64_t size) { + // Deterministic RNG. + std::mt19937 rand(48709241); + std::vector> triplets; + for (int64_t col = 0; col < size; ++col) { + int64_t row = -1; + while (row < size) { + row += absl::Uniform(rand, 1, col + 2); + if (row < size) { + double value = absl::Uniform(rand, 1, 10); + triplets.emplace_back(row, col, value); + } + } + } + Eigen::SparseMatrix mat(size, size); + mat.setFromTriplets(triplets.begin(), triplets.end()); + return mat; +} + +// Verify that the given sharder is consistent and has shards of reasonable +// mass. Requires target_num_shards > 0 and !element_masses.empty(). +void VerifySharder(const Sharder& sharder, const int target_num_shards, + const std::vector& element_masses) { + int64_t num_elements = element_masses.size(); + int num_shards = sharder.NumShards(); + ASSERT_EQ(sharder.NumElements(), num_elements); + ASSERT_GE(num_elements, 1); + ASSERT_GE(num_shards, 1); + int64_t elements_so_far = 0; + for (int shard = 0; shard < num_shards; ++shard) { + int64_t shard_start = sharder.ShardStart(shard); + EXPECT_EQ(shard_start, elements_so_far) << " in shard: " << shard; + int64_t shard_mass = 0; + EXPECT_GE(sharder.ShardSize(shard), 1) << " in shard: " << shard; + EXPECT_GE(sharder.ShardMass(shard), 1) << " in shard: " << shard; + for (int64_t i = 0; i < sharder.ShardSize(shard); ++i) { + shard_mass += element_masses[shard_start + i]; + } + EXPECT_EQ(shard_mass, sharder.ShardMass(shard)) << " in shard: " << shard; + elements_so_far += sharder.ShardSize(shard); + } + EXPECT_EQ(elements_so_far, num_elements); + + EXPECT_LE(num_shards, 2 * target_num_shards); + ASSERT_GE(target_num_shards, 1); + const int64_t overall_mass = + std::accumulate(element_masses.begin(), element_masses.end(), int64_t{0}); + const int64_t max_element_mass = + *std::max_element(element_masses.begin(), element_masses.end()); + const int64_t upper_mass_limit = std::max( + max_element_mass, + MathUtil::CeilOfRatio(max_element_mass, int64_t{2}) + + MathUtil::CeilOfRatio(overall_mass, int64_t{target_num_shards})); + const int64_t lower_mass_limit = + overall_mass / target_num_shards - + MathUtil::CeilOfRatio(max_element_mass, int64_t{2}); + for (int shard = 0; shard < sharder.NumShards(); ++shard) { + EXPECT_LE(sharder.ShardMass(shard), upper_mass_limit) + << " in shard: " << shard; + if (shard + 1 < sharder.NumShards()) { + EXPECT_GE(sharder.ShardMass(shard), lower_mass_limit) + << " in shard: " << shard; + } + } +} + +TEST(SharderTest, SharderFromMatrix) { + Eigen::SparseMatrix mat = + TestSparseMatrix(); + Sharder sharder(mat, /*num_shards=*/2, nullptr); + VerifySharder(sharder, 2, {4, 2, 2, 3}); +} + +TEST(SharderTest, UniformSharder) { + Sharder sharder(/*num_elements=*/10, /*num_shards=*/3, nullptr); + VerifySharder(sharder, 3, {1, 1, 1, 1, 1, 1, 1, 1, 1, 1}); +} + +TEST(SharderTest, UniformSharderFromOtherSharder) { + Sharder other_sharder(/*num_elements=*/5, /*num_shards=*/3, nullptr); + Sharder sharder(other_sharder, /*num_elements=*/10); + VerifySharder(sharder, other_sharder.NumShards(), + {1, 1, 1, 1, 1, 1, 1, 1, 1, 1}); +} + +TEST(SharderTest, UniformSharderExcessiveShards) { + Sharder sharder(/*num_elements=*/5, /*num_shards=*/7, nullptr); + EXPECT_THAT(sharder.ShardStartsForTesting(), ElementsAre(0, 1, 2, 3, 4, 5)); + VerifySharder(sharder, 7, {1, 1, 1, 1, 1}); +} + +TEST(SharderTest, UniformSharderOneShard) { + Sharder sharder(/*num_elements=*/5, /*num_shards=*/1, nullptr); + EXPECT_THAT(sharder.ShardStartsForTesting(), ElementsAre(0, 5)); + VerifySharder(sharder, 1, {1, 1, 1, 1, 1}); +} + +TEST(SharderTest, UniformSharderOneElementVector) { + Sharder sharder(/*num_elements=*/1, /*num_shards=*/5, nullptr); + EXPECT_THAT(sharder.ShardStartsForTesting(), ElementsAre(0, 1)); + VerifySharder(sharder, 5, {1}); +} + +TEST(SharderTest, UniformSharderZeroElementVector) { + Sharder sharder(/*num_elements=*/0, /*num_shards=*/3, nullptr); + EXPECT_THAT(sharder.ShardStartsForTesting(), ElementsAre(0)); + EXPECT_EQ(sharder.NumShards(), 0); + EXPECT_EQ(sharder.NumElements(), 0); + sharder.ParallelForEachShard([](const Shard& /*shard*/) { + LOG(FATAL) << "There are no shards so this shouldn't be called."; + }); +} + +TEST(SharderTest, UniformSharderFromOtherZeroElementSharder) { + Sharder empty_sharder(/*num_elements=*/0, /*num_shards=*/3, nullptr); + EXPECT_THAT(empty_sharder.ShardStartsForTesting(), ElementsAre(0)); + EXPECT_EQ(empty_sharder.NumShards(), 0); + EXPECT_EQ(empty_sharder.NumElements(), 0); + Sharder sharder(empty_sharder, /*num_elements=*/5); + EXPECT_THAT(sharder.ShardStartsForTesting(), ElementsAre(0, 5)); + VerifySharder(sharder, 1, {1, 1, 1, 1, 1}); +} + +TEST(ParallelSumOverShards, SmallExample) { + VectorXd vec(3); + vec << 1, 2, 3; + Sharder sharder(vec.size(), /*num_shards=*/2, nullptr); + const double sum = sharder.ParallelSumOverShards( + [&vec](const Shard& shard) { return shard(vec).sum(); }); + EXPECT_EQ(sum, 6.0); +} + +TEST(ParallelSumOverShards, SmallExampleUsingVectorBlock) { + VectorXd vec(3); + vec << 1, 2, 3; + auto vec_block = vec.segment(1, 2); + Sharder sharder(vec_block.size(), /*num_shards=*/2, nullptr); + const double sum = sharder.ParallelSumOverShards( + [&vec_block](const Shard& shard) { return shard(vec_block).sum(); }); + EXPECT_EQ(sum, 5.0); +} + +TEST(ParallelSumOverShards, SmallExampleUsingConstVectorBlock) { + VectorXd vec(3); + vec << 1, 2, 3; + const VectorXd& const_vec = vec; + auto vec_block = const_vec.segment(1, 2); + Sharder sharder(vec_block.size(), /*num_shards=*/2, nullptr); + const double sum = sharder.ParallelSumOverShards( + [&vec_block](const Shard& shard) { return shard(vec_block).sum(); }); + EXPECT_EQ(sum, 5.0); +} + +TEST(ParallelSumOverShards, SmallExampleUsingDiagonalMatrix) { + DiagonalMatrix diag{{1, 2, 3}}; + Sharder sharder(diag.cols(), /*num_shards=*/2, nullptr); + const double sum = sharder.ParallelSumOverShards( + [&diag](const Shard& shard) { return shard(diag).diagonal().sum(); }); + EXPECT_EQ(sum, 6.0); +} + +TEST(ParallelSumOverShards, SmallExampleUsingDiagonalMatrixMultiplication) { + DiagonalMatrix diag{{1, 2, 3}}; + VectorXd vec{{1, 1, 1}}; + VectorXd answer(3); + Sharder sharder(diag.cols(), /*num_shards=*/2, nullptr); + sharder.ParallelForEachShard( + [&](const Shard& shard) { shard(answer) = shard(diag) * shard(vec); }); + EXPECT_THAT(answer, ElementsAre(1.0, 2.0, 3.0)); +} + +TEST(ParallelTrueForAllShards, SmallTrueExample) { + VectorXd vec(3); + vec << 1, 2, 3; + Sharder sharder(vec.size(), /*num_shards=*/2, nullptr); + const bool result = sharder.ParallelTrueForAllShards( + [&vec](const Shard& shard) { return (shard(vec).array() > 0.0).all(); }); + EXPECT_TRUE(result); +} + +TEST(ParallelTrueForAllShards, SmallFalseExample) { + VectorXd vec(3); + vec << 1, 2, 3; + Sharder sharder(vec.size(), /*num_shards=*/2, nullptr); + const bool result = sharder.ParallelTrueForAllShards( + [&vec](const Shard& shard) { return (shard(vec).array() < 2.5).all(); }); + EXPECT_FALSE(result); +} + +TEST(MatrixVectorProductTest, SmallExample) { + Eigen::SparseMatrix mat = + TestSparseMatrix(); + Sharder sharder(mat, /*num_shards=*/3, nullptr); + VectorXd vec(3); + vec << 1, 2, 3; + VectorXd ans = TransposedMatrixVectorProduct(mat, vec, sharder); + EXPECT_THAT(ans, ElementsAre(6.0, -0.5, 6.0, 19)); +} + +TEST(AddScaledVectorTest, SmallExample) { + Sharder sharder(3, /*num_shards=*/2, nullptr); + VectorXd vec1(3), vec2(3); + vec1 << 4, 5, 20; + vec2 << 1, 7, 3; + AddScaledVector(2.0, vec2, sharder, /*dest=*/vec1); + EXPECT_THAT(vec1, ElementsAre(6, 19, 26)); +} + +TEST(AssignVectorTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec1, vec2(3); + vec2 << 1, 7, 3; + AssignVector(vec2, sharder, /*dest=*/vec1); + EXPECT_THAT(vec1, ElementsAre(1, 7, 3)); +} + +TEST(CloneVectorTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + vec << 1, 7, 3; + EXPECT_THAT(CloneVector(vec, sharder), ElementsAre(1, 7, 3)); +} + +TEST(CoefficientWiseProductInPlaceTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec1(3), vec2(3); + vec1 << 4, 5, 20; + vec2 << 1, 2, 3; + CoefficientWiseProductInPlace(/*scale=*/vec2, sharder, + /*dest=*/vec1); + EXPECT_THAT(vec1, ElementsAre(4, 10, 60)); +} + +TEST(CoefficientWiseQuotientInPlaceTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec1(3), vec2(3); + vec1 << 4, 6, 20; + vec2 << 1, 2, 5; + CoefficientWiseQuotientInPlace(/*scale=*/vec2, sharder, + /*dest=*/vec1); + EXPECT_THAT(vec1, ElementsAre(4, 3, 4)); +} + +TEST(DotTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec1(3), vec2(3); + vec1 << 1, 2, 3; + vec2 << 4, 5, 6; + double ans = Dot(vec1, vec2, sharder); + EXPECT_THAT(ans, DoubleNear(4 + 10 + 18, 1.0e-13)); +} + +TEST(LInfNormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + vec << -1, 2, -3; + double ans = LInfNorm(vec, sharder); + EXPECT_EQ(ans, 3); +} + +TEST(LInfNormTest, EmptyExample) { + Sharder sharder(/*num_elements=*/0, /*num_shards=*/2, nullptr); + VectorXd vec(0); + double ans = LInfNorm(vec, sharder); + EXPECT_EQ(ans, 0); +} + +TEST(L1NormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + vec << -1, 2, -3; + double ans = L1Norm(vec, sharder); + EXPECT_EQ(ans, 6); +} + +TEST(L1NormTest, EmptyExample) { + Sharder sharder(/*num_elements=*/0, /*num_shards=*/2, nullptr); + VectorXd vec(0); + double ans = L1Norm(vec, sharder); + EXPECT_EQ(ans, 0); +} + +TEST(SquaredNormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + vec << 1, 2, 3; + double ans = SquaredNorm(vec, sharder); + EXPECT_THAT(ans, DoubleNear(1 + 4 + 9, 1.0e-13)); +} + +TEST(NormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + vec << 1, 2, 3; + double ans = Norm(vec, sharder); + EXPECT_THAT(ans, DoubleNear(std::sqrt(1 + 4 + 9), 1.0e-13)); +} + +TEST(SquaredDistanceTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec1(3); + VectorXd vec2(3); + vec1 << 1, 1, 1; + vec2 << 1, 2, 3; + double ans = SquaredDistance(vec1, vec2, sharder); + EXPECT_THAT(ans, DoubleNear(5, 1.0e-13)); +} + +TEST(DistanceTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec1(3); + VectorXd vec2(3); + vec1 << 1, 1, 1; + vec2 << 1, 2, 3; + double ans = Distance(vec1, vec2, sharder); + EXPECT_THAT(ans, DoubleNear(std::sqrt(5), 1.0e-13)); +} + +TEST(ScaledLInfNormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + VectorXd scale(3); + vec << -1, 2, -3; + scale << 4, 6, 1; + double ans = ScaledLInfNorm(vec, scale, sharder); + EXPECT_EQ(ans, 12); +} + +TEST(ScaledSquaredNormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + VectorXd scale(3); + vec << -1, 2, -3; + scale << 4, 6, 1; + double ans = ScaledSquaredNorm(vec, scale, sharder); + EXPECT_EQ(ans, 169); +} + +TEST(ScaledNormTest, SmallExample) { + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + VectorXd vec(3); + VectorXd scale(3); + vec << -1, 2, -3; + scale << 4, 6, 1; + double ans = ScaledNorm(vec, scale, sharder); + EXPECT_EQ(ans, std::sqrt(169)); +} + +TEST(ScaledColLInfNorm, SmallExample) { + Eigen::SparseMatrix mat = + TestSparseMatrix(); + Sharder sharder(mat, /*num_shards=*/3, nullptr); + VectorXd row_scaling_vec(3); + VectorXd col_scaling_vec(4); + row_scaling_vec << 1, -2, 1; + col_scaling_vec << 1, 2, -1, -1; + VectorXd answer = + ScaledColLInfNorm(mat, row_scaling_vec, col_scaling_vec, sharder); + EXPECT_THAT(answer, ElementsAre(7, 1, 6, 5)); +} + +TEST(ScaledColL2Norm, SmallExample) { + Eigen::SparseMatrix mat = + TestSparseMatrix(); + Sharder sharder(mat, /*num_shards=*/3, nullptr); + VectorXd row_scaling_vec(3); + VectorXd col_scaling_vec(4); + row_scaling_vec << 1, -2, 1; + col_scaling_vec << 1, 2, -1, -1; + VectorXd answer = + ScaledColL2Norm(mat, row_scaling_vec, col_scaling_vec, sharder); + EXPECT_THAT(answer, ElementsAre(std::sqrt(54), 1.0, 6.0, std::sqrt(41))); +} + +TEST(IsDiagonal, DiagonalSquareMatrix) { + Eigen::SparseMatrix mat(4, 4); + std::vector> matrix_triplets = { + {0, 0, 1.0}, {1, 1, 2.5}, {3, 3, -3}}; + mat.setFromTriplets(matrix_triplets.begin(), matrix_triplets.end()); + Sharder sharder(mat, /*num_shards=*/3, nullptr); + EXPECT_TRUE(IsDiagonal(mat, sharder)); +} + +TEST(IsDiagonal, DiagonalRectangularMatrix) { + Eigen::SparseMatrix mat(3, 5); + std::vector> matrix_triplets = { + {0, 0, 2}, {1, 1, -1}, {2, 2, 3}}; + mat.setFromTriplets(matrix_triplets.begin(), matrix_triplets.end()); + Sharder sharder(mat, /*num_shards=*/3, nullptr); + EXPECT_TRUE(IsDiagonal(mat, sharder)); +} + +TEST(IsDiagonal, NonDiagonalSquareMatrix) { + Eigen::SparseMatrix mat(3, 3); + std::vector> matrix_triplets = { + {0, 0, 2}, {0, 1, -1}, {1, 0, -1}, {2, 2, 1}}; + mat.setFromTriplets(matrix_triplets.begin(), matrix_triplets.end()); + Sharder sharder(mat, /*num_shards=*/3, nullptr); + EXPECT_FALSE(IsDiagonal(mat, sharder)); +} + +class VariousSizesTest : public testing::TestWithParam {}; + +TEST_P(VariousSizesTest, LargeMatVec) { + const int64_t size = GetParam(); + Eigen::SparseMatrix mat = + LargeSparseMatrix(size); + const int num_threads = 5; + const int shards_per_thread = 3; + ThreadPool pool("MatrixVectorProductTest", num_threads); + pool.StartWorkers(); + Sharder sharder(mat, shards_per_thread * num_threads, &pool); + VectorXd rhs = VectorXd::Random(size); + VectorXd direct = mat.transpose() * rhs; + VectorXd threaded = TransposedMatrixVectorProduct(mat, rhs, sharder); + EXPECT_LE((direct - threaded).norm(), 1.0e-8); +} + +TEST_P(VariousSizesTest, LargeVectors) { + const int64_t size = GetParam(); + const int num_threads = 5; + ThreadPool pool("SquaredNormTest", num_threads); + pool.StartWorkers(); + Sharder sharder(size, num_threads, &pool); + VectorXd vec = VectorXd::Random(size); + const double direct = vec.squaredNorm(); + const double threaded = SquaredNorm(vec, sharder); + EXPECT_THAT(threaded, DoubleNear(direct, size * 1.0e-14)); +} + +INSTANTIATE_TEST_SUITE_P(VariousSizesTestInstantiation, VariousSizesTest, + testing::Values(10, 1000, 100 * 1000)); + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/solve_log.proto b/ortools/pdlp/solve_log.proto new file mode 100644 index 0000000000..bc8f9188c4 --- /dev/null +++ b/ortools/pdlp/solve_log.proto @@ -0,0 +1,377 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// These proto messages are for collecting solve statistics, e.g., during +// experiments. + +syntax = "proto2"; + +package operations_research.pdlp; + +import "ortools/pdlp/solvers.proto"; + +// Easy-to-compute statistics for the quadratic program. +message QuadraticProgramStats { + optional int64 num_variables = 1; + optional int64 num_constraints = 2; + + // Minimum row and column infinity norms of the constraint matrix. If the + // constraint matrix is empty, the values returned are 0.0. + optional double constraint_matrix_col_min_l_inf_norm = 3; + optional double constraint_matrix_row_min_l_inf_norm = 4; + + optional int64 constraint_matrix_num_nonzeros = 5; + + // Max/min/mean of absolute values of nonzero elements in constraint matrix. + // Note that the maximum absolute value is also equal to the maximal row and + // column infinity norms of the constraint matrix. If the constraint matrix + // is empty, the values returned are 0 for the maximum and minimum, and + // NaN for the average. + optional double constraint_matrix_abs_max = 6; + optional double constraint_matrix_abs_min = 7; + optional double constraint_matrix_abs_avg = 8; + + // Statistics of the combined vector of the constraint lower and upper bounds. + // Given parallel lower and upper bounds vectors, the "combined bounds" vector + // takes the maximum absolute value of each pair of bounds, ignoring all non- + // finite values. The comment in solvers.proto:TerminationCriteria provides an + // example of the combined bounds vector. + // If there are no constraints, the values returned are 0 for the + // maximum, minimum, and l2 norm and NaN for the average. + optional double combined_bounds_max = 9; + optional double combined_bounds_min = 10; + optional double combined_bounds_avg = 11; + optional double combined_bounds_l2_norm = 24; + + // Number of finite variable bound gaps, which are the elementwise difference + // between the upper and lower bounds on primal feasible solutions. + optional int64 variable_bound_gaps_num_finite = 12; + + // Max/min/mean over all finite variable bound gaps. When there are no finite + // gaps, the values returned are 0 for the maximum and minimum and NaN for the + // average. + optional double variable_bound_gaps_max = 13; + optional double variable_bound_gaps_min = 14; + optional double variable_bound_gaps_avg = 15; + + // Statistics of the objective vector. + optional double objective_vector_abs_max = 16; + optional double objective_vector_abs_min = 17; + optional double objective_vector_abs_avg = 18; + optional double objective_vector_l2_norm = 23; + + optional int64 objective_matrix_num_nonzeros = 19; + + // Max/min/mean of absolute values of nonzero elements of the objective + // matrix. If the objective matrix is empty, the returned values are 0.0, 0.0 + // and NaN respectively. + optional double objective_matrix_abs_max = 20; + optional double objective_matrix_abs_min = 21; + optional double objective_matrix_abs_avg = 22; +} + +// Specifies whether a restart was performed on a given iteration. +enum RestartChoice { + RESTART_CHOICE_UNSPECIFIED = 0; + // No restart on this iteration. + RESTART_CHOICE_NO_RESTART = 1; + // The weighted average of iterates is cleared and reset to the current point. + // Note that from a mathematical perspective this can be equivalently viewed + // as restarting the algorithm but picking the restart point to be the current + // iterate. + RESTART_CHOICE_WEIGHTED_AVERAGE_RESET = 2; + // The algorithm is restarted at the average of iterates since the last + // restart. + RESTART_CHOICE_RESTART_TO_AVERAGE = 3; +} + +// Identifies the type of point used to compute the fields in a given proto; see +// ConvergenceInformation and InfeasibilityInformation. +enum PointType { + POINT_TYPE_UNSPECIFIED = 0; + // Current iterate (x_k, y_k). + POINT_TYPE_CURRENT_ITERATE = 1; + // Difference of iterates (x_{k+1} - x_k, y_{k+1} - y_k). + POINT_TYPE_ITERATE_DIFFERENCE = 2; + // Average of iterates since the last restart. + POINT_TYPE_AVERAGE_ITERATE = 3; + // There is no corresponding point. + POINT_TYPE_NONE = 4; + // Output of presolver. + POINT_TYPE_PRESOLVER_SOLUTION = 5; +} + +// Information measuring how close a candidate is to establishing feasibility +// and optimality; see also TerminationCriteria. +message ConvergenceInformation { + // Type of the candidate point described by this ConvergenceInformation. + optional PointType candidate_type = 1; + + // The primal objective. The primal need not be feasible. + optional double primal_objective = 2; + + // The dual objective. The dual need not be feasible. The dual objective + // includes the contributions from reduced costs. + optional double dual_objective = 3; + + // If possible (e.g., when all primal variables have lower and upper bounds), + // a correct dual bound. The value is negative infinity if no corrected dual + // bound is available. + optional double corrected_dual_objective = 4; + + // The maximum violation of any primal constraint, i.e., the l_∞ norm of the + // violations. + optional double l_inf_primal_residual = 5; + + // The l_2 norm of the violations of primal constraints. + optional double l2_primal_residual = 6; + + // The maximum violation of any dual constraint, i.e., the l_∞ norm of the + // violations. + optional double l_inf_dual_residual = 7; + + // The l_2 norm of the violations of dual constraints. + optional double l2_dual_residual = 8; + + // The maximum absolute value of the primal variables, i.e., the l_∞ norm. + // This is useful to detect when the primal iterates are diverging. Divergence + // of the primal variables could be an algorithmic issue, or indicate that the + // dual is infeasible. + optional double l_inf_primal_variable = 14; + + // The l_2 norm of the primal variables. + optional double l2_primal_variable = 15; + + // The maximum absolute value of the dual variables, i.e., the l_∞ norm. This + // is useful to detect when the dual iterates are diverging. Divergence of the + // dual variables could be an algorithmic issue, or indicate the primal is + // infeasible. + optional double l_inf_dual_variable = 16; + + // The l_2 norm of the dual variables. + optional double l2_dual_variable = 17; + + reserved 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23; +} + +// Information measuring how close a point is to establishing primal or dual +// infeasibility (i.e. has no solution); see also TerminationCriteria. +message InfeasibilityInformation { + // Let x_ray be the algorithm's estimate of the primal extreme ray where x_ray + // is a vector scaled such that its infinity norm is one. A simple and typical + // choice of x_ray is x_ray = x / | x |_∞ where x is the current primal + // iterate. For this value compute the maximum absolute error in the primal + // linear program with the right hand side and finite variable bounds set to + // zero. This error refers to both the linear constraints and sign constraints + // on the ray. + optional double max_primal_ray_infeasibility = 1; + + // The value of the linear part of the primal objective (ignoring additive + // constants) evaluated at x_ray, i.e., c' * x_ray where c is the objective + // coefficient vector. + optional double primal_ray_linear_objective = 2; + + // The l_∞ norm of the vector resulting from taking the quadratic matrix from + // primal objective and multiplying it by the primal variables. For linear + // programming problems this is zero. + optional double primal_ray_quadratic_norm = 3; + + // Let (y_ray, r_ray) be the algorithm's estimate of the dual and reduced cost + // extreme ray where (y_ray, r_ray) is a vector scaled such that its infinity + // norm is one. A simple and typical choice of y_ray is + // (y_ray, r_ray) = (y, r) / max(| y |_∞, | r |_∞) where y is the + // current dual iterate and r is the current dual reduced costs. + // Consider the quadratic program we are solving but with the objective (both + // quadratic and linear terms) set to zero. This forms a linear program + // (label this linear program (1)) with no objective. Take the dual of (1) and + // compute the maximum absolute value of the constraint error for + // (y_ray, r_ray) to obtain the value of max_dual_ray_infeasibility. + optional double max_dual_ray_infeasibility = 4; + + // The objective of the linear program labeled (1) in the previous paragraph. + optional double dual_ray_objective = 5; + + // Type of the point used to compute the InfeasibilityInformation. + optional PointType candidate_type = 6; + + reserved 7, 8; +} + +message PointMetadata { + // Type of the point that this metadata corresponds to. + optional PointType point_type = 1; + + // Projections of the primal solution onto random planes. + repeated double random_primal_projections = 2 [packed = true]; + + // Projections of the dual solution onto random planes. + repeated double random_dual_projections = 3 [packed = true]; + + // The number of primal variables that are not at their bounds. + optional int64 active_primal_variable_count = 4; + + // The number of dual variables that are not at their bounds. + optional int64 active_dual_variable_count = 5; + + // The number of primal variables that have a different bound status than they + // did at the last restart. + optional int64 active_primal_variable_change = 6; + + // The number of dual variables that have a different bound status than they + // did at the last restart. + optional int64 active_dual_variable_change = 7; +} + +// All values in IterationStats assume that the primal quadratic program is a +// minimization problem and the dual is a maximization problem. Problems should +// be transformed to this form if they are not already in this form. The dual +// vector is defined to be the vector of multipliers on the linear constraints, +// that is, excluding dual multipliers on variable bounds (reduced costs). +message IterationStats { + // The iteration number at which these stats were recorded. By convention, + // iteration counts start at 1, and the stats correspond to the solution + // *after* the iteration. Therefore stats from iteration 0 are the stats at + // the starting point. + optional int32 iteration_number = 1; + + // A set of statistics measuring how close a point is to establishing primal + // and dual feasibility and optimality. This field is repeated since there + // might be several different points that are considered. + repeated ConvergenceInformation convergence_information = 2; + + // A set of statistics measuring how close a point is to establishing primal + // or dual infeasibility (i.e., has no solution). This field is repeated since + // there might be several different points that could establish infeasibility. + repeated InfeasibilityInformation infeasibility_information = 3; + + // Auxiliary statistics for each type of point. + repeated PointMetadata point_metadata = 11; + + // The cumulative number of passes through the KKT matrix since the start of + // the solve. One pass is a multply by the constraint matrix, its transpose + // and the matrix that defines the quadratic part of the objective. + // + // For example, each iteration of mirror saddle prox contributes 2.0 to this + // sum. This is a float because it can include fractional passes through the + // data. For example, in an active set method we may only use a submatrix with + // 20% of the nonzeros of the KKT matrix at each iteration in which case 0.2 + // would be added to the total. + optional double cumulative_kkt_matrix_passes = 4; + + // The total number of rejected steps (e.g., within a line search procedure) + // since the start of the solve. + optional int32 cumulative_rejected_steps = 5; + + // The amount of time passed since we started solving the problem (see solver + // log solve_time_sec) which records total time. + optional double cumulative_time_sec = 6; + + // The kind of restart that occurred at this iteration, or NO_RESTART if a + // restart did not occur. + optional RestartChoice restart_used = 7; + + // Step size used at this iteration. Note that the step size used for the + // primal update is step_size / primal_weight, while the one used for the dual + // update is step_size * primal_weight. + optional double step_size = 8; + + // Primal weight controlling the relation between primal and dual step sizes. + // See field 'step_size' for a detailed description. + optional double primal_weight = 9; + + map method_specific_stats = 10; +} + +enum TerminationReason { + TERMINATION_REASON_UNSPECIFIED = 0; + TERMINATION_REASON_OPTIMAL = 1; + // Note in this situation the dual could be either unbounded or infeasible. + TERMINATION_REASON_PRIMAL_INFEASIBLE = 2; + // Note in this situation the primal could be either unbounded or infeasible. + TERMINATION_REASON_DUAL_INFEASIBLE = 3; + TERMINATION_REASON_TIME_LIMIT = 4; + TERMINATION_REASON_ITERATION_LIMIT = 5; + TERMINATION_REASON_KKT_MATRIX_PASS_LIMIT = 8; + TERMINATION_REASON_NUMERICAL_ERROR = 6; + // Indicates that the solver detected invalid problem data, e.g., inconsistent + // bounds. + TERMINATION_REASON_INVALID_PROBLEM = 9; + // Indicates that an invalid value for the parameters was detected. + TERMINATION_REASON_INVALID_PARAMETER = 10; + TERMINATION_REASON_OTHER = 7; + // Primal or dual infeasibility was detected (e.g. by presolve) but no + // certificate is available. + TERMINATION_REASON_PRIMAL_OR_DUAL_INFEASIBLE = 11; +} + +message SolveLog { + // The name of the optimization problem. + optional string instance_name = 1; + + // If solved with PDLP, the parameters for this solve. + optional PrimalDualHybridGradientParams params = 14; + + // The reason that the solve terminated. + optional TerminationReason termination_reason = 3; + + // Optional extra information about the termination reason. + optional string termination_string = 4; + + // The total number of iterations during the solve. + optional int32 iteration_count = 5; + + // Time for preprocessing (everything before iteration 0). This is also + // included in solve_time_sec. + optional double preprocessing_time_sec = 13; + + // The runtime of the solve. Note: This should not be used for comparing + // methods unless care is taken to control for noise in runtime measurement. + optional double solve_time_sec = 6; + + // The IterationStats for the final iteration of the solver. + // NOTE: Regardless of preprocessing (i.e. scaling or presolve) the optimality + // or infeasibility information is evaluated with respect to the original + // problem. + optional IterationStats solution_stats = 8; + + // The type of the output point that the solver returned. The quality of the + // point is reported in the corresponding entry of + // solution_stats.convergence_information and/or + // solution_stats.infeasibility_information. If termination_reason is + // TERMINATION_REASON_OPTIMAL, it's guaranteed that the corresponding entry of + // solution_stats.convergence_information satisfies the optimality conditions. + // Similarly, if termination_reason is either + // TERMINATION_REASON_PRIMAL_INFEASIBLE or TERMINATION_REASON_DUAL_INFEASIBLE + // the corresponding entry of solution_stats.infeasibility_information + // satisifes conditions for declaring primal or dual infeasibility, + // respectively. + // If termination_reason is anything else, e.g. TERMINATION_REASON_TIME_LIMIT + // or TERMINATION_REASON_PRIMAL_OR_DUAL_INFEASIBLE, the solution may not + // satisfy the optimality or infeasibility conditions. + optional PointType solution_type = 10; + + // A history of iteration stats for the solve. The iteration_number fields + // should be in increasing order. The frequency at which these stats should be + // recorded is not specified. This field is "more" optional than the others + // because it often significantly increases the size of the message, and + // because the information may not be available for third-party solvers. + repeated IterationStats iteration_stats = 7; + + // Statistics of the original problem. + optional QuadraticProgramStats original_problem_stats = 11; + + // Statistics of the problem after preprocessing. + optional QuadraticProgramStats preprocessed_problem_stats = 12; + + reserved 2, 9; +} diff --git a/ortools/pdlp/solvers.proto b/ortools/pdlp/solvers.proto new file mode 100644 index 0000000000..c30b1b8066 --- /dev/null +++ b/ortools/pdlp/solvers.proto @@ -0,0 +1,319 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto2"; + +package operations_research.pdlp; + +import "ortools/glop/parameters.proto"; + +enum OptimalityNorm { + OPTIMALITY_NORM_UNSPECIFIED = 0; + OPTIMALITY_NORM_L_INF = 1; // The infinity norm. + OPTIMALITY_NORM_L2 = 2; // The Euclidean norm. +} + +// A description of solver termination criteria. The criteria are defined in +// terms of the quantities recorded in IterationStats in solve_log.proto. + +// Relevant readings on infeasibility certificates: +// (1) https://docs.mosek.com/modeling-cookbook/qcqo.html provides references +// explaining why the primal rays imply dual infeasibility and dual rays imply +// primal infeasibility. +// (2) The termination criteria for Mosek's linear programming optimizer +// https://docs.mosek.com/9.0/pythonfusion/solving-linear.html. +// (3) The termination criteria for OSQP is in section 3.3 of +// https://web.stanford.edu/~boyd/papers/pdf/osqp.pdf. +// (4) The termination criteria for SCS is in section 3.5 of +// https://arxiv.org/pdf/1312.3039.pdf. +message TerminationCriteria { + // The norm that we are measuring the optimality criteria in. + optional OptimalityNorm optimality_norm = 1 [default = OPTIMALITY_NORM_L2]; + + // Define CombineBounds(l,u) as a function that takes two equal-length vectors + // and returns a vector whose elements are max{|l_i|, |u_i|} where non-finite + // values of l_i and u_i are replaced with zero. + // For example, CombineBounds([-2, -Inf, 5], [1, 10, 5]) == [2, 10, 5]. + + // Recalling the definitions from + // https://developers.google.com/optimization/lp/pdlp_math, c is the linear + // objective vector, l^c are the constraint lower bounds, and u^c are the + // constraint upper bounds. Define b^c = CombineBounds(l^c, u^c). + + // Let p correspond to the norm we are using as specified by optimality_norm. + // If the algorithm terminates with termination_reason = + // TERMINATION_REASON_OPTIMAL then + // + // | primal_objective - dual_objective | <= eps_optimal_absolute + + // eps_optimal_relative * ( | primal_objective | + | dual_objective | ) + // norm(primal_residual, p) <= eps_optimal_absolute + eps_optimal_relative * + // norm(b, p) + // norm(dual_residual, p) <= eps_optimal_absolute + eps_optimal_relative * + // norm(c, p) + // It is possible to prove that a solution satisfying the above conditions + // also satisfies SCS's optimality conditions (see link above) with ϵ_pri = + // ϵ_dual = ϵ_gap = eps_optimal_absolute = eps_optimal_relative. (ϵ_pri, + // ϵ_dual, and ϵ_gap are SCS's parameters). + + // Absolute tolerance on the duality gap, primal feasibility, and dual + // feasibility. + optional double eps_optimal_absolute = 2 [default = 1.0e-6]; + + // Relative tolerance on the duality gap, primal feasibility, and dual + // feasibility. + optional double eps_optimal_relative = 3 [default = 1.0e-6]; + + // If the following two conditions hold we say that we have obtained an + // approximate dual ray, which is an approximate certificate of primal + // infeasibility. + // (1) dual_ray_objective > 0, + // (2) max_dual_ray_infeasibility / dual_ray_objective <= + // eps_primal_infeasible. + optional double eps_primal_infeasible = 4 [default = 1.0e-8]; + + // If the following three conditions hold we say we have obtained an + // approximate primal ray, which is an approximate certificate of dual + // infeasibility. + // (1) primal_ray_linear_objective < 0, + // (2) max_primal_ray_infeasibility / (-primal_ray_linear_objective) <= + // eps_dual_infeasible + // (3) primal_ray_quadratic_norm / (-primal_ray_linear_objective) <= + // eps_dual_infeasible. + optional double eps_dual_infeasible = 5 [default = 1.0e-8]; + + // If termination_reason = TERMINATION_REASON_TIME_LIMIT then the solver has + // taken at least time_sec_limit time. + optional double time_sec_limit = 6 [default = inf]; + + // If termination_reason = TERMINATION_REASON_ITERATION_LIMIT then the solver + // has taken at least iterations_limit iterations. + optional int32 iteration_limit = 7 [default = 2147483647]; + + // If termination_reason = TERMINATION_REASON_KKT_MATRIX_PASS_LIMIT then + // cumulative_kkt_matrix_passes is at least kkt_pass_limit. + optional double kkt_matrix_pass_limit = 8 [default = inf]; +} + +message AdaptiveLinesearchParams { + // At the end of each iteration, regardless of whether the step was accepted + // or not, the adaptive rule updates the step_size as the minimum of two + // potential step sizes defined by the following two exponents. + + // The step size reduction exponent defines a step size given by + // (1 - (total_steps_attempted + 1)^(-step_size_reduction_exponent)) * + // step_size_limit where step_size_limit is the maximum allowed step size at + // the current iteration. + optional double step_size_reduction_exponent = 1 [default = 0.3]; + + // The step size growth exponent defines a step size given by (1 + + // (total_steps_attempted + 1)^(-step_size_growth_exponent)) * step_size_. + optional double step_size_growth_exponent = 2 [default = 0.6]; +} + +message MalitskyPockParams { + // At every inner iteration the algorithm can decide to accept the step size + // or to update it to step_size = step_size_downscaling_factor * step_size. + // This parameter should lie between 0 and 1. The default is the value used in + // Malitsky and Pock (2016). + optional double step_size_downscaling_factor = 1 [default = 0.7]; + + // Contraction factor used in the linesearch condition of Malitsky and Pock. + // A step size is accepted if primal_weight * primal_stepsize * + // norm(constraint_matrix' * (next_dual - current_dual)) is less + // than linearsearch_contraction_factor * norm(next_dual - current_dual). + // The default is the value used in Malitsky and Pock (2016). + optional double linesearch_contraction_factor = 2 [default = 0.99]; + + // Malitsky and Pock linesearch rule permits an arbitrary choice of the first + // step size guess within an interval [m, M]. This parameter determines where + // in that interval to pick the step size. In particular, the next stepsize is + // given by m + step_size_interpolation*(M - m). The default is the value used + // in Malitsky and Pock (2016). + optional double step_size_interpolation = 3 [default = 1]; +} + +// Parameters for PrimalDualHybridGradient() in primal_dual_hybrid_gradient.h +message PrimalDualHybridGradientParams { + enum RestartStrategy { + RESTART_STRATEGY_UNSPECIFIED = 0; + // No restarts are performed. The average solution is cleared every major + // iteration, but the current solution is not changed. + NO_RESTARTS = 1; + // On every major iteration, the current solution is reset to the average + // since the last major iteration. + EVERY_MAJOR_ITERATION = 2; + // A heuristic that adaptively decides on every major iteration whether to + // restart (this is forced approximately on increasing powers-of-two + // iterations), and if so to the current or to the average, based on + // reduction in a potential function. The rule more or less follows the + // description of the adaptive restart scheme in + // https://arxiv.org/pdf/2106.04756.pdf. + ADAPTIVE_HEURISTIC = 3; + // A distance-based restarting scheme that restarts the algorithm whenever + // an appropriate potential function is reduced sufficiently. This check + // happens at every major iteration. + // TODO(user): Cite paper for the restart strategy and definition of the + // potential function, when available. + ADAPTIVE_DISTANCE_BASED = 4; + } + + enum LinesearchRule { + LINESEARCH_RULE_UNSPECIFIED = 0; + // Applies the heuristic rule presented in Section 3.1 of + // https://arxiv.org/pdf/2106.04756.pdf (further generalized to QP). There + // is not a proof of convergence for it. It is usually the fastest in + // practice but sometimes behaves poorly. + ADAPTIVE_LINESEARCH_RULE = 1; + // Applies Malitsky & Pock linesearch rule. This guarantees an + // ergodic O(1/N) convergence rate https://arxiv.org/pdf/1608.08883.pdf. + // This is provably convergent, but only supports linear problems and + // quadratic problems with a diagonal objective matrix. + MALITSKY_POCK_LINESEARCH_RULE = 2; + // Uses a constant step size corresponding to an estimate of the maximum + // singular value of the constraint matrix. + CONSTANT_STEP_SIZE_RULE = 3; + } + + optional TerminationCriteria termination_criteria = 1; + + // The number of threads to use. Must be positive. + optional int32 num_threads = 2 [default = 1]; + + // For more efficient parallel computation, the matrices and vectors are + // divided (virtually) into num_shards shards. Results are computed + // independently for each shard and then combined. As a consequence, the order + // of computation, and hence floating point roundoff, depends on the number of + // shards so reproducible results require using the same value for num_shards. + // However, for efficiency num_shards should a be at least num_threads, and + // preferably at least 4*num_threads to allow better load balancing. If + // num_shards is positive, the computation will use that many shards. + // Otherwise a default that depends on num_threads will be used. + optional int32 num_shards = 27 [default = 0]; + + // If true, the iteration_stats field of the SolveLog output will be populated + // at every iteration. Note that we only compute solution statistics at + // termination checks. Setting this parameter to true may substantially + // increase the size of the output. + optional bool record_iteration_stats = 3; + + // The frequency at which extra work is performed to make major algorithmic + // decisions, e.g., performing restarts and updating the primal weight. Major + // iterations also trigger a termination check. For best performance using + // the NO_RESTARTS or EVERY_MAJOR_ITERATION rule, one should perform a + // log-scale grid search over this parameter. ADAPTIVE_HEURISTIC is mostly + // insensitive to this value. + optional int32 major_iteration_frequency = 4 [default = 64]; + + // The frequency (based on a counter reset every major iteration) to check for + // termination (involves extra work) and log iteration stats. Termination + // checks do not affect algorithmic progress unless termination is triggered. + optional int32 termination_check_frequency = 5 [default = 64]; + + optional RestartStrategy restart_strategy = 6 [default = ADAPTIVE_HEURISTIC]; + + // This parameter controls exponential smoothing of log(primal_weight) when a + // primal weight update occurs (i.e., when the ratio of primal and dual step + // sizes is adjusted). At 0.0, the primal weight will be frozen at its initial + // value and there will be no dynamic updates in the algorithm. At 1.0, there + // is no smoothing in the updates. + optional double primal_weight_update_smoothing = 7 [default = 0.5]; + + // The initial value of the primal weight (i.e., the ratio of primal and dual + // step sizes). The primal weight remains fixed throughout the solve if + // primal_weight_update_smoothing = 0.0. If unset, the default is the ratio of + // the norm of the objective vector to the L2 norm of the combined constraint + // bounds vector (as defined above). If this ratio is not finite and positive, + // then the default is 1.0 instead. + optional double initial_primal_weight = 8; + + message PresolveOptions { + // If true runs glop's presolver on the given instance prior to solving. + // Note that convergence criteria are interpreted with respect to the + // presolved problem. Certificates may not be available if presolve detects + // infeasibility. + optional bool use_glop = 1; + + // Parameters to control glop's presolver. Only used when use_glop is true. + // These are merged with and override Bisco's defaults. + optional operations_research.glop.GlopParameters glop_parameters = 2; + } + optional PresolveOptions presolve_options = 16; + + // Number of L_infinity Ruiz rescaling iterations to apply to the constraint + // matrix. Zero disables this rescaling pass. + optional int32 l_inf_ruiz_iterations = 9 [default = 5]; + + // If true, applies L_2 norm rescaling after the Ruiz rescaling. Heuristically + // this has been found to help convergence. + optional bool l2_norm_rescaling = 10 [default = true]; + + // For ADAPTIVE_HEURISTIC and ADAPTIVE_DISTANCE_BASED only: A relative + // reduction in the potential function by this amount always triggers a + // restart. Must be between 0.0 and 1.0. + optional double sufficient_reduction_for_restart = 11 [default = 0.1]; + + // For ADAPTIVE_HEURISTIC only: A relative reduction in the potential function + // by this amount triggers a restart if, additionally, the quality of the + // iterates appears to be getting worse. The value must be in the interval + // [sufficient_reduction_for_restart, 1). Smaller values make restarts less + // frequent, and larger values make them more frequent. + optional double necessary_reduction_for_restart = 17 [default = 0.9]; + + // Linesearch rule applied at each major iteration. + optional LinesearchRule linesearch_rule = 12 + [default = ADAPTIVE_LINESEARCH_RULE]; + + optional AdaptiveLinesearchParams adaptive_linesearch_parameters = 18; + + optional MalitskyPockParams malitsky_pock_parameters = 19; + + // Scaling factor applied to the initial step size (all step sizes if + // linesearch_rule == CONSTANT_STEP_SIZE_RULE). + optional double initial_step_size_scaling = 25 [default = 1.0]; + + reserved 13, 14, 15; + + // Seeds for generating (pseudo-)random projections of iterates during + // termination checks. For each seed, the projection of the primal and dual + // solutions onto random planes in primal and dual space will be computed and + // added the IterationStats if record_iteration_stats is true. The random + // planes generated will be determined by the seeds, the primal and dual + // dimensions, and num_threads. + repeated double random_projection_seeds = 20 [packed = true]; + + // When computing relative feasibility norms, constraint bounds with absolute + // value at least this threshold are treated as infinite, and hence not + // included in the relative feasibility norm. + // NOTE: This affects only the relative convergence criteria (and problem + // statistics LOG()ed at the start of the run). A smaller value makes the + // relative convergence criteria stronger. + optional double infinite_constraint_bound_threshold = 22 [default = inf]; + + // When solving QPs with diagonal objective matrices, this option can be + // turned on to enable an experimental solver that avoids linearization of the + // quadratic term. The `diagonal_qp_solver_accuracy` parameter controls the + // solve accuracy. + // TODO(user): Turn this option on by default for quadratic + // programs after numerical evaluation. + optional bool use_diagonal_qp_trust_region_solver = 23 [default = false]; + + // The solve tolerance of the experimental trust region solver for diagonal + // QPs, controlling the accuracy of binary search over a one-dimensional + // scaling parameter. Smaller values imply smaller relative error of the final + // solution vector. + // TODO(user): Find an expression for the final relative error. + optional double diagonal_qp_trust_region_solver_tolerance = 24 + [default = 1e-8]; + + reserved 21; +} diff --git a/ortools/pdlp/solvers_proto_validation.cc b/ortools/pdlp/solvers_proto_validation.cc new file mode 100644 index 0000000000..a5a910a5aa --- /dev/null +++ b/ortools/pdlp/solvers_proto_validation.cc @@ -0,0 +1,159 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/solvers_proto_validation.h" + +#include "absl/status/status.h" +#include "ortools/base/status_macros.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { + +using ::absl::InvalidArgumentError; +using ::absl::OkStatus; + +absl::Status ValidateTerminationCriteria(const TerminationCriteria& criteria) { + if (criteria.optimality_norm() != OPTIMALITY_NORM_L_INF && + criteria.optimality_norm() != OPTIMALITY_NORM_L2) { + return InvalidArgumentError("invalid value for optimality_norm"); + } + if (criteria.eps_optimal_absolute() < 0) { + return InvalidArgumentError("eps_optimal_absolute must be non-negative"); + } + if (criteria.eps_optimal_relative() < 0) { + return InvalidArgumentError("eps_optimal_relative must be non-negative"); + } + if (criteria.eps_primal_infeasible() < 0) { + return InvalidArgumentError("eps_primal_infeasible must be non-negative"); + } + if (criteria.eps_dual_infeasible() < 0) { + return InvalidArgumentError("eps_dual_infeasible must be non-negative"); + } + if (criteria.time_sec_limit() < 0) { + return InvalidArgumentError("time_sec_limit must be non-negative"); + } + if (criteria.iteration_limit() < 0) { + return InvalidArgumentError("iteration_limit must be non-negative"); + } + if (criteria.kkt_matrix_pass_limit() < 0) { + return InvalidArgumentError("kkt_matrix_pass_limit must be non-negative"); + } + return OkStatus(); +} + +absl::Status ValidateAdaptiveLinesearchParams( + const AdaptiveLinesearchParams& params) { + if (params.step_size_reduction_exponent() <= 0) { + return InvalidArgumentError( + "step_size_reduction_exponent must be positive"); + } + if (params.step_size_growth_exponent() <= 0) { + return InvalidArgumentError("step_size_growth_exponent must be positive"); + } + return OkStatus(); +} + +absl::Status ValidateMalitskyPockParams(const MalitskyPockParams& params) { + if (params.step_size_downscaling_factor() <= 0 || + params.step_size_downscaling_factor() >= 1) { + return InvalidArgumentError( + "step_size_downscaling_factor must be between 0 and 1 exclusive"); + } + if (params.linesearch_contraction_factor() <= 0 || + params.linesearch_contraction_factor() >= 1) { + return InvalidArgumentError( + "linesearch_contraction_factor must be between 0 and 1 exclusive"); + } + if (params.step_size_interpolation() < 0) { + return InvalidArgumentError("step_size_interpolation must be non-negative"); + } + return OkStatus(); +} + +absl::Status ValidatePrimalDualHybridGradientParams( + const PrimalDualHybridGradientParams& params) { + RETURN_IF_ERROR(ValidateTerminationCriteria(params.termination_criteria())) + << "termination_criteria invalid"; + if (params.num_threads() <= 0) { + return InvalidArgumentError("num_threads must be positive"); + } + if (params.major_iteration_frequency() <= 0) { + return InvalidArgumentError("major_iteration_frequency must be positive"); + } + if (params.termination_check_frequency() <= 0) { + return InvalidArgumentError("termination_check_frequency must be positive"); + } + if (params.restart_strategy() != + PrimalDualHybridGradientParams::NO_RESTARTS && + params.restart_strategy() != + PrimalDualHybridGradientParams::EVERY_MAJOR_ITERATION && + params.restart_strategy() != + PrimalDualHybridGradientParams::ADAPTIVE_HEURISTIC && + params.restart_strategy() != + PrimalDualHybridGradientParams::ADAPTIVE_DISTANCE_BASED) { + return InvalidArgumentError("invalid restart_strategy"); + } + if (params.primal_weight_update_smoothing() < 0 || + params.primal_weight_update_smoothing() > 1) { + return InvalidArgumentError( + "primal_weight_update_smoothing must be between 0 and 1 inclusive"); + } + if (params.has_initial_primal_weight() && + params.initial_primal_weight() <= 0) { + return InvalidArgumentError( + "initial_primal_weight must be positive if specified"); + } + if (params.l_inf_ruiz_iterations() < 0) { + return InvalidArgumentError("l_inf_ruiz_iterations must be non-negative"); + } + if (params.sufficient_reduction_for_restart() <= 0 || + params.sufficient_reduction_for_restart() >= 1) { + return InvalidArgumentError( + "sufficient_reduction_for_restart must be between 0 and 1 exclusive"); + } + if (params.necessary_reduction_for_restart() < + params.sufficient_reduction_for_restart() || + params.necessary_reduction_for_restart() >= 1) { + return InvalidArgumentError( + "necessary_reduction_for_restart must be in the interval " + "[sufficient_reduction_for_restart, 1)"); + } + if (params.linesearch_rule() != + PrimalDualHybridGradientParams::ADAPTIVE_LINESEARCH_RULE && + params.linesearch_rule() != + PrimalDualHybridGradientParams::MALITSKY_POCK_LINESEARCH_RULE && + params.linesearch_rule() != + PrimalDualHybridGradientParams::CONSTANT_STEP_SIZE_RULE) { + return InvalidArgumentError("invalid linesearch_rule"); + } + RETURN_IF_ERROR( + ValidateAdaptiveLinesearchParams(params.adaptive_linesearch_parameters())) + << "adaptive_linesearch_parameters invalid"; + RETURN_IF_ERROR(ValidateMalitskyPockParams(params.malitsky_pock_parameters())) + << "malitsky_pock_parameters invalid"; + if (params.initial_step_size_scaling() <= 0.0) { + return InvalidArgumentError("initial_step_size_scaling must be positive"); + } + + if (params.infinite_constraint_bound_threshold() < 0.0) { + return InvalidArgumentError( + "infinite_constraint_bound_threshold must be non-negative"); + } + if (params.diagonal_qp_trust_region_solver_tolerance() < 0.0) { + return InvalidArgumentError( + "diagonal_qp_trust_region_solver_tolerance must be non-negative"); + } + return OkStatus(); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/solvers_proto_validation.h b/ortools/pdlp/solvers_proto_validation.h new file mode 100644 index 0000000000..4bfd07ddce --- /dev/null +++ b/ortools/pdlp/solvers_proto_validation.h @@ -0,0 +1,44 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Validation utilities for solvers.proto. + +#ifndef PDLP_SOLVERS_PROTO_VALIDATION_H_ +#define PDLP_SOLVERS_PROTO_VALIDATION_H_ + +#include "absl/status/status.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { + +// Returns InvalidArgument if the proto contains invalid values. Returns +// OkStatus otherwise. +absl::Status ValidateTerminationCriteria(const TerminationCriteria& criteria); + +// Returns InvalidArgument if the proto contains invalid values. Returns +// OkStatus otherwise. +absl::Status ValidateAdaptiveLinesearchParams( + const AdaptiveLinesearchParams& params); + +// Returns InvalidArgument if the proto contains invalid values. Returns +// OkStatus otherwise. +absl::Status ValidateMalitskyPockParams(const MalitskyPockParams& params); + +// Returns InvalidArgument if the proto contains invalid values. Returns +// OkStatus otherwise. +absl::Status ValidatePrimalDualHybridGradientParams( + const PrimalDualHybridGradientParams& params); + +} // namespace operations_research::pdlp + +#endif // PDLP_SOLVERS_PROTO_VALIDATION_H_ diff --git a/ortools/pdlp/solvers_proto_validation_test.cc b/ortools/pdlp/solvers_proto_validation_test.cc new file mode 100644 index 0000000000..3fbd897fa0 --- /dev/null +++ b/ortools/pdlp/solvers_proto_validation_test.cc @@ -0,0 +1,334 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/solvers_proto_validation.h" + +#include "absl/status/status.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { +namespace { + +using ::testing::HasSubstr; + +TEST(ValidateTerminationCriteria, DefaultIsValid) { + TerminationCriteria criteria; + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_TRUE(status.ok()) << status; +} + +TEST(ValidateTerminationCriteria, BadOptimalityNorm) { + TerminationCriteria criteria; + criteria.set_optimality_norm(OPTIMALITY_NORM_UNSPECIFIED); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("optimality_norm")); +} + +TEST(ValidateTerminationCriteria, BadEpsOptimalAbsolute) { + TerminationCriteria criteria; + criteria.set_eps_optimal_absolute(-1.0); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("eps_optimal_absolute")); +} + +TEST(ValidateTerminationCriteria, BadEpsOptimalRelative) { + TerminationCriteria criteria; + criteria.set_eps_optimal_relative(-1.0); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("eps_optimal_relative")); +} + +TEST(ValidateTerminationCriteria, BadEpsPriamlInfeasible) { + TerminationCriteria criteria; + criteria.set_eps_primal_infeasible(-1.0); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("eps_primal_infeasible")); +} + +TEST(ValidateTerminationCriteria, BadEpsDualInfeasible) { + TerminationCriteria criteria; + criteria.set_eps_dual_infeasible(-1.0); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("eps_dual_infeasible")); +} + +TEST(ValidateTerminationCriteria, BadTimeSecLimit) { + TerminationCriteria criteria; + criteria.set_time_sec_limit(-1.0); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("time_sec_limit")); +} + +TEST(ValidateTerminationCriteria, BadIterationLimit) { + TerminationCriteria criteria; + criteria.set_iteration_limit(-1); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("iteration_limit")); +} + +TEST(ValidateTerminationCriteria, BadKktMatrixPassLimit) { + TerminationCriteria criteria; + criteria.set_kkt_matrix_pass_limit(-1.0); + const absl::Status status = ValidateTerminationCriteria(criteria); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("kkt_matrix_pass_limit")); +} + +TEST(ValidateAdaptiveLinesearchParams, DefaultIsValid) { + AdaptiveLinesearchParams params; + const absl::Status status = ValidateAdaptiveLinesearchParams(params); + EXPECT_TRUE(status.ok()) << status; +} + +TEST(ValidateAdaptiveLinesearchParams, BadReductionExponent) { + AdaptiveLinesearchParams params; + params.set_step_size_reduction_exponent(0.0); + const absl::Status status = ValidateAdaptiveLinesearchParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("step_size_reduction_exponent")); +} + +TEST(ValidateAdaptiveLinesearchParams, BadGrowthExponent) { + AdaptiveLinesearchParams params; + params.set_step_size_growth_exponent(0.0); + const absl::Status status = ValidateAdaptiveLinesearchParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("step_size_growth_exponent")); +} + +TEST(ValidateMalitskyPockParams, DefaultIsValid) { + MalitskyPockParams params; + const absl::Status status = ValidateMalitskyPockParams(params); + EXPECT_TRUE(status.ok()) << status; +} + +TEST(ValidateMalitskyPockParams, BadDownscalingFactor) { + MalitskyPockParams params0; + params0.set_step_size_downscaling_factor(0.0); + const absl::Status status0 = ValidateMalitskyPockParams(params0); + EXPECT_EQ(status0.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status0.message(), HasSubstr("step_size_downscaling_factor")); + + MalitskyPockParams params1; + params1.set_step_size_downscaling_factor(1.0); + const absl::Status status1 = ValidateMalitskyPockParams(params1); + EXPECT_EQ(status1.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status1.message(), HasSubstr("step_size_downscaling_factor")); +} + +TEST(ValidateMalitskyPockParams, BadConstractionFactor) { + MalitskyPockParams params0; + params0.set_linesearch_contraction_factor(0.0); + const absl::Status status0 = ValidateMalitskyPockParams(params0); + EXPECT_EQ(status0.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status0.message(), HasSubstr("linesearch_contraction_factor")); + + MalitskyPockParams params1; + params1.set_linesearch_contraction_factor(1.0); + const absl::Status status1 = ValidateMalitskyPockParams(params1); + EXPECT_EQ(status1.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status1.message(), HasSubstr("linesearch_contraction_factor")); +} + +TEST(ValidateMalitskyPockParams, BadStepSizeInterpolation) { + MalitskyPockParams params; + params.set_step_size_interpolation(-1.0); + const absl::Status status = ValidateMalitskyPockParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("step_size_interpolation")); +} + +TEST(ValidatePrimalDualHybridGradientParams, DefaultIsValid) { + PrimalDualHybridGradientParams params; + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_TRUE(status.ok()) << status; +} + +TEST(ValidatePrimalDualHybridGradientParams, BadTerminationCriteria) { + PrimalDualHybridGradientParams params; + params.mutable_termination_criteria()->set_eps_dual_infeasible(-1); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("eps_dual_infeasible")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadNumThreads) { + PrimalDualHybridGradientParams params; + params.set_num_threads(0); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("num_threads")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadMajorIterationFrequency) { + PrimalDualHybridGradientParams params; + params.set_major_iteration_frequency(0); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("major_iteration_frequency")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadTerminationCheckFrequency) { + PrimalDualHybridGradientParams params; + params.set_termination_check_frequency(0); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("termination_check_frequency")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadRestartStrategy) { + PrimalDualHybridGradientParams params; + params.set_restart_strategy( + PrimalDualHybridGradientParams::RESTART_STRATEGY_UNSPECIFIED); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("restart_strategy")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadPrimalWeightUpdateSmoothing) { + PrimalDualHybridGradientParams params_high; + params_high.set_primal_weight_update_smoothing(1.1); + const absl::Status status_high = + ValidatePrimalDualHybridGradientParams(params_high); + EXPECT_EQ(status_high.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status_high.message(), + HasSubstr("primal_weight_update_smoothing")); + + PrimalDualHybridGradientParams params_low; + params_low.set_primal_weight_update_smoothing(-0.1); + const absl::Status status_low = + ValidatePrimalDualHybridGradientParams(params_low); + EXPECT_EQ(status_low.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status_low.message(), + HasSubstr("primal_weight_update_smoothing")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadInitialPrimalWeight) { + PrimalDualHybridGradientParams params; + params.set_initial_primal_weight(-1); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("initial_primal_weight")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadLInfRuizIterations) { + PrimalDualHybridGradientParams params; + params.set_l_inf_ruiz_iterations(-1); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("l_inf_ruiz_iterations")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadSufficientReductionForRestart) { + PrimalDualHybridGradientParams params_high; + params_high.set_sufficient_reduction_for_restart(1.0); + const absl::Status status_high = + ValidatePrimalDualHybridGradientParams(params_high); + EXPECT_EQ(status_high.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status_high.message(), + HasSubstr("sufficient_reduction_for_restart")); + + PrimalDualHybridGradientParams params_low; + params_low.set_sufficient_reduction_for_restart(0.0); + const absl::Status status_low = + ValidatePrimalDualHybridGradientParams(params_low); + EXPECT_EQ(status_low.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status_low.message(), + HasSubstr("sufficient_reduction_for_restart")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadNecessaryReductionForRestart) { + PrimalDualHybridGradientParams params_high; + params_high.set_necessary_reduction_for_restart(1.0); + const absl::Status status_high = + ValidatePrimalDualHybridGradientParams(params_high); + EXPECT_EQ(status_high.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status_high.message(), + HasSubstr("necessary_reduction_for_restart")); + + PrimalDualHybridGradientParams params_low; + params_low.set_sufficient_reduction_for_restart(0.5); + params_low.set_necessary_reduction_for_restart(0.4); + const absl::Status status_low = + ValidatePrimalDualHybridGradientParams(params_low); + EXPECT_EQ(status_low.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status_low.message(), + HasSubstr("necessary_reduction_for_restart")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadLinesearchRule) { + PrimalDualHybridGradientParams params; + params.set_linesearch_rule( + PrimalDualHybridGradientParams::LINESEARCH_RULE_UNSPECIFIED); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("linesearch_rule")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadAdaptiveLinesearchParameters) { + PrimalDualHybridGradientParams params; + params.mutable_adaptive_linesearch_parameters() + ->set_step_size_reduction_exponent(-1); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("step_size_reduction_exponent")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadMalitskyPockParameters) { + PrimalDualHybridGradientParams params; + params.mutable_malitsky_pock_parameters()->set_linesearch_contraction_factor( + -1); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("linesearch_contraction_factor")); +} + +TEST(ValidatePrimalDualHybridGradientParams, BadInitialStepSizeScaling) { + PrimalDualHybridGradientParams params; + params.set_initial_step_size_scaling(-1.0); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), HasSubstr("initial_step_size_scaling")); +} + +TEST(ValidatePrimalDualHybridGradientParams, + BadInfiniteConstraintBoundThreshold) { + PrimalDualHybridGradientParams params; + params.set_infinite_constraint_bound_threshold(-1.0); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), + HasSubstr("infinite_constraint_bound_threshold")); +} + +TEST(ValidatePrimalDualHybridGradientParams, + BadDiagonalTrustRegionSolverTolerance) { + PrimalDualHybridGradientParams params; + params.set_diagonal_qp_trust_region_solver_tolerance(-1.0); + const absl::Status status = ValidatePrimalDualHybridGradientParams(params); + EXPECT_EQ(status.code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(status.message(), + HasSubstr("diagonal_qp_trust_region_solver_tolerance")); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/termination.cc b/ortools/pdlp/termination.cc new file mode 100644 index 0000000000..de568f0d60 --- /dev/null +++ b/ortools/pdlp/termination.cc @@ -0,0 +1,175 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/termination.h" + +#include +#include + +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { + +namespace { + +bool OptimalityCriteriaMet(const OptimalityNorm optimality_norm, + const double abs_tol, const double rel_tol, + const ConvergenceInformation& stats, + const QuadraticProgramBoundNorms& bound_norms) { + const double abs_obj = + std::abs(stats.primal_objective()) + std::abs(stats.dual_objective()); + const double gap = + std::abs(stats.primal_objective() - stats.dual_objective()); + + double primal_err; + double primal_err_baseline; + double dual_err; + double dual_err_baseline; + + switch (optimality_norm) { + case OPTIMALITY_NORM_L_INF: + primal_err = stats.l_inf_primal_residual(); + primal_err_baseline = bound_norms.l_inf_norm_constraint_bounds; + dual_err = stats.l_inf_dual_residual(); + dual_err_baseline = bound_norms.l_inf_norm_primal_linear_objective; + break; + case OPTIMALITY_NORM_L2: + primal_err = stats.l2_primal_residual(); + primal_err_baseline = bound_norms.l2_norm_constraint_bounds; + dual_err = stats.l2_dual_residual(); + dual_err_baseline = bound_norms.l2_norm_primal_linear_objective; + break; + default: + LOG(FATAL) << "Invalid optimality_norm value " + << OptimalityNorm_Name(optimality_norm); + } + + return dual_err <= abs_tol + rel_tol * dual_err_baseline && + primal_err <= abs_tol + rel_tol * primal_err_baseline && + std::isfinite(abs_obj) && gap <= abs_tol + rel_tol * abs_obj; +} + +// Checks if the criteria for primal infeasibility are approximately +// satisfied; see https://developers.google.com/optimization/lp/pdlp_math for +// more details. +bool PrimalInfeasibilityCriteriaMet(double eps_primal_infeasible, + const InfeasibilityInformation& stats) { + if (stats.dual_ray_objective() <= 0.0) return false; + return stats.max_dual_ray_infeasibility() / stats.dual_ray_objective() <= + eps_primal_infeasible; +} + +// Checks if the criteria for dual infeasibility are approximately satisfied; +// see https://developers.google.com/optimization/lp/pdlp_math for more details. +bool DualInfeasibilityCriteriaMet(double eps_dual_infeasible, + const InfeasibilityInformation& stats) { + if (stats.primal_ray_linear_objective() >= 0.0) return false; + return (stats.max_primal_ray_infeasibility() / + -stats.primal_ray_linear_objective() <= + eps_dual_infeasible) && + (stats.primal_ray_quadratic_norm() / + -stats.primal_ray_linear_objective() <= + eps_dual_infeasible); +} + +} // namespace + +absl::optional CheckTerminationCriteria( + const TerminationCriteria& criteria, const IterationStats& stats, + const QuadraticProgramBoundNorms& bound_norms, + const bool force_numerical_termination) { + for (const auto& convergence_stats : stats.convergence_information()) { + if (OptimalityCriteriaMet( + criteria.optimality_norm(), criteria.eps_optimal_absolute(), + criteria.eps_optimal_relative(), convergence_stats, bound_norms)) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_OPTIMAL, + .type = convergence_stats.candidate_type()}; + } + } + for (const auto& infeasibility_stats : stats.infeasibility_information()) { + if (PrimalInfeasibilityCriteriaMet(criteria.eps_primal_infeasible(), + infeasibility_stats)) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_PRIMAL_INFEASIBLE, + .type = infeasibility_stats.candidate_type()}; + } + if (DualInfeasibilityCriteriaMet(criteria.eps_dual_infeasible(), + infeasibility_stats)) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_DUAL_INFEASIBLE, + .type = infeasibility_stats.candidate_type()}; + } + } + if (stats.iteration_number() >= criteria.iteration_limit()) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_ITERATION_LIMIT, .type = POINT_TYPE_NONE}; + } + if (stats.cumulative_kkt_matrix_passes() >= + criteria.kkt_matrix_pass_limit()) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_KKT_MATRIX_PASS_LIMIT, + .type = POINT_TYPE_NONE}; + } + if (stats.cumulative_time_sec() >= criteria.time_sec_limit()) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_TIME_LIMIT, .type = POINT_TYPE_NONE}; + } + if (force_numerical_termination) { + return TerminationReasonAndPointType{ + .reason = TERMINATION_REASON_NUMERICAL_ERROR, .type = POINT_TYPE_NONE}; + } + return absl::nullopt; +} + +QuadraticProgramBoundNorms BoundNormsFromProblemStats( + const QuadraticProgramStats& stats) { + return { + .l2_norm_primal_linear_objective = stats.objective_vector_l2_norm(), + .l2_norm_constraint_bounds = stats.combined_bounds_l2_norm(), + .l_inf_norm_primal_linear_objective = stats.objective_vector_abs_max(), + .l_inf_norm_constraint_bounds = stats.combined_bounds_max()}; +} + +RelativeConvergenceInformation ComputeRelativeResiduals( + const double eps_optimal_absolute, const double eps_optimal_relative, + const QuadraticProgramBoundNorms& norms, + const ConvergenceInformation& stats) { + const double eps_ratio = eps_optimal_relative == 0.0 + ? std::numeric_limits::infinity() + : eps_optimal_absolute / eps_optimal_relative; + RelativeConvergenceInformation info; + info.relative_l_inf_primal_residual = + stats.l_inf_primal_residual() / + (eps_ratio + norms.l_inf_norm_constraint_bounds); + info.relative_l2_primal_residual = + stats.l2_primal_residual() / + (eps_ratio + norms.l2_norm_constraint_bounds); + info.relative_l_inf_dual_residual = + stats.l_inf_dual_residual() / + (eps_ratio + norms.l_inf_norm_primal_linear_objective); + info.relative_l2_dual_residual = + stats.l2_dual_residual() / + (eps_ratio + norms.l2_norm_primal_linear_objective); + const double abs_obj = + std::abs(stats.primal_objective()) + std::abs(stats.dual_objective()); + const double gap = stats.primal_objective() - stats.dual_objective(); + info.relative_optimality_gap = gap / (eps_ratio + abs_obj); + + return info; +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/termination.h b/ortools/pdlp/termination.h new file mode 100644 index 0000000000..c00b083167 --- /dev/null +++ b/ortools/pdlp/termination.h @@ -0,0 +1,94 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_TERMINATION_H_ +#define PDLP_TERMINATION_H_ + +#include "absl/types/optional.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { + +struct TerminationReasonAndPointType { + TerminationReason reason; + PointType type; +}; + +// Information about the quadratic program's primal and dual bounds that are +// needed to evaluate relative convergence criteria. +struct QuadraticProgramBoundNorms { + double l2_norm_primal_linear_objective; + double l2_norm_constraint_bounds; + double l_inf_norm_primal_linear_objective; + double l_inf_norm_constraint_bounds; +}; + +// Checks if any termination criteria are satisfied by the solution state +// described by the IterationStats instance stats (see definitions of +// termination criteria in solvers.proto). bound_norms provides the instance- +// dependent data required for the relative convergence criteria. Returns a +// termination reason and a point type if so (if multiple are satisfied, the +// optimality and infeasibility conditions are checked first). If +// force_numerical_termination is true, returns NUMERICAL_ERROR if no other +// criteria are satisfied. The return value is empty in any other case. If the +// output is not empty, the PointType indicates which entry prompted +// termination. If no entry prompted termination, e.g. NUMERICAL_ERROR or +// ITERATION_LIMIT is returned, then the PointType is set to POINT_TYPE_NONE. +// NOTE: This function assumes that the solution used to compute the stats +// satisfies the primal and dual variable bounds; see +// https://developers.google.com/optimization/lp/pdlp_math#dual_variable_bounds. +absl::optional CheckTerminationCriteria( + const TerminationCriteria& criteria, const IterationStats& stats, + const QuadraticProgramBoundNorms& bound_norms, + bool force_numerical_termination = false); + +// Extracts the norms needed for the termination criteria from the full problem +// statistics. +QuadraticProgramBoundNorms BoundNormsFromProblemStats( + const QuadraticProgramStats& stats); + +// Metrics for tracking progress when relative convergence criteria are used. +// These depend on the ConvergenceInformation, the problem data, and the +// convergence tolerances. +struct RelativeConvergenceInformation { + // Relative versions of the residuals, defined as + // relative_residual = residual / (eps_ratio + norm), + // where + // eps_ratio = eps_optimal_absolute / eps_optimal_relative + // residual = one of the residuals (l{2,_inf}_{primal,dual}_residual) + // norm = the relative norm (l{2,_inf} norm of + // {constraint_bounds,primal_linear_objective} respectively). + // If eps_optimal_relative = 0.0, these will all be 0.0. + // + // If eps_optimal_relative > 0.0, the absolute and relative termination + // criteria translate to relative_residual <= eps_optimal_relative. + double relative_l_inf_primal_residual = 0; + double relative_l2_primal_residual = 0; + double relative_l_inf_dual_residual = 0; + double relative_l2_dual_residual = 0; + + // Relative optimality gap: + // (primal_objective - dual_objective) / + // (eps_ratio + |primal_objective| + |dual_objective|) + double relative_optimality_gap = 0; +}; + +RelativeConvergenceInformation ComputeRelativeResiduals( + double eps_optimal_absolute, double eps_optimal_relative, + const QuadraticProgramBoundNorms& norms, + const ConvergenceInformation& stats); + +} // namespace operations_research::pdlp + +#endif // PDLP_TERMINATION_H_ diff --git a/ortools/pdlp/termination_test.cc b/ortools/pdlp/termination_test.cc new file mode 100644 index 0000000000..f5c1ad3be1 --- /dev/null +++ b/ortools/pdlp/termination_test.cc @@ -0,0 +1,470 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/termination.h" + +#include + +#include "absl/types/optional.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/protobuf_util.h" +#include "ortools/pdlp/solve_log.pb.h" +#include "ortools/pdlp/solvers.pb.h" + +namespace operations_research::pdlp { +namespace { + +using ::google::protobuf::util::ParseTextOrDie; +using ::testing::FieldsAre; +using ::testing::Optional; + +QuadraticProgramBoundNorms TestLpBoundNorms() { + return {.l2_norm_primal_linear_objective = std::sqrt(36.25), + .l2_norm_constraint_bounds = std::sqrt(210.0), + .l_inf_norm_primal_linear_objective = 5.5, + .l_inf_norm_constraint_bounds = 12.0}; +} + +class TerminationTest : public testing::TestWithParam { + protected: + void SetUp() override { + test_criteria_ = ParseTextOrDie(R"pb( + eps_optimal_absolute: 1.0e-4 + eps_optimal_relative: 1.0e-4 + eps_primal_infeasible: 1.0e-6 + eps_dual_infeasible: 1.0e-6 + time_sec_limit: 1.0 + kkt_matrix_pass_limit: 2000 + iteration_limit: 10)pb"); + test_criteria_.set_optimality_norm(GetParam()); + } + + TerminationCriteria test_criteria_; +}; + +TEST_P(TerminationTest, NoTerminationWithLargeGap) { + IterationStats stats = ParseTextOrDie(R"pb( + convergence_information { + # Ensures that optimality conditions are not met. + primal_objective: 50.0 + dual_objective: -50.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithEmptyIterationStats) { + IterationStats stats; + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, TerminationWithNumericalError) { + IterationStats stats; + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms(), + /*force_numerical_termination=*/true); + EXPECT_THAT( + maybe_result, + Optional(FieldsAre(TERMINATION_REASON_NUMERICAL_ERROR, POINT_TYPE_NONE))); +} + +TEST_P(TerminationTest, TerminationWithTimeLimit) { + const auto stats = + ParseTextOrDie(R"pb(cumulative_time_sec: 100.0)pb"); + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()); + EXPECT_THAT(maybe_result, Optional(FieldsAre(TERMINATION_REASON_TIME_LIMIT, + POINT_TYPE_NONE))); +} + +TEST_P(TerminationTest, TerminationWithKktMatrixPassLimit) { + const auto stats = ParseTextOrDie(R"pb( + cumulative_kkt_matrix_passes: 2500)pb"); + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()); + EXPECT_THAT(maybe_result, + Optional(FieldsAre(TERMINATION_REASON_KKT_MATRIX_PASS_LIMIT, + POINT_TYPE_NONE))); +} + +TEST_P(TerminationTest, PrimalInfeasibleFromIterateDifference) { + const auto stats = ParseTextOrDie(R"pb( + infeasibility_information: { + dual_ray_objective: 1.0 + max_dual_ray_infeasibility: 1.0e-16 + candidate_type: POINT_TYPE_ITERATE_DIFFERENCE + })pb"); + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()); + EXPECT_THAT(maybe_result, + Optional(FieldsAre(TERMINATION_REASON_PRIMAL_INFEASIBLE, + POINT_TYPE_ITERATE_DIFFERENCE))); +} + +TEST_P(TerminationTest, NoTerminationWithInfeasibleDualRay) { + const auto stats_infeasible_ray = ParseTextOrDie(R"pb( + infeasibility_information: { + dual_ray_objective: 1.0 + max_dual_ray_infeasibility: 1.0e-5 # Too large + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_infeasible_ray, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithNegativeDualRayObjective) { + const auto stats_wrong_sign = ParseTextOrDie(R"pb( + infeasibility_information: { + dual_ray_objective: -1.0 # Wrong sign + max_dual_ray_infeasibility: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_wrong_sign, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithZeroDualRayObjective) { + const auto stats_objective_zero = ParseTextOrDie(R"pb( + infeasibility_information: { + dual_ray_objective: 0.0 + max_dual_ray_infeasibility: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_objective_zero, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, DualInfeasibleFromAverageIterate) { + const auto stats = ParseTextOrDie(R"pb( + infeasibility_information: { + primal_ray_linear_objective: -1.0 + max_primal_ray_infeasibility: 1.0e-16 + candidate_type: POINT_TYPE_AVERAGE_ITERATE + })pb"); + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()); + EXPECT_THAT(maybe_result, + Optional(FieldsAre(TERMINATION_REASON_DUAL_INFEASIBLE, + POINT_TYPE_AVERAGE_ITERATE))); +} + +TEST_P(TerminationTest, NoTerminationWithInfeasiblePrimalRay) { + const auto stats_infeasible_ray = ParseTextOrDie(R"pb( + infeasibility_information: { + primal_ray_linear_objective: -1.0 + max_primal_ray_infeasibility: 1.0e-5 # Too large + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_infeasible_ray, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithPositivePrimalRayObjective) { + const auto stats_wrong_sign = ParseTextOrDie(R"pb( + infeasibility_information: { + primal_ray_linear_objective: 1.0 # Wrong sign + max_primal_ray_infeasibility: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_wrong_sign, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithZeroPrimalRayObjective) { + const auto stats_objective_zero = ParseTextOrDie(R"pb( + infeasibility_information: { + primal_ray_linear_objective: 0.0 + max_primal_ray_infeasibility: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_objective_zero, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, Optimal) { + const auto stats = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 1.0 + dual_objective: 1.0 + l_inf_primal_residual: 0.0 + l_inf_dual_residual: 0.0 + l2_primal_residual: 0.0 + l2_dual_residual: 0.0 + candidate_type: POINT_TYPE_CURRENT_ITERATE + })pb"); + + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()); + EXPECT_THAT(maybe_result, Optional(FieldsAre(TERMINATION_REASON_OPTIMAL, + POINT_TYPE_CURRENT_ITERATE))); +} + +TEST_P(TerminationTest, OptimalEvenWithNumericalError) { + const auto stats = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 1.0 + dual_objective: 1.0 + l_inf_primal_residual: 0.0 + l_inf_dual_residual: 0.0 + l2_primal_residual: 0.0 + l2_dual_residual: 0.0 + candidate_type: POINT_TYPE_CURRENT_ITERATE + })pb"); + // Tests that OPTIMAL overrides NUMERICAL_ERROR when + // force_numerical_termination == true. + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms(), + /*force_numerical_termination=*/true); + EXPECT_THAT(maybe_result, Optional(FieldsAre(TERMINATION_REASON_OPTIMAL, + POINT_TYPE_CURRENT_ITERATE))); +} + +TEST_P(TerminationTest, NoTerminationWithBadGap) { + const auto stats_bad_gap = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 10.0 + dual_objective: 1.0 + l_inf_primal_residual: 0.0 + l_inf_dual_residual: 0.0 + l2_primal_residual: 0.0 + l2_dual_residual: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_bad_gap, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithInfiniteGap) { + const auto stats_infinite_gap = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 0 + dual_objective: -Inf + l_inf_primal_residual: 0.0 + l_inf_dual_residual: 0.0 + l2_primal_residual: 0.0 + l2_dual_residual: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_infinite_gap, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithBadPrimalResidual) { + const auto stats_bad_primal = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 1.0 + dual_objective: 1.0 + l_inf_primal_residual: 1.0 + l_inf_dual_residual: 0.0 + l2_primal_residual: 1.0 + l2_dual_residual: 0.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_bad_primal, + TestLpBoundNorms()), + absl::nullopt); +} + +TEST_P(TerminationTest, NoTerminationWithBadDualResidual) { + const auto stats_bad_dual = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 1.0 + dual_objective: 1.0 + l_inf_primal_residual: 0.0 + l_inf_dual_residual: 1.0 + l2_primal_residual: 0.0 + l2_dual_residual: 1.0 + })pb"); + EXPECT_EQ(CheckTerminationCriteria(test_criteria_, stats_bad_dual, + TestLpBoundNorms()), + absl::nullopt); +} + +// Tests that optimality is checked with non-strict inequalities, as per the +// definitions in solvers.proto. +TEST_P(TerminationTest, ZeroToleranceZeroError) { + const auto stats = ParseTextOrDie(R"pb( + convergence_information { + primal_objective: 1.0 + dual_objective: 1.0 + l_inf_primal_residual: 0.0 + l_inf_dual_residual: 0.0 + l2_primal_residual: 0.0 + l2_dual_residual: 0.0 + candidate_type: POINT_TYPE_CURRENT_ITERATE + })pb"); + + test_criteria_.set_eps_optimal_absolute(0.0); + test_criteria_.set_eps_optimal_relative(0.0); + + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria_, stats, TestLpBoundNorms()); + EXPECT_THAT(maybe_result, Optional(FieldsAre(TERMINATION_REASON_OPTIMAL, + POINT_TYPE_CURRENT_ITERATE))); +} + +INSTANTIATE_TEST_SUITE_P(OptNorm, TerminationTest, + testing::Values(OPTIMALITY_NORM_L2, + OPTIMALITY_NORM_L_INF)); + +TEST(TerminationTest, L2AndLInfDiffer) { + auto test_criteria = ParseTextOrDie(R"pb( + eps_optimal_relative: 1.0)pb"); + + // For L2, optimality requires norm(primal_residual, 2) <= 14.49 + // For LInf, optimality requires norm(primal_residual, Inf) <= 12.0 + + struct { + double primal_residual; + absl::optional expected_l2; + absl::optional expected_l_inf; + } test_configs[] = { + {10.0, + TerminationReasonAndPointType{.reason = TERMINATION_REASON_OPTIMAL, + .type = POINT_TYPE_CURRENT_ITERATE}, + TerminationReasonAndPointType{.reason = TERMINATION_REASON_OPTIMAL, + .type = POINT_TYPE_CURRENT_ITERATE}}, + {13.0, + TerminationReasonAndPointType{.reason = TERMINATION_REASON_OPTIMAL, + .type = POINT_TYPE_CURRENT_ITERATE}, + absl::nullopt}, + {15.0, absl::nullopt, absl::nullopt}}; + + for (const auto& config : test_configs) { + IterationStats stats; + auto* convergence_info = stats.add_convergence_information(); + convergence_info->set_primal_objective(1.0); + convergence_info->set_dual_objective(1.0); + convergence_info->set_l2_primal_residual(config.primal_residual); + convergence_info->set_l_inf_primal_residual(config.primal_residual); + convergence_info->set_candidate_type(POINT_TYPE_CURRENT_ITERATE); + + test_criteria.set_optimality_norm(OPTIMALITY_NORM_L2); + + absl::optional maybe_result = + CheckTerminationCriteria(test_criteria, stats, TestLpBoundNorms()); + ASSERT_TRUE(maybe_result.has_value() == config.expected_l2.has_value()) + << "primal_residual: " << config.primal_residual; + if (config.expected_l2.has_value()) { + EXPECT_EQ(maybe_result->reason, config.expected_l2->reason); + EXPECT_EQ(maybe_result->type, config.expected_l2->type); + } + + test_criteria.set_optimality_norm(OPTIMALITY_NORM_L_INF); + maybe_result = + CheckTerminationCriteria(test_criteria, stats, TestLpBoundNorms()); + ASSERT_TRUE(maybe_result.has_value() == config.expected_l_inf.has_value()) + << "primal_residual: " << config.primal_residual; + if (config.expected_l_inf.has_value()) { + EXPECT_EQ(maybe_result->reason, config.expected_l_inf->reason); + EXPECT_EQ(maybe_result->type, config.expected_l_inf->type); + } + } +} + +TEST(BoundNormsFromProblemStats, ExtractsBoundNorms) { + const auto qp_stats = ParseTextOrDie(R"pb( + objective_vector_l2_norm: 4.0 + combined_bounds_l2_norm: 3.0 + objective_vector_abs_max: 1.0 + combined_bounds_max: 2.0 + )pb"); + const QuadraticProgramBoundNorms norms = BoundNormsFromProblemStats(qp_stats); + EXPECT_EQ(norms.l2_norm_primal_linear_objective, 4.0); + EXPECT_EQ(norms.l2_norm_constraint_bounds, 3.0); + EXPECT_EQ(norms.l_inf_norm_primal_linear_objective, 1.0); + EXPECT_EQ(norms.l_inf_norm_constraint_bounds, 2.0); +} + +TEST(ComputeRelativeResiduals, + ComputesRelativeResidualsForZeroAbsoluteTolerance) { + ConvergenceInformation stats; + // If the absolute error tolerance is 0.0, the relative residuals are just the + // absolute residuals divided by the corresponding norms (the actual nonzero + // value of the relative error tolerance doesn't matter). + stats.set_primal_objective(10.0); + stats.set_dual_objective(5.0); + stats.set_l_inf_primal_residual(1.0); + stats.set_l2_primal_residual(1.0); + stats.set_l_inf_dual_residual(1.0); + stats.set_l2_dual_residual(1.0); + const RelativeConvergenceInformation relative_info = ComputeRelativeResiduals( + /*eps_optimal_absolute=*/0.0, /*eps_optimal_relative=*/1.0e-6, + TestLpBoundNorms(), stats); + + EXPECT_EQ(relative_info.relative_l_inf_primal_residual, 1.0 / 12.0); + EXPECT_EQ(relative_info.relative_l2_primal_residual, 1.0 / std::sqrt(210.0)); + + EXPECT_EQ(relative_info.relative_l_inf_dual_residual, 1.0 / 5.5); + EXPECT_EQ(relative_info.relative_l2_dual_residual, 1.0 / sqrt(36.25)); + + // The relative optimality gap should just be the objective difference divided + // by the sum of absolute values (the actual nonzero value of the relative + // error tolerance doesn't matter). + EXPECT_EQ(relative_info.relative_optimality_gap, 5.0 / 15.0); +} + +TEST(ComputeRelativeResiduals, + ComputesRelativeResidualsForZeroRelativeTolerance) { + ConvergenceInformation stats; + // If the relative error tolerance is 0.0, all of the relative residuals and + // the relative optimality gap should be 0.0, no matter what the absolute + // error tolerance is. + stats.set_primal_objective(10.0); + stats.set_dual_objective(5.0); + stats.set_l_inf_primal_residual(1.0); + stats.set_l2_primal_residual(1.0); + stats.set_l_inf_dual_residual(1.0); + stats.set_l2_dual_residual(1.0); + const RelativeConvergenceInformation relative_info = ComputeRelativeResiduals( + /*eps_optimal_absolute=*/0.0, /*eps_optimal_relative=*/0.0, + TestLpBoundNorms(), stats); + + EXPECT_EQ(relative_info.relative_l_inf_primal_residual, 0.0); + EXPECT_EQ(relative_info.relative_l2_primal_residual, 0.0); + EXPECT_EQ(relative_info.relative_l_inf_dual_residual, 0.0); + EXPECT_EQ(relative_info.relative_l2_dual_residual, 0.0); + EXPECT_EQ(relative_info.relative_optimality_gap, 0.0); +} + +TEST(ComputeRelativeResiduals, + ComputesCorrectRelativeResidualsForEqualTolerances) { + ConvergenceInformation stats; + // If the absolute error tolerance and relative error tolerance are equal (and + // nonzero), the relative residuals are the absolute residuals divided by 1.0 + // plus the corresponding norms. + stats.set_primal_objective(10.0); + stats.set_dual_objective(5.0); + stats.set_l_inf_primal_residual(1.0); + stats.set_l2_primal_residual(1.0); + stats.set_l_inf_dual_residual(1.0); + stats.set_l2_dual_residual(1.0); + const RelativeConvergenceInformation relative_info = ComputeRelativeResiduals( + /*eps_optimal_absolute=*/1.0e-6, /*eps_optimal_relative=*/1.0e-6, + TestLpBoundNorms(), stats); + + EXPECT_EQ(relative_info.relative_l_inf_primal_residual, 1.0 / (1.0 + 12.0)); + EXPECT_EQ(relative_info.relative_l2_primal_residual, + 1.0 / (1.0 + std::sqrt(210.0))); + + EXPECT_EQ(relative_info.relative_l_inf_dual_residual, 1.0 / (1.0 + 5.5)); + EXPECT_EQ(relative_info.relative_l2_dual_residual, 1.0 / (1.0 + sqrt(36.25))); + + // The relative optimality gap should just be the objective difference divided + // by 1.0 + the sum of absolute values. + EXPECT_EQ(relative_info.relative_optimality_gap, 5.0 / (1.0 + 15.0)); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/test_util.cc b/ortools/pdlp/test_util.cc new file mode 100644 index 0000000000..96002327db --- /dev/null +++ b/ortools/pdlp/test_util.cc @@ -0,0 +1,289 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/test_util.h" + +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "gmock/gmock.h" +#include "ortools/pdlp/quadratic_program.h" + +namespace operations_research::pdlp { + +using ::testing::ElementsAre; + +constexpr double kInfinity = std::numeric_limits::infinity(); + +QuadraticProgram TestLp() { + QuadraticProgram lp(4, 4); + lp.constraint_lower_bounds << 12, -kInfinity, -4, -1; + lp.constraint_upper_bounds << 12, 7, kInfinity, 1; + lp.variable_lower_bounds << -kInfinity, -2, -kInfinity, 2.5; + lp.variable_upper_bounds << kInfinity, kInfinity, 6, 3.5; + std::vector> triplets = { + {0, 0, 2}, {0, 1, 1}, {0, 2, 1}, {0, 3, 2}, {1, 0, 1}, + {1, 2, 1}, {2, 0, 4}, {3, 2, 1.5}, {3, 3, -1}}; + lp.constraint_matrix.setFromTriplets(triplets.begin(), triplets.end()); + + lp.objective_vector << 5.5, -2, -1, 1; + lp.objective_offset = -14; + return lp; +} + +void VerifyTestLp(const QuadraticProgram& qp, bool maximize) { + const double objective_sign = maximize ? -1 : 1; + EXPECT_THAT(objective_sign * qp.objective_offset, testing::DoubleEq(-14)); + EXPECT_THAT(objective_sign * qp.objective_vector, + ElementsAre(5.5, -2, -1, 1)); + EXPECT_EQ(qp.objective_scaling_factor, objective_sign); + EXPECT_FALSE(qp.objective_matrix.has_value()); + EXPECT_THAT(qp.variable_lower_bounds, + ElementsAre(-kInfinity, -2, -kInfinity, 2.5)); + EXPECT_THAT(qp.variable_upper_bounds, + ElementsAre(kInfinity, kInfinity, 6, 3.5)); + EXPECT_THAT(qp.constraint_lower_bounds, ElementsAre(12, -kInfinity, -4, -1)); + EXPECT_THAT(qp.constraint_upper_bounds, ElementsAre(12, 7, kInfinity, 1)); + EXPECT_THAT(ToDense(qp.constraint_matrix), + EigenArrayEq( + {{2, 1, 1, 2}, {1, 0, 1, 0}, {4, 0, 0, 0}, {0, 0, 1.5, -1}})); +} + +QuadraticProgram TinyLp() { + QuadraticProgram lp(4, 3); + lp.objective_offset = -14; + lp.objective_vector << 5, 2, 1, 1; + lp.constraint_lower_bounds << 12, 7, 1; + lp.constraint_upper_bounds << 12, kInfinity, kInfinity; + lp.variable_lower_bounds << 0, 0, 0, 0; + lp.variable_upper_bounds << 2, 4, 6, 3; + lp.constraint_matrix.coeffRef(0, 0) = 2.0; + lp.constraint_matrix.coeffRef(0, 1) = 1.0; + lp.constraint_matrix.coeffRef(0, 2) = 1.0; + lp.constraint_matrix.coeffRef(0, 3) = 2.0; + lp.constraint_matrix.coeffRef(1, 0) = 1.0; + lp.constraint_matrix.coeffRef(1, 2) = 1.0; + lp.constraint_matrix.coeffRef(2, 2) = 1.0; + lp.constraint_matrix.coeffRef(2, 3) = -1.0; + lp.constraint_matrix.makeCompressed(); + return lp; +} + +QuadraticProgram CorrelationClusteringLp() { + QuadraticProgram lp(6, 3); + lp.objective_offset = 4; + lp.objective_vector << -1.0, -1.0, 1.0, -1.0, 1.0, -1.0; + lp.constraint_lower_bounds << -1.0, -1.0, -1.0; + lp.constraint_upper_bounds << kInfinity, kInfinity, kInfinity; + lp.variable_lower_bounds << 0.0, 0.0, 0.0, 0.0, 0.0, 0.0; + lp.variable_upper_bounds << 1.0, 1.0, 1.0, 1.0, 1.0, 1.0; + lp.constraint_matrix.coeffRef(0, 1) = -1.0; + lp.constraint_matrix.coeffRef(0, 2) = 1.0; + lp.constraint_matrix.coeffRef(0, 5) = -1.0; + lp.constraint_matrix.coeffRef(1, 3) = -1.0; + lp.constraint_matrix.coeffRef(1, 4) = 1.0; + lp.constraint_matrix.coeffRef(1, 5) = -1.0; + lp.constraint_matrix.coeffRef(2, 0) = -1.0; + lp.constraint_matrix.coeffRef(2, 1) = -1.0; + lp.constraint_matrix.coeffRef(2, 3) = 1.0; + lp.constraint_matrix.makeCompressed(); + return lp; +} + +QuadraticProgram CorrelationClusteringStarLp() { + QuadraticProgram lp(6, 3); + lp.objective_offset = 3; + lp.objective_vector << -1.0, -1.0, -1.0, 1.0, 1.0, 1.0; + lp.constraint_lower_bounds << -1.0, -1.0, -1.0; + lp.constraint_upper_bounds << kInfinity, kInfinity, kInfinity; + lp.variable_lower_bounds << 0.0, 0.0, 0.0, 0.0, 0.0, 0.0; + lp.variable_upper_bounds << 1.0, 1.0, 1.0, 1.0, 1.0, 1.0; + lp.constraint_matrix.coeffRef(0, 0) = -1.0; + lp.constraint_matrix.coeffRef(0, 1) = -1.0; + lp.constraint_matrix.coeffRef(0, 3) = 1.0; + lp.constraint_matrix.coeffRef(1, 0) = -1.0; + lp.constraint_matrix.coeffRef(1, 2) = -1.0; + lp.constraint_matrix.coeffRef(1, 4) = 1.0; + lp.constraint_matrix.coeffRef(2, 1) = -1.0; + lp.constraint_matrix.coeffRef(2, 2) = -1.0; + lp.constraint_matrix.coeffRef(2, 5) = 1.0; + lp.constraint_matrix.makeCompressed(); + return lp; +} + +namespace { + +Eigen::DiagonalMatrix ConstructDiagonal( + const std::vector& vec) { + Eigen::DiagonalMatrix diag; + diag.resize(vec.size()); + for (int i = 0; i < vec.size(); ++i) { + diag.diagonal()[i] = vec[i]; + } + return diag; +} + +} // namespace + +QuadraticProgram TestDiagonalQp1() { + QuadraticProgram qp(2, 1); + qp.constraint_lower_bounds << -kInfinity; + qp.constraint_upper_bounds << 1; + qp.variable_lower_bounds << 1, -2; + qp.variable_upper_bounds << 2, 4; + qp.objective_vector << -1, -1; + qp.objective_offset = 5; + std::vector> constraint_triplets = { + {0, 0, 1}, {0, 1, 1}}; + qp.constraint_matrix.setFromTriplets(constraint_triplets.begin(), + constraint_triplets.end()); + qp.objective_matrix = ConstructDiagonal({4.0, 1.0}); + return qp; +} + +QuadraticProgram TestDiagonalQp2() { + QuadraticProgram qp(2, 1); + qp.constraint_lower_bounds << 2; + qp.constraint_upper_bounds << 2; + qp.variable_lower_bounds << 0, 0; + qp.variable_upper_bounds << kInfinity, kInfinity; + qp.objective_vector << -3, -1; + qp.objective_offset = 0; + std::vector> constraint_triplets = { + {0, 0, 1}, {0, 1, -1}}; + qp.constraint_matrix.setFromTriplets(constraint_triplets.begin(), + constraint_triplets.end()); + qp.objective_matrix = ConstructDiagonal({1.0, 1.0}); + return qp; +} + +QuadraticProgram TestDiagonalQp3() { + QuadraticProgram qp(3, 2); + qp.constraint_lower_bounds << 1, 4; + qp.constraint_upper_bounds << 1, 4; + qp.variable_lower_bounds << 0, 0, 0; + qp.variable_upper_bounds << kInfinity, kInfinity, kInfinity; + qp.objective_vector << 1, 0, -1; + qp.objective_offset = 0; + std::vector> constraint_triplets = { + {0, 0, 1}, {0, 2, -1}, {1, 0, 2}}; + qp.constraint_matrix.setFromTriplets(constraint_triplets.begin(), + constraint_triplets.end()); + qp.objective_matrix = ConstructDiagonal({0.0, 1.0, 2.0}); + return qp; +} + +QuadraticProgram SmallInvalidProblemLp() { + QuadraticProgram lp(2, 1); + lp.constraint_matrix.coeffRef(0, 0) = 1.0; + lp.constraint_matrix.coeffRef(0, 1) = -1.0; + lp.constraint_lower_bounds << 2.0; + lp.constraint_upper_bounds << 1.0; + lp.variable_lower_bounds << 0.0, 0.0; + lp.variable_upper_bounds << kInfinity, kInfinity; + lp.constraint_matrix.makeCompressed(); + lp.objective_vector << 1.0, 1.0; + return lp; +} + +QuadraticProgram SmallInconsistentVariableBoundsLp() { + QuadraticProgram lp(2, 1); + lp.constraint_matrix.coeffRef(0, 0) = 1.0; + lp.constraint_matrix.coeffRef(0, 1) = -1.0; + lp.constraint_matrix.makeCompressed(); + lp.constraint_lower_bounds << -kInfinity; + lp.constraint_upper_bounds << 1.0; + lp.variable_lower_bounds << 2.0, 0.0; + lp.variable_upper_bounds << 1.0, kInfinity; + lp.objective_vector << 1.0, 1.0; + return lp; +} + +QuadraticProgram SmallPrimalInfeasibleLp() { + QuadraticProgram lp(2, 2); + lp.constraint_matrix.coeffRef(0, 0) = 1.0; + lp.constraint_matrix.coeffRef(0, 1) = -1.0; + lp.constraint_matrix.coeffRef(1, 0) = -1.0; + lp.constraint_matrix.coeffRef(1, 1) = 1.0; + lp.constraint_lower_bounds << -kInfinity, -kInfinity; + lp.variable_lower_bounds << 0.0, 0.0; + lp.variable_upper_bounds << kInfinity, kInfinity; + lp.constraint_matrix.makeCompressed(); + + lp.constraint_upper_bounds << 1.0, -2.0; + lp.objective_vector << 1.0, 1.0; + return lp; +} + +QuadraticProgram SmallDualInfeasibleLp() { + QuadraticProgram lp = SmallPrimalInfeasibleLp(); + lp.constraint_upper_bounds(1) = 2.0; + lp.objective_vector *= -1.0; + return lp; +} + +QuadraticProgram SmallPrimalDualInfeasibleLp() { + QuadraticProgram lp = SmallPrimalInfeasibleLp(); + lp.objective_vector *= -1.0; + return lp; +} + +QuadraticProgram SmallInitializationLp() { + QuadraticProgram lp(2, 2); + lp.constraint_matrix.coeffRef(0, 0) = 1.0; + lp.constraint_matrix.coeffRef(0, 1) = 1.0; + lp.constraint_matrix.coeffRef(1, 0) = 1.0; + lp.constraint_matrix.coeffRef(1, 1) = 2.0; + lp.constraint_lower_bounds << -kInfinity, -kInfinity; + lp.constraint_upper_bounds << 2.0, 2.0; + lp.variable_lower_bounds << 0.5, 0.5; + lp.variable_upper_bounds << 2.0, 2.0; + lp.constraint_matrix.makeCompressed(); + + lp.objective_vector << -4.0, 0.0; + return lp; +} + +QuadraticProgram LpWithoutConstraints() { + QuadraticProgram lp(2, 0); + lp.variable_lower_bounds << 0.0, -kInfinity; + lp.variable_upper_bounds << kInfinity, 0.0; + lp.objective_vector << 4.0, 0.0; + return lp; +} + +void VerifyTestQp(const QuadraticProgram& qp, bool maximize) { + const double objective_sign = maximize ? -1 : 1; + EXPECT_EQ(qp.objective_scaling_factor, objective_sign); + EXPECT_THAT(objective_sign * qp.objective_offset, testing::DoubleEq(5)); + EXPECT_THAT(objective_sign * qp.objective_vector, ElementsAre(-1, -1)); + ASSERT_TRUE(qp.objective_matrix.has_value()); + EXPECT_THAT(objective_sign * qp.objective_matrix->diagonal(), + EigenArrayEq({4, 1})); + EXPECT_THAT(qp.variable_lower_bounds, ElementsAre(1, -2)); + EXPECT_THAT(qp.variable_upper_bounds, ElementsAre(2, 4)); + EXPECT_THAT(qp.constraint_lower_bounds, ElementsAre(-kInfinity)); + EXPECT_THAT(qp.constraint_upper_bounds, ElementsAre(1)); + EXPECT_THAT(ToDense(qp.constraint_matrix), EigenArrayEq({{1, 1}})); +} + +::Eigen::ArrayXXd ToDense( + const Eigen::SparseMatrix& sparse_mat) { + return ::Eigen::ArrayXXd(::Eigen::MatrixXd(sparse_mat)); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/test_util.h b/ortools/pdlp/test_util.h new file mode 100644 index 0000000000..d5c74def31 --- /dev/null +++ b/ortools/pdlp/test_util.h @@ -0,0 +1,417 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_TEST_UTIL_H_ +#define PDLP_TEST_UTIL_H_ + +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "absl/types/span.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/logging.h" +#include "ortools/pdlp/quadratic_program.h" + +namespace operations_research::pdlp { + +// A small LP with all 4 patterns of which lower and upper bounds on the +// constraints are finite and similarly for the variables. +// min 5.5 x_0 - 2 x_1 - x_2 + x_3 - 14 s.t. +// 2 x_0 + x_1 + x_2 + 2 x_3 = 12 +// x_0 + x_2 <= 7 +// 4 x_0 >= -4 +// -1 <= 1.5 x_2 - x_3 <= 1 +// -infinity <= x_0 <= infinity +// -2 <= x_1 <= infinity +// -infinity <= x_2 <= 6 +// 2.5 <= x_3 <= 3.5 +// +// Optimal solutions: +// Primal: [-1, 8, 1, 2.5] +// Dual: [-2, 0, 2.375, 2.0/3] +// Value: -5.5 - 16 -1 + 2.5 - 14 = -34 +QuadraticProgram TestLp(); + +// Verifies that the given QuadraticProgram equals TestLp(). +void VerifyTestLp(const QuadraticProgram& qp, bool maximize = false); + +// Returns a small test LP. +// The LP: +// min 5 x_1 + 2 x_2 + x_3 + x_4 - 14 s.t. +// 2 x_1 + x_2 + x_3 + 2 x_4 = 12 +// x_1 + x_3 >= 7 +// x_3 - x_4 >= 1 +// 0 <= x_1 <= 2 +// 0 <= x_2 <= 4 +// 0 <= x_3 <= 6 +// 0 <= x_4 <= 3 +// +// Optimum solutions: +// Primal: x_1 = 1, x_2 = 0, x_3 = 6, x_4 = 2. Value: 5 + 0 + 6 + 2 - 14 = -1. +// Dual: [0.5, 4.0, 0.0] Value: 6 + 28 - 3.5*6 - 14 = -1 +// Reduced costs: [0.0, 1.5, -3.5, 0.0] +QuadraticProgram TinyLp(); + +// Returns a correlation clustering LP. +// This is the LP for minimizing disagreements for correlation clustering for +// the 4-vertex graph +// 1 - 3 - 4 +// | / +// 2 +// In integer solutions x_ij is 1 if i and j are in the same cluster and 0 +// otherwise. The 6 variables are in the order +// x_12, x_13, x_14, x_23, x_24, x_34. +// For any distinct i,j,k there's a triangle inequality +// (1-x_ik) <= (1-x_ij) + (1-x_jk) +// i.e. +// -x_ij - x_jk + x_ik >= -1. +// For brevity we only include 3 out of the 12 possible triangle inequalities: +// two needed in the optimal solution and 1 other. +// +// Optimal solutions: +// Primal: [1, 1, 0, 1, 0, 0] +// Dual: Multiple. +// Value: 1. +QuadraticProgram CorrelationClusteringLp(); + +// Returns another 4-vertex correlation clustering LP. +// +// The variables are x_12, x_13, x_14, x_23, x_24, and x_34. +// This time the graph is a star centered at vertex 1. +// Only the three triangle inequalities that are needed are included.""" +// Optimal solutions: +// Primal: [0.5, 0.5, 0.5, 0.0, 0.0, 0.0] +// Dual: [0.5, 0.5, 0.5] +// Value: 1.5 +QuadraticProgram CorrelationClusteringStarLp(); + +// Returns a small test QP. +// min 2 x_0^2 + 0.5 x_1^2 - x_0 - x_1 + 5 s.t. +// x_0 + x_1 <= 1 +// 1 <= x_0 <= 2 +// -2 <= x_1 <= 4 +// +// Optimal solutions: +// Primal: [1.0, 0.0] +// Dual: [-1.0] +// Reduced costs: [4.0, 0.0] +// Value: 2 - 1 + 5 = 6 +QuadraticProgram TestDiagonalQp1(); + +// Returns a small diagonal QP. +// min 0.5 x_0^2 + 0.5 x_1^2 - 3 x_0 - x_1 s.t. +// x_0 - x_1 = 2 +// x_0 >= 0 +// x_1 >= 0 +// Optimal solutions: +// Primal: [3, 1] +// Dual: [0] +// Value: 0 +// Reduced costs: [0, 0] +QuadraticProgram TestDiagonalQp2(); + +// Returns a small diagonal QP. +// min 0.5 x_1^2 + x_2^2 + x_0 - x_2 s.t. +// x_0 - x_2 = 1 +// 2x_0 = 4 +// x_0, x_1, x_2 >= 0 +// Optimal solutions: +// Primal: [2, 0, 1] +// Dual: [-1, 1] +// Value: 3 +// Reduced costs: [0, 0, 0] +QuadraticProgram TestDiagonalQp3(); + +// Returns a small invalid LP. +// min x_0 + x_1 s.t. +// 2.0 <= x_0 - x_1 <= 1.0 +// 0.0 <= x_0 +// 0.0 <= x_1 +QuadraticProgram SmallInvalidProblemLp(); + +// Returns a small LP that's invalid due to inconsistent variable bounds. +// min x_0 + x_1 s.t. +// x_0 - x_1 <= 1.0 +// 2.0 <= x_0 <= 1.0 +// 0.0 <= x_1 +QuadraticProgram SmallInconsistentVariableBoundsLp(); + +// Returns a small test LP with infeasible primal. +// min x_0 + x_1 s.t. +// x_0 - x_1 <= 1.0 +// -x_0 + x_1 <= -2.0 +// 0.0 <= x_0 +// 0.0 <= x_1 +QuadraticProgram SmallPrimalInfeasibleLp(); + +// Returns a small test LP with infeasible dual. +// min - x_0 - x_1 s.t. +// x_0 - x_1 <= 1.0 +// -x_0 + x_1 <= 2.0 +// 0.0 <= x_0 +// 0.0 <= x_1 +// This is the SmallPrimalInfeasibleLp with the objective vector negated and +// with the second constraint changed to make it feasible. +QuadraticProgram SmallDualInfeasibleLp(); + +// Returns a small test LP with infeasible primal and dual. +// min - x_0 - x_1 s.t. +// x_0 - x_1 <= 1.0 +// -x_0 + x_1 <= -2.0 +// 0.0 <= x_0 +// 0.0 <= x_1 +// This is just the SmallPrimalInfeasibleLp with the objective vector +// negated. +QuadraticProgram SmallPrimalDualInfeasibleLp(); + +// This is a small lp for which optimality conditions are met by x=(0, 0), y=(0, +// 0) if one doesn't check that x satisfies the variable bounds. Analogously, +// the assignment x=(1, 0), y = -(1, 1) also satisfies the optimality conditions +// if one doesn't check dual variable bounds. +// min -4 x_0 s.t. +// x_0 + x_1 <= 2.0 +// x_0 + 2x_1 <= 2.0 +// 0.5 <= x_0 <= 2.0 +// 0.5 <= x_1 <= 2.0 +QuadraticProgram SmallInitializationLp(); + +// This is a small LP with 2 variables and zero constraints (excluding variable +// bounds), resulting in an empty constraint matrix (zero rows) and empty lower +// and upper constraint bounds. +// min 4 x_0 s.t. +// 0 <= x_0 +// x_1 <= 0 +QuadraticProgram LpWithoutConstraints(); + +// Verifies that the given QuadraticProgram equals TestQp(). +void VerifyTestQp(const QuadraticProgram& qp, bool maximize = false); + +// Converts a sparse matrix into a dense matrix in the format suitable for +// the matcher EigenArrayEq. Example usage: +// EXPECT_THAT(ToDense(sparse_mat), EigenArrayEq({{1, 1}})); +::Eigen::ArrayXXd ToDense( + const Eigen::SparseMatrix& sparse_mat); + +// gMock matchers for Eigen. + +namespace internal { + +MATCHER_P(TupleIsNear, tolerance, "is near") { + return std::abs(std::get<0>(arg) - std::get<1>(arg)) <= tolerance; +} + +MATCHER(TupleFloatEq, "is almost equal to") { + testing::Matcher matcher = testing::FloatEq(std::get<1>(arg)); + return matcher.Matches(std::get<0>(arg)); +} + +// Convert nested Span to a 2D Eigen Array. Spans are implicitly +// constructable from initializer_lists and vectors, so this conversion is used +// in EigenArrayNear and EigenArrayEq to support syntaxes like +// EXPECT_THAT(array2d, EigenArrayNear({{1, 2}, {3, 4}}, tolerance); +// This conversion creates a copy of the slice data, so it is safe to use the +// result even after the original slices vanish. +template +Eigen::Array EigenArray2DFromNestedSpans( + absl::Span> rows) { + Eigen::Array result(0, rows.size()); + if (!rows.empty()) { + result.resize(rows.size(), rows[0].size()); + } + for (int i = 0; i < rows.size(); ++i) { + CHECK_EQ(rows[0].size(), rows[i].size()); + result.row(i) = Eigen::Map>( + &rows[i][0], rows[i].size()); + } + return result; +} + +// Get a matcher's description as a string. To produce the description for +// EigenEach(inner_matcher), this function is called to get the description of +// inner_matcher. +template +std::string GetMatcherDescriptionAsString( + const testing::Matcher& matcher, bool negation) { + std::stringstream ss; + if (negation) { + matcher.DescribeNegationTo(&ss); + } else { + matcher.DescribeTo(&ss); + } + return ss.str(); +} + +} // namespace internal + +// Defines a gMock matcher that tests whether two numeric arrays are +// approximately equal in the sense of maximum absolute difference. The element +// value type may be float, double, or integral types. +// +// Example: +// vector output = ComputeVector(); +// vector expected({-1.5333, sqrt(2), M_PI}); +// EXPECT_THAT(output, FloatArrayNear(expected, 1e-3)); +template +decltype(testing::Pointwise(internal::TupleIsNear(0.0), ContainerType())) +FloatArrayNear(const ContainerType& container, double tolerance) { + return testing::Pointwise(internal::TupleIsNear(tolerance), container); +} + +// Defines a gMock matcher acting as an elementwise version of FloatEq() for +// arrays of real floating point types. It tests whether two arrays are +// pointwise equal within 4 units in the last place (ULP) in float precision +// [http://en.wikipedia.org/wiki/Unit_in_the_last_place]. Roughly, 4 ULPs is +// 2^-21 times the absolute value, or 0.00005%. Exceptionally, zero matches +// values with magnitude less than 5.6e-45 (2^-147), infinities match infinities +// of the same sign, and NaNs don't match anything. +// +// Example: +// vector output = ComputeVector(); +// vector expected({-1.5333, sqrt(2), M_PI}); +// EXPECT_THAT(output, FloatArrayEq(expected)); +template +decltype(testing::Pointwise(internal::TupleFloatEq(), ContainerType())) +FloatArrayEq(const ContainerType& container) { + return testing::Pointwise(internal::TupleFloatEq(), container); +} + +// Call .eval() on input and convert it to a column major representation. +template +Eigen::Array +EvalAsColMajorEigenArray(const EigenType& input) { + return input.eval(); +} + +// Wrap a column major Eigen Array as a Span. +template +absl::Span EigenArrayAsSpan( + const Eigen::Array& + array) { + return absl::Span(array.data(), array.size()); +} + +// Gmock matcher to test whether all elements in an array match expected_array +// within the specified tolerance, and print a detailed error message pointing +// to the first mismatched element if they do not. Essentially an elementwise +// version of testing::DoubleNear for Eigen arrays. +// +// Example: +// Eigen::ArrayXf expected = ... +// EXPECT_THAT(actual_arrayxf, EigenArrayNear(expected, 1e-5)); +MATCHER_P2(EigenArrayNear, expected_array, tolerance, + "array is near " + testing::PrintToString(expected_array) + + " within tolerance " + testing::PrintToString(tolerance)) { + if (arg.rows() != expected_array.rows() || + arg.cols() != expected_array.cols()) { + *result_listener << "where shape (" << expected_array.rows() << ", " + << expected_array.cols() << ") doesn't match (" + << arg.rows() << ", " << arg.cols() << ")"; + return false; + } + // Call .eval() to allow callers to pass in Eigen expressions and possibly + // noncontiguous objects, e.g. Eigen::ArrayXf::Zero(10) or Map with a stride. + // Arrays are represented in column major order for consistent comparison. + auto realized_expected_array = EvalAsColMajorEigenArray(expected_array); + auto realized_actual_array = EvalAsColMajorEigenArray(arg); + return ExplainMatchResult( + FloatArrayNear(EigenArrayAsSpan(realized_expected_array), tolerance), + EigenArrayAsSpan(realized_actual_array), result_listener); +} + +// Gmock matcher to test whether all elements in an array match expected_array +// within 4 units of least precision (ULP) in float precision. Essentially an +// elementwise version of testing::FloatEq for Eigen arrays. +// +// Example: +// Eigen::ArrayXf expected = ... +// EXPECT_THAT(actual_arrayxf, EigenArrayEq(expected)); +MATCHER_P(EigenArrayEq, expected_array, + "array is almost equal to " + + testing::PrintToString(expected_array)) { + if (arg.rows() != expected_array.rows() || + arg.cols() != expected_array.cols()) { + *result_listener << "where shape (" << expected_array.rows() << ", " + << expected_array.cols() << ") doesn't match (" + << arg.rows() << ", " << arg.cols() << ")"; + return false; + } + // Call .eval() to allow callers to pass in Eigen expressions and possibly + // noncontiguous objects, e.g. Eigen::ArrayXf::Zero(10) or Map with a stride. + // Arrays are represented in column major order for consistent comparison. + auto realized_expected_array = EvalAsColMajorEigenArray(expected_array); + auto realized_actual_array = EvalAsColMajorEigenArray(arg); + return ExplainMatchResult( + FloatArrayEq(EigenArrayAsSpan(realized_expected_array)), + EigenArrayAsSpan(realized_actual_array), result_listener); +} + +// The next few functions are syntactic sugar for EigenArrayNear and +// EigenArrayEq to allow callers to pass in non-Eigen types that can be +// statically initialized like (nested in the 2D case) initializer_lists, or +// vectors, etc. For example this specialization lets one make calls inlining +// expected_array like: +// EXPECT_THAT(array1d, EigenArrayNear({0.1, 0.2}, tolerance)); +// or in the 2D case: +// EXPECT_THAT(array2d, EigenArrayNear({{1, 2}, {3, 4}}, tolerance); + +template +EigenArrayNearMatcherP2, double> +EigenArrayNear(absl::Span data, double tolerance) { + Eigen::Array temp_array = + Eigen::Map>(&data[0], + data.size()); + return EigenArrayNear(temp_array, tolerance); +} + +template +EigenArrayNearMatcherP2, double> +EigenArrayNear(absl::Span> rows, double tolerance) { + return EigenArrayNear(internal::EigenArray2DFromNestedSpans(rows), tolerance); +} + +template +EigenArrayEqMatcherP> EigenArrayEq( + absl::Span data) { + Eigen::Array temp_array = + Eigen::Map>(&data[0], + data.size()); + return EigenArrayEq(temp_array); +} + +template +EigenArrayEqMatcherP> +EigenArrayEq(absl::Span> rows) { + return EigenArrayEq(internal::EigenArray2DFromNestedSpans(rows)); +} + +} // namespace operations_research::pdlp + +namespace Eigen { +// Pretty prints an Eigen::Array on a gunit test failures. See +// https://google.github.io/googletest/advanced.html#teaching-googletest-how-to-print-your-values +template +void PrintTo(const Array& array, + std::ostream* os) { + IOFormat format(StreamPrecision, 0, ", ", ",\n", "[", "]", "[", "]"); + *os << "\n" << array.format(format); +} +} // namespace Eigen + +#endif // PDLP_TEST_UTIL_H_ diff --git a/ortools/pdlp/test_util_test.cc b/ortools/pdlp/test_util_test.cc new file mode 100644 index 0000000000..69ec3f0afc --- /dev/null +++ b/ortools/pdlp/test_util_test.cc @@ -0,0 +1,331 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/test_util.h" + +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "absl/types/span.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/base/logging.h" + +namespace operations_research::pdlp { +namespace { + +using ::absl::Span; +using ::Eigen::ColMajor; +using ::Eigen::Dynamic; +using ::Eigen::RowMajor; +using ::testing::Matcher; +using ::testing::Not; + +TEST(FloatArrayNearTest, TypicalUse) { + std::vector test_vector({0.998, -1.414, 3.142}); + std::vector reference_vector({1.0, -M_SQRT2, M_PI}); + EXPECT_THAT(test_vector, FloatArrayNear(reference_vector, 1e-2)); + EXPECT_THAT(test_vector, Not(FloatArrayNear(reference_vector, 1e-4))); +} + +template +class FloatArrayNearContainerTypeTest : public testing::Test {}; + +using TestContainerTypes = testing::Types, std::deque, + std::list, Span>; +TYPED_TEST_SUITE(FloatArrayNearContainerTypeTest, TestContainerTypes); + +TYPED_TEST(FloatArrayNearContainerTypeTest, MatchesApproximately) { + using ContainerType = TypeParam; + auto test_values = {0.505f, 1.0f, -0.992f, 1.995f}; + ContainerType test_container(test_values); + auto reference_values = {0.5f, 1.0f, -1.0f, 2.0f}; + ContainerType reference_container(reference_values); + + const Matcher m1 = FloatArrayNear(reference_container, 1e-2); + EXPECT_TRUE(m1.Matches(test_container)); + const Matcher m2 = FloatArrayNear(reference_container, 1e-3); + EXPECT_FALSE(m2.Matches(test_container)); +} + +TYPED_TEST(FloatArrayNearContainerTypeTest, DoesNotMatchWrongSize) { + using ContainerType = TypeParam; + EXPECT_THAT(ContainerType({1.0f, 2.0f}), + Not(FloatArrayNear(ContainerType({1.0f, 2.0f, 3.0f}), 1e-2))); +} + +TYPED_TEST(FloatArrayNearContainerTypeTest, DoesNotMatchWrongOrder) { + using ContainerType = TypeParam; + EXPECT_THAT(ContainerType({1.0f, 3.0f, 2.0f}), + Not(FloatArrayNear(ContainerType({1.0f, 2.0f, 3.0f}), 1e-2))); +} + +TYPED_TEST(FloatArrayNearContainerTypeTest, DoesNotMatchNaNs) { + using ContainerType = TypeParam; + auto test_values = {1.0f, std::numeric_limits::quiet_NaN()}; + ContainerType test_container(test_values); + + EXPECT_THAT(test_container, + Not(FloatArrayNear(ContainerType({1.0f, 2.0f}), 1e0))); + EXPECT_THAT(test_container, Not(FloatArrayNear(test_container, 1e0))); +} + +TEST(FloatArrayNearTest, WithIntegerElements) { + std::vector test_vector({505, 1000, -992, 1990}); + std::vector reference_vector({500, 1000, -1000, 2000}); + + const Matcher> m1 = FloatArrayNear(reference_vector, 10); + EXPECT_TRUE(m1.Matches(test_vector)); + const Matcher> m2 = FloatArrayNear(reference_vector, 1); + EXPECT_FALSE(m2.Matches(test_vector)); +} + +TEST(FloatArrayEqTest, TypicalUse) { + std::vector reference_vector({1e6, -M_SQRT2, M_PI}); + // Values are within 4 ULPs. + std::vector test_vector({1e6 + 0.25, -1.41421323, 3.14159262}); + EXPECT_THAT(test_vector, FloatArrayEq(reference_vector)); + // Create a difference of 5 ULPs in the first element. + test_vector[0] = 1e6 + 0.3125; + EXPECT_THAT(test_vector, Not(FloatArrayEq(reference_vector))); +} + +template +class FloatArrayEqContainerTypeTest : public testing::Test {}; + +TYPED_TEST_SUITE(FloatArrayEqContainerTypeTest, TestContainerTypes); + +TYPED_TEST(FloatArrayEqContainerTypeTest, MatchesApproximately) { + using ContainerType = TypeParam; + auto reference_values = {-1e6f, 0.0f, 1.0f}; + ContainerType reference_container(reference_values); + const Matcher m = FloatArrayEq(reference_container); + EXPECT_TRUE(m.Matches(reference_container)); + EXPECT_TRUE(m.Matches(ContainerType({-1e6 + 0.25, 5e-45, 1.0000002}))); + EXPECT_TRUE(m.Matches(ContainerType({-1e6 - 0.25, -5e-45, 0.9999998}))); + EXPECT_FALSE(m.Matches(ContainerType({-1e6 + 0.3125, 0.0, 1.0}))); + EXPECT_FALSE(m.Matches(ContainerType({-1e6, 1e-44, 1.0}))); + EXPECT_FALSE(m.Matches(ContainerType({-1e6, 0.0, 1.0000006}))); +} + +TYPED_TEST(FloatArrayEqContainerTypeTest, DoesNotMatchWrongSize) { + using ContainerType = TypeParam; + EXPECT_THAT(ContainerType({1.0f, 2.0f}), + Not(FloatArrayEq(ContainerType({1.0f, 2.0f, 3.0f})))); +} + +TYPED_TEST(FloatArrayEqContainerTypeTest, DoesNotMatchWrongOrder) { + using ContainerType = TypeParam; + EXPECT_THAT(ContainerType({1.0f, 3.0f, 2.0f}), + Not(FloatArrayEq(ContainerType({1.0f, 2.0f, 3.0f})))); +} + +TYPED_TEST(FloatArrayEqContainerTypeTest, DoesNotMatchNaNs) { + using ContainerType = TypeParam; + auto reference_values = {1.0f, std::numeric_limits::quiet_NaN()}; + ContainerType reference_container(reference_values); + const Matcher m = FloatArrayEq(reference_container); + EXPECT_FALSE(m.Matches(reference_container)); + EXPECT_FALSE(m.Matches(ContainerType({1.0f, 2.0f}))); +} + +TYPED_TEST(FloatArrayEqContainerTypeTest, HandlesInfinities) { + using ContainerType = TypeParam; + auto reference_values = {1.0f, std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}; + ContainerType reference_container(reference_values); + const Matcher m = FloatArrayEq(reference_container); + EXPECT_TRUE(m.Matches(reference_container)); + EXPECT_FALSE(m.Matches(ContainerType({1.0f, 2.0f, 3.0f}))); +} + +static const double kEps = 1e-6; + +TEST(EigenArrayNearTest, ArrayXd) { + const Eigen::ArrayXd expected = Eigen::ArrayXd::Random(4); + Eigen::ArrayXd actual = expected; + EXPECT_THAT(actual, EigenArrayNear(expected, kEps)); + EXPECT_THAT(actual, EigenArrayNear(expected, 1e-100)); + + actual += 100; + EXPECT_THAT(actual, Not(EigenArrayNear(expected, kEps))); + // Wrong shape. + actual.resize(2); + EXPECT_THAT(actual, Not(EigenArrayNear(expected, kEps))); +} + +TEST(EigenArrayNearTest, ArrayXdInlinedValues) { + Eigen::ArrayXd actual(3); + actual << 1.0, 2.0, 3.0; + EXPECT_THAT(actual, EigenArrayNear({1.0, 2.0, 3.0}, kEps)); + EXPECT_THAT(actual, + EigenArrayNear({1.0, 2.0 + 0.5 * kEps, 3.0}, kEps)); + + EXPECT_THAT(actual, Not(EigenArrayNear({1.0, 2.0, 5.0}, kEps))); + // Wrong shape. + EXPECT_THAT(actual, Not(EigenArrayNear({1.0, 2.0}, kEps))); +} + +TEST(EigenArrayNearTest, EmptyArrayX) { + const Eigen::ArrayXi empty; + EXPECT_THAT(empty, EigenArrayNear(empty, kEps)); + // Can pass in an Eigen expression type. + EXPECT_THAT(empty, EigenArrayNear(Eigen::ArrayXi(), kEps)); + + EXPECT_THAT(empty, Not(EigenArrayNear({1, 2}, kEps))); + EXPECT_THAT(empty, Not(EigenArrayNear(Eigen::ArrayXi::Zero(3), kEps))); +} + +TEST(EigenArrayNearTest, ArrayXXf) { + const Eigen::ArrayXXf expected = Eigen::ArrayXXf::Random(4, 5); + Eigen::ArrayXXf actual = expected; + EXPECT_THAT(actual, EigenArrayNear(expected, kEps)); + EXPECT_THAT(actual, EigenArrayNear(expected, 1e-100)); + + actual.row(2) += 100; + EXPECT_THAT(actual, Not(EigenArrayNear(expected, kEps))); + // Wrong shape. + EXPECT_THAT(expected, Not(EigenArrayNear(expected.transpose(), kEps))); + actual.resize(4, 3); + EXPECT_THAT(actual, Not(EigenArrayNear(expected, kEps))); + + // Expression type. + actual.resize(3, 2); + actual.col(0) << 1.0, 2.0, 3.0; + actual.col(1) << 4.0, 5.0, 6.0; + std::vector expected_vector({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); + EXPECT_THAT(actual, EigenArrayNear(Eigen::Map( + &expected_vector[0], /*rows=*/3, + /*cols=*/2), + kEps)); + // Wrong shape. + EXPECT_THAT(actual, Not(EigenArrayNear(Eigen::Map( + &expected_vector[0], /*rows=*/3, + /*cols=*/1), + kEps))); +} + +TEST(EigenArrayNearTest, DifferentMajor) { + Eigen::Array col_major(2, 3); + col_major << 1.0, 2.0, 3.0, 4.0, 5.0, 6.0; + Eigen::Array row_major(2, 3); + row_major << 1.0, 2.0, 3.0, 4.0, 5.0, 6.0; + CHECK_EQ(col_major(1, 0), row_major(1, 0)); + + EXPECT_THAT(row_major, EigenArrayNear(col_major, 0.0)); + EXPECT_THAT(row_major, EigenArrayNear({{1.0, 2.0, 3.0}, // + {4.0, 5.0, 6.0}}, + 0.0)); + EXPECT_THAT(col_major, EigenArrayNear(row_major, 0.0)); + EXPECT_THAT(col_major, EigenArrayNear({{1.0, 2.0, 3.0}, // + {4.0, 5.0, 6.0}}, + 0.0)); +} + +TEST(EigenArrayNearTest, ArrayXXfInlinedValues) { + Eigen::ArrayXXf actual(2, 3); + actual.row(0) << 1.0, 2.0, 3.0; + actual.row(1) << 4.0, -5.0, -6.0; + + EXPECT_THAT(actual, EigenArrayNear({{1.0, 2.0, 3.0}, // + {4.0, -5.0, -6.0}}, + kEps)); + EXPECT_THAT(actual, EigenArrayNear( + {{1.0, 2.0, 3.0}, + {4.0, -5.0, static_cast(-6.0 - 0.9 * kEps)}}, + kEps)); + EXPECT_THAT(actual, Not(EigenArrayNear({{1.0, 2.0, 3.0}, // + {4.0, -5.0, -8.0}}, + kEps))); + // Wrong shape. + EXPECT_THAT(actual, Not(EigenArrayNear({{1.0, 2.0, 3.0}}, kEps))); +} + +TEST(EigenArrayEqTest, ArrayXd) { + const Eigen::ArrayXd expected = Eigen::ArrayXd::Random(4); + Eigen::ArrayXd actual = expected; + EXPECT_THAT(actual, EigenArrayEq(expected)); + + actual += 100; + EXPECT_THAT(actual, Not(EigenArrayEq(expected))); + // Wrong shape. + actual.resize(2); + EXPECT_THAT(actual, Not(EigenArrayEq(expected))); +} + +TEST(EigenArrayEqTest, ArrayXdInlinedValues) { + Eigen::ArrayXd actual(3); + actual << 1.0, 2.0, 3.0; + EXPECT_THAT(actual, EigenArrayEq({1.0, 2.0, 3.0})); + EXPECT_THAT(actual, EigenArrayEq({1.0, 2.0 + 5e-7, 3.0})); + + EXPECT_THAT(actual, Not(EigenArrayEq({1.0, 2.0, 5.0}))); + // Wrong shape. + EXPECT_THAT(actual, Not(EigenArrayEq({1.0, 2.0}))); +} + +TEST(EigenArrayEqTest, EmptyArrayX) { + const Eigen::ArrayXi empty; + EXPECT_THAT(empty, EigenArrayEq(empty)); + // Can pass in an Eigen expression type. + EXPECT_THAT(empty, EigenArrayEq(Eigen::ArrayXi())); + + EXPECT_THAT(empty, Not(EigenArrayEq({1, 2}))); + EXPECT_THAT(empty, Not(EigenArrayEq(Eigen::ArrayXi::Zero(3)))); +} + +TEST(EigenArrayEqTest, ArrayXXf) { + const Eigen::ArrayXXf expected = Eigen::ArrayXXf::Random(4, 5); + Eigen::ArrayXXf actual = expected; + EXPECT_THAT(actual, EigenArrayEq(expected)); + + actual.row(2) += 100; + EXPECT_THAT(actual, Not(EigenArrayEq(expected))); + // Wrong shape. + EXPECT_THAT(expected, Not(EigenArrayEq(expected.transpose()))); + actual.resize(4, 3); + EXPECT_THAT(actual, Not(EigenArrayEq(expected))); + + // Expression type. + actual.resize(3, 2); + actual.col(0) << 1.0, 2.0, 3.0; + actual.col(1) << 4.0, 5.0, 6.0; + std::vector expected_vector({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); + EXPECT_THAT(actual, EigenArrayEq(Eigen::Map( + &expected_vector[0], /*rows=*/3, /*cols=*/2))); + // Wrong shape. + EXPECT_THAT(actual, Not(EigenArrayEq(Eigen::Map( + &expected_vector[0], /*rows=*/3, /*cols=*/1)))); +} + +TEST(EigenArrayEqTest, ArrayXXfInlinedValues) { + Eigen::ArrayXXf actual(2, 3); + actual.row(0) << 1.0, 2.0, 3.0; + actual.row(1) << 4.0, -5.0, -6.0; + + EXPECT_THAT(actual, EigenArrayEq({{1.0, 2.0, 3.0}, // + {4.0, -5.0, -6.0}})); + EXPECT_THAT(actual, EigenArrayEq({{1.0, 2.0, 3.0}, // + {4.0, -5.0, -6.0 - 1e-6}})); + EXPECT_THAT(actual, Not(EigenArrayEq({{1.0, 2.0, 3.0}, // + {4.0, -5.0, -8.0}}))); + // Wrong shape. + EXPECT_THAT(actual, Not(EigenArrayEq({{1.0, 2.0, 3.0}}))); +} + +} // namespace +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/trust_region.cc b/ortools/pdlp/trust_region.cc new file mode 100644 index 0000000000..f839c6493c --- /dev/null +++ b/ortools/pdlp/trust_region.cc @@ -0,0 +1,1200 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/trust_region.h" + +#include +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "absl/algorithm/container.h" +#include "absl/types/optional.h" +#include "ortools/base/logging.h" +#include "ortools/base/mathutil.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_optimization_utils.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" + +namespace operations_research::pdlp { + +using ::Eigen::VectorXd; + +namespace { + +// The functions in this file that are templated on a TrustRegionProblem use the +// templated class to specify the following trust-region problem with bound +// constraints: +// min_x Objective' * (x - CenterPoint) +// s.t. LowerBound <= x <= UpperBound +// || x - Centerpoint ||_W <= target_radius +// where ||y||_W = sqrt(sum_i NormWeight[i] * y[i]^2) +// The templated TrustRegionProblem type should provide methods: +// double Objective(int64_t index) const; +// double LowerBound(int64_t index) const; +// double UpperBound(int64_t index) const; +// double CenterPoint(int64_t index) const; +// double NormWeight(int64_t index) const; +// which give the values of the corresponding terms in the problem +// specification. See VectorTrustRegionProblem for an example. The +// *TrustRegionProblem classes below implement several instances of +// TrustRegionProblem. +// On the other hand, the functions that are templated on a +// DiagonalTrustRegionProblem use the templated class to specify the following +// trust-region problem with bound constraints: +// min_x (1 / 2) * (x - CenterPoint)' ObjectiveMatrix * (x - CenterPoint) +// + Objective' * (x - CenterPoint) +// s.t. LowerBound <= x <= UpperBound +// || x - CenterPoint ||_W <= target_radius, +// where ||y||_W = sqrt(sum_i NormWeight[i] * y[i]^2) and ObjectiveMatrix is +// assumed to be a diagonal matrix with nonnegative entries. Templated +// DiagonalTrustRegionProblem types should provide all the methods provided by +// templated TrustRegionProblem types, as well as: +// double ObjectiveMatrixDiagonalAt(int64_t index) const; +// which gives the value of the objective matrix diagonal at a specified index. +// See DiagonalTrustRegionProblemFromQp for an example that sets up the diagonal +// trust region problem from an existing ShardedQuadraticProgram. + +// VectorTrustRegionProblem uses explicit vectors to define the trust region +// problem. It captures const references to the vectors used in the constrcutor, +// which should outlive the class instance. +class VectorTrustRegionProblem { + public: + VectorTrustRegionProblem(const VectorXd* objective, + const VectorXd* lower_bound, + const VectorXd* upper_bound, + const VectorXd* center_point, + const VectorXd* norm_weight) + : objective_(*objective), + lower_bound_(*lower_bound), + upper_bound_(*upper_bound), + center_point_(*center_point), + norm_weight_(*norm_weight) {} + double Objective(int64_t index) const { return objective_(index); } + double LowerBound(int64_t index) const { return lower_bound_(index); } + double UpperBound(int64_t index) const { return upper_bound_(index); } + double CenterPoint(int64_t index) const { return center_point_(index); } + double NormWeight(int64_t index) const { return norm_weight_(index); } + + private: + const VectorXd& objective_; + const VectorXd& lower_bound_; + const VectorXd& upper_bound_; + const VectorXd& center_point_; + const VectorXd& norm_weight_; +}; + +// PrimalTrustRegionProblem defines the primal trust region problem given a +// QuadraticProgram, primal solution, and primal gradient. It captures const +// references to the constructor arguments, which should outlive the class +// instance. +// The corresponding trust region problem is +// min_x primal_gradient' * (x - primal_solution) +// s.t. qp.variable_lower_bounds <= x <= qp.variable_upper_bounds +// || x - primal_solution ||_2 <= target_radius +class PrimalTrustRegionProblem { + public: + PrimalTrustRegionProblem(const QuadraticProgram* qp, + const VectorXd* primal_solution, + const VectorXd* primal_gradient) + : qp_(*qp), + primal_solution_(*primal_solution), + primal_gradient_(*primal_gradient) {} + double Objective(int64_t index) const { return primal_gradient_[index]; } + double LowerBound(int64_t index) const { + return qp_.variable_lower_bounds[index]; + } + double UpperBound(int64_t index) const { + return qp_.variable_upper_bounds[index]; + } + double CenterPoint(int64_t index) const { return primal_solution_[index]; } + double NormWeight(int64_t index) const { return 1.0; } + + private: + const QuadraticProgram& qp_; + const VectorXd& primal_solution_; + const VectorXd& primal_gradient_; +}; + +// DualTrustRegionProblem defines the dual trust region problem given a +// QuadraticProgram, dual solution, and dual gradient. It captures const +// references to the constructor arguments, which should outlive the class +// instance. +// The corresponding trust region problem is +// max_y dual_gradient' * (y - dual_solution) +// s.t. qp.implicit_dual_lower_bounds <= y <= qp.implicit_dual_upper_bounds +// || y - dual_solution ||_2 <= target_radius +// where the implicit dual bounds are those given in +// https://developers.google.com/optimization/lp/pdlp_math#dual_variable_bounds +class DualTrustRegionProblem { + public: + DualTrustRegionProblem(const QuadraticProgram* qp, + const VectorXd* dual_solution, + const VectorXd* dual_gradient) + : qp_(*qp), + dual_solution_(*dual_solution), + dual_gradient_(*dual_gradient) {} + double Objective(int64_t index) const { + // The objective is negated because the trust region problem objective is + // minimize, but for the dual problem we want to maximize the gradient. + return -dual_gradient_[index]; + } + double LowerBound(int64_t index) const { + return std::isfinite(qp_.constraint_upper_bounds[index]) + ? -std::numeric_limits::infinity() + : 0.0; + } + double UpperBound(int64_t index) const { + return std::isfinite(qp_.constraint_lower_bounds[index]) + ? std::numeric_limits::infinity() + : 0.0; + } + double CenterPoint(int64_t index) const { return dual_solution_[index]; } + double NormWeight(int64_t index) const { return 1.0; } + + private: + const QuadraticProgram& qp_; + const VectorXd& dual_solution_; + const VectorXd& dual_gradient_; +}; + +// JointTrustRegionProblem defines the joint primal/dual trust region problem +// given a QuadraticProgram, primal and dual solutions, primal and dual +// gradients, and the primal weight. The joint problem (implicitly) concatenates +// the primal and dual vectors. The class captures const references to the +// constructor arguments (except primal_weight), which should outlive the class +// instance. +// The corresponding trust region problem is +// min primal_gradient' * (x - primal_solution) +// - dual_gradient' * (y - dual_solution) +// s.t. qp.variable_lower_bounds <= x <= qp.variable_upper_bounds +// qp.implicit_dual_lower_bounds <= y <= qp.implicit_dual_upper_bounds +// || (x, y) - (primal_solution, dual_solution) ||_2 <= target_radius +// where the implicit dual bounds are those given in +// https://developers.google.com/optimization/lp/pdlp_math#dual_variable_bounds +class JointTrustRegionProblem { + public: + JointTrustRegionProblem(const QuadraticProgram* qp, + const VectorXd* primal_solution, + const VectorXd* dual_solution, + const VectorXd* primal_gradient, + const VectorXd* dual_gradient, + const double primal_weight) + : qp_(*qp), + primal_size_(qp_.variable_lower_bounds.size()), + primal_solution_(*primal_solution), + dual_solution_(*dual_solution), + primal_gradient_(*primal_gradient), + dual_gradient_(*dual_gradient), + primal_weight_(primal_weight) {} + double Objective(int64_t index) const { + return index < primal_size_ ? primal_gradient_[index] + : -dual_gradient_[index - primal_size_]; + } + double LowerBound(int64_t index) const { + return index < primal_size_ ? qp_.variable_lower_bounds[index] + : std::isfinite(qp_.constraint_upper_bounds[index - primal_size_]) + ? -std::numeric_limits::infinity() + : 0.0; + } + double UpperBound(int64_t index) const { + return index < primal_size_ ? qp_.variable_upper_bounds[index] + : std::isfinite(qp_.constraint_lower_bounds[index - primal_size_]) + ? std::numeric_limits::infinity() + : 0.0; + } + double CenterPoint(int64_t index) const { + return index < primal_size_ ? primal_solution_[index] + : dual_solution_[index - primal_size_]; + } + double NormWeight(int64_t index) const { + return index < primal_size_ ? 0.5 * primal_weight_ : 0.5 / primal_weight_; + } + + private: + const QuadraticProgram& qp_; + const int64_t primal_size_; + const VectorXd& primal_solution_; + const VectorXd& dual_solution_; + const VectorXd& primal_gradient_; + const VectorXd& dual_gradient_; + const double primal_weight_; +}; + +struct TrustRegionResultStepSize { + // The step_size of the solution. + double solution_step_size; + // The value objective_vector' * (solution - center_point). + double objective_value; +}; + +// The distance (in the indexed element) from the center point to the bound, in +// the direction that reduces the objective. +template +double DistanceAtCriticalStepSize(const TrustRegionProblem& problem, + const int64_t index) { + if (problem.Objective(index) == 0.0) { + return 0.0; + } + if (problem.Objective(index) > 0.0) { + return problem.LowerBound(index) - problem.CenterPoint(index); + } else { + return problem.UpperBound(index) - problem.CenterPoint(index); + } +} + +// The critical step size is the step size at which the indexed element hits its +// bound (or infinity if that doesn't happen). +template +double CriticalStepSize(const TrustRegionProblem& problem, + const int64_t index) { + if (problem.Objective(index) == 0.0) { + return std::numeric_limits::infinity(); + } + return -problem.NormWeight(index) * + DistanceAtCriticalStepSize(problem, index) / problem.Objective(index); +} + +// The value of the indexed element at the given step_size, projected onto the +// bounds. +template +double ProjectedValue(const TrustRegionProblem& problem, const int64_t index, + const double step_size) { + const double full_step = + problem.CenterPoint(index) - + step_size * problem.Objective(index) / problem.NormWeight(index); + return std::min(std::max(full_step, problem.LowerBound(index)), + problem.UpperBound(index)); +} + +// The distance (in the indexed element) from the center point to the value at +// at the given step size projected onto the bounds. +template +double DistanceAtProjectedValue(const TrustRegionProblem& problem, + const int64_t index, const double step_size) { + return ProjectedValue(problem, index, step_size) - problem.CenterPoint(index); +} + +// This is an easy way of computing medians that's slightly off when the length +// of the array is even. "array" is intentionally passed by copy. +// "value_function" maps an element of "array" to its (double) value. Returns +// the value of the median element. +template +double EasyMedian(ArrayType array, ValueFunction value_function) { + CHECK_GT(array.size(), 0); + auto middle = array.begin() + (array.size() / 2); + absl::c_nth_element(array, middle, + [&](typename ArrayType::value_type lhs, + typename ArrayType::value_type rhs) { + return value_function(lhs) < value_function(rhs); + }); + return value_function(*middle); +} + +// "problem" is sharded according to the given sharder. Within each shard, +// this function selects the subset of elements corresponding to +// indexed_components_by_shard, and takes the median of the critical step sizes +// of these elements, producing an array A of shard medians. Then returns the +// median of the array A. CHECK-fails if indexed_components_by_shard is empty +// for all shards. +template +double MedianOfShardMedians( + const TrustRegionProblem& problem, + const std::vector>& indexed_components_by_shard, + const Sharder& sharder) { + std::vector> shard_medians(sharder.NumShards(), + absl::nullopt); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const auto& indexed_shard_components = + indexed_components_by_shard[shard.Index()]; + if (!indexed_shard_components.empty()) { + shard_medians[shard.Index()] = + EasyMedian(indexed_shard_components, [&](const int64_t index) { + return CriticalStepSize(problem, index); + }); + } + }); + std::vector non_empty_medians; + for (const auto& median : shard_medians) { + if (median.has_value()) { + non_empty_medians.push_back(*median); + } + } + CHECK(!non_empty_medians.empty()); + return EasyMedian(non_empty_medians, [](const double x) { return x; }); +} + +struct InitialState { + std::vector> undecided_components_by_shard; + double radius_coefficient_of_decided_coefficients; +}; + +// Lists the undecided components (per shard) as those with finite critical step +// sizes. The components with infinite critical step sizes will never hit their +// bounds, so we compute their contribution to the radius as +// radius_coefficient_of_decided_coefficients. +template +InitialState ComputeInitialState(const TrustRegionProblem& problem, + const Sharder& sharder) { + InitialState result; + result.undecided_components_by_shard.resize(sharder.NumShards()); + result.radius_coefficient_of_decided_coefficients = + sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + std::vector& undecided_components = + result.undecided_components_by_shard[shard.Index()]; + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_size = sharder.ShardSize(shard.Index()); + undecided_components.reserve(shard_size); + double radius_coefficient = 0.0; + for (int64_t index = shard_start; index < shard_start + shard_size; + ++index) { + if (std::isfinite(CriticalStepSize(problem, index))) { + undecided_components.push_back(index); + } else { + // Simplified from norm_weight * (objective / norm_weight)^2. + radius_coefficient += MathUtil::Square(problem.Objective(index)) / + problem.NormWeight(index); + } + } + return radius_coefficient; + }); + return result; +} + +template +double RadiusSquaredAtUndecidedComponents( + const TrustRegionProblem& problem, + const std::vector>& undecided_components_by_shard, + const double step_size, const Sharder& sharder) { + return sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + const std::vector& undecided_components = + undecided_components_by_shard[shard.Index()]; + return absl::c_accumulate( + undecided_components, 0.0, [&](double sum, int64_t index) { + return sum + problem.NormWeight(index) * + MathUtil::Square(DistanceAtProjectedValue( + problem, index, step_size)); + }); + }); +} + +// Points whose critical step-sizes are greater than or equal to pivot are +// eliminated from the undecided components (we know they'll be determined by +// center_point - step_size * objective / norm_weights). Returns the coefficient +// of step_size^2 that accounts of the contribution of the removed variables +// to the radius squared. +template +double RemoveCriticalStepsAbovePivot( + const double pivot, const TrustRegionProblem& problem, + const Sharder& sharder, + std::vector>& undecided_components_by_shard) { + return sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + std::vector& undecided_components = + undecided_components_by_shard[shard.Index()]; + double variable_radius_coefficient = 0.0; + for (const int64_t index : undecided_components) { + if (CriticalStepSize(problem, index) >= pivot) { + // Simplified from norm_weight * (objective / norm_weight)^2. + variable_radius_coefficient += + MathUtil::Square(problem.Objective(index)) / + problem.NormWeight(index); + } + } + auto result = + std::remove_if(undecided_components.begin(), undecided_components.end(), + [&](const int64_t index) { + return CriticalStepSize(problem, index) >= pivot; + }); + undecided_components.erase(result, undecided_components.end()); + return variable_radius_coefficient; + }); +} + +// Points whose critical step-sizes are smaller than or equal to pivot are +// eliminated from the undecided components (we know they'll always be at their +// bounds). Returns the weighted distance squared from the center point for the +// removed components. +template +double RemoveCriticalStepsBelowPivot( + const double pivot, const TrustRegionProblem& problem, + const Sharder& sharder, + std::vector>& undecided_components_by_shard) { + return sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + std::vector& undecided_components = + undecided_components_by_shard[shard.Index()]; + double radius_sq = 0.0; + for (const int64_t index : undecided_components) { + if (CriticalStepSize(problem, index) <= pivot) { + radius_sq += + problem.NormWeight(index) * + MathUtil::Square(DistanceAtCriticalStepSize(problem, index)); + } + } + auto result = + std::remove_if(undecided_components.begin(), undecided_components.end(), + [&](const int64_t index) { + return CriticalStepSize(problem, index) <= pivot; + }); + undecided_components.erase(result, undecided_components.end()); + return radius_sq; + }); +} + +int64_t NumUndecidedComponents( + const std::vector>& undecided_components_by_shard) { + int64_t num_undecided_components = 0; + for (const auto& undecided_components : undecided_components_by_shard) { + num_undecided_components += undecided_components.size(); + } + return num_undecided_components; +} + +int64_t MaxUndecidedComponentsInAnyShard( + const std::vector>& undecided_components_by_shard) { + int64_t max = 0; + for (const auto& undecided_components : undecided_components_by_shard) { + max = std::max(max, undecided_components.size()); + } + return max; +} + +template +VectorXd ComputeSolution(const TrustRegionProblem& problem, + const double step_size, const Sharder& sharder) { + VectorXd solution(sharder.NumElements()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_size = sharder.ShardSize(shard.Index()); + for (int64_t index = shard_start; index < shard_start + shard_size; + ++index) { + solution[index] = ProjectedValue(problem, index, step_size); + } + }); + return solution; +} + +template +double ComputeObjectiveValue(const TrustRegionProblem& problem, + const double step_size, const Sharder& sharder) { + return sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_size = sharder.ShardSize(shard.Index()); + double shard_value = 0.0; + for (int64_t index = shard_start; index < shard_start + shard_size; + ++index) { + shard_value += problem.Objective(index) * + (ProjectedValue(problem, index, step_size) - + problem.CenterPoint(index)); + } + return shard_value; + }); +} + +// Solves the following trust-region problem with bound constraints: +// min_x Objective' * (x - CenterPoint) +// s.t. LowerBound <= x <= UpperBound +// || x - Centerpoint ||_W <= target_radius +// where ||y||_W = sqrt(sum_i NormWeight[i] * y[i]^2) +// given by a TrustRegionProblem (see description at the top of this file), +// using an exact linear-time method. The sharder's size is used to determine +// the size of the problem. Assumes that there is always a feasible solution, +// that is, that problem.LowerBound(i) <= problem.CenterPoint(i) <= +// problem.UpperBound(i), and that problem.NormWeight(i) > 0, for +// 0 <= i < sharder.NumElements(). +// +// The linear-time method is based on the observation that the optimal x will be +// of the form x(delta) = +// proj(center_point - delta * objective_vector / norm_weights, bounds) +// for some delta such that || x(delta) - center_point ||_W = target_radius +// (except for corner cases where the radius constraint is inactive) and the +// vector division is element-wise. Therefore we find the critical threshold for +// each coordinate, and repeatedly: (1) take the median delta, (2) check the +// corresponding radius, and (3) eliminate half of the data points from +// consideration. +template +TrustRegionResultStepSize SolveTrustRegionStepSize( + const TrustRegionProblem& problem, const double target_radius, + const Sharder& sharder) { + CHECK_GE(target_radius, 0.0); + + const bool norm_weights_are_positive = + sharder.ParallelTrueForAllShards([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_size = sharder.ShardSize(shard.Index()); + for (int64_t index = shard_start; index < shard_start + shard_size; + ++index) { + if (problem.NormWeight(index) <= 0.0) return false; + } + return true; + }); + CHECK(norm_weights_are_positive); + + if (target_radius == 0.0) { + return {.solution_step_size = 0.0, .objective_value = 0.0}; + } + + const bool objective_is_all_zeros = + sharder.ParallelTrueForAllShards([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_size = sharder.ShardSize(shard.Index()); + for (int64_t index = shard_start; index < shard_start + shard_size; + ++index) { + if (problem.Objective(index) != 0.0) return false; + } + return true; + }); + if (objective_is_all_zeros) { + return {.solution_step_size = 0.0, .objective_value = 0.0}; + } + + InitialState initial_state = ComputeInitialState(problem, sharder); + + // The contribution to the weighted radius squared from the variables that we + // know are at their bounds in the solution. + double fixed_radius_squared = 0.0; + + // This value times step_size^2 gives the contribution to the weighted radius + // squared from the variables determined in the solution by the formula + // center_point - step_size * objective / norm_weights. These variables are + // not at their bounds in the solution, except in degenerate cases. + double variable_radius_coefficient = + initial_state.radius_coefficient_of_decided_coefficients; + + // For each shard, the components of the variables that aren't accounted for + // in fixed_radius_squared or variable_radius_coefficient, i.e., we don't know + // if they're at their bounds in the solution. + std::vector> undecided_components_by_shard( + std::move(initial_state.undecided_components_by_shard)); + + // These are counters for the number of variables we inspect overall during + // the solve, including in the initialization. The "worst case" accounts for + // imbalance across the shards by charging each round for the maximum number + // of elements in a shard, because shards with fewer elements may correspond + // to idle threads. + int64_t actual_elements_seen = sharder.NumElements(); + int64_t worst_case_elements_seen = sharder.NumElements(); + + while (NumUndecidedComponents(undecided_components_by_shard) > 0) { + worst_case_elements_seen += + MaxUndecidedComponentsInAnyShard(undecided_components_by_shard) * + sharder.NumShards(); + actual_elements_seen += + NumUndecidedComponents(undecided_components_by_shard); + + const double pivot = + MedianOfShardMedians(problem, undecided_components_by_shard, sharder); + const double radius_squared_at_undecided_components = + RadiusSquaredAtUndecidedComponents(problem, + undecided_components_by_shard, + /*step_size=*/pivot, sharder); + + const double radius_squared_at_pivot = + radius_squared_at_undecided_components + fixed_radius_squared + + variable_radius_coefficient * MathUtil::Square(pivot); + + if (radius_squared_at_pivot > MathUtil::Square(target_radius)) { + variable_radius_coefficient += RemoveCriticalStepsAbovePivot( + pivot, problem, sharder, undecided_components_by_shard); + } else { + fixed_radius_squared += RemoveCriticalStepsBelowPivot( + pivot, problem, sharder, undecided_components_by_shard); + } + } + VLOG(1) << "Total passes through variables: " + << actual_elements_seen / static_cast(sharder.NumElements()); + VLOG(1) << "Theoretical slowdown because of shard imbalance: " + << static_cast(worst_case_elements_seen) / + actual_elements_seen - + 1.0; + + // Now that we know exactly which variables are fixed at their bounds, + // compute the step size that will give us the exact target radius. + // This is the solution to: fixed_radius_squared + + // variable_radius_coefficient * step_size^2 == target_radius^2. + double step_size = 0.0; + if (variable_radius_coefficient > 0.0) { + step_size = + std::sqrt((MathUtil::Square(target_radius) - fixed_radius_squared) / + variable_radius_coefficient); + } else { + // All variables are fixed at their bounds. So we can take a very large + // finite step. We don't use infinity as the step in order to avoid 0 * + // infinity = NaN when zeros are present in the objective vector. It's ok if + // the result of step_size * objective_vector has infinity components + // because these are projected correctly to bounds. + step_size = std::numeric_limits::max(); + } + + return { + .solution_step_size = step_size, + .objective_value = ComputeObjectiveValue(problem, step_size, sharder)}; +} + +} // namespace + +TrustRegionResult SolveTrustRegion(const VectorXd& objective_vector, + const VectorXd& variable_lower_bounds, + const VectorXd& variable_upper_bounds, + const VectorXd& center_point, + const VectorXd& norm_weights, + const double target_radius, + const Sharder& sharder) { + VectorTrustRegionProblem problem(&objective_vector, &variable_lower_bounds, + &variable_upper_bounds, ¢er_point, + &norm_weights); + TrustRegionResultStepSize solution = + SolveTrustRegionStepSize(problem, target_radius, sharder); + return TrustRegionResult{ + .solution_step_size = solution.solution_step_size, + .objective_value = solution.objective_value, + .solution = + ComputeSolution(problem, solution.solution_step_size, sharder), + }; +} + +// A generic trust region problem of the form: +// min_{x} (1 / 2) * (x - center_point)'Q(x - center_point) +// + c'(x - center_point) +// s.t. l <= (x - center_point) <= u +// ||x - center_point||_W <= radius +// where ||z||_W = sqrt(sum_i w_i z_i^2) is a weighted Euclidean norm. +// It is assumed that the objective matrix Q is a nonnegative diagonal matrix. +class DiagonalTrustRegionProblem { + public: + // A reference to the objects passed in the constructor is kept, so they must + // outlive the DiagonalTrustRegionProblem instance. + DiagonalTrustRegionProblem(const VectorXd* objective_vector, + const VectorXd* objective_matrix_diagonal, + const VectorXd* lower_bounds, + const VectorXd* upper_bounds, + const VectorXd* center_point, + const VectorXd* norm_weights) + : objective_vector_(*objective_vector), + objective_matrix_diagonal_(*objective_matrix_diagonal), + variable_lower_bounds_(*lower_bounds), + variable_upper_bounds_(*upper_bounds), + center_point_(*center_point), + norm_weight_(*norm_weights) {} + + double CenterPoint(int64_t index) const { return center_point_[index]; } + + double NormWeight(int64_t index) const { return norm_weight_[index]; } + + double LowerBound(int64_t index) const { + return variable_lower_bounds_[index]; + } + + double UpperBound(int64_t index) const { + return variable_upper_bounds_[index]; + } + + double Objective(int64_t index) const { return objective_vector_[index]; } + + double ObjectiveMatrixDiagonalAt(int64_t index) const { + return objective_matrix_diagonal_[index]; + } + + private: + const VectorXd& objective_vector_; + const VectorXd& objective_matrix_diagonal_; + const VectorXd& variable_lower_bounds_; + const VectorXd& variable_upper_bounds_; + const VectorXd& center_point_; + const VectorXd& norm_weight_; +}; + +// DiagonalTrustRegionProblemFromQp accepts a diagonal quadratic program and +// information about the current solution and gradient and sets up the following +// trust-region subproblem: +// min_{x, y} (x - primal_solution)'Q(x - primal_solution) +// + primal_gradient'(x - primal_solution) +// - dual_gradient'(y - dual_solution) +// s.t. l <= x - primal_solution <= u +// l_implicit <= y - dual_solution <= u_implicit +// ||(x, y) - (primal_solution, dual_solution)||_W <= r, +// where +// ||(x, y)||_W = sqrt(0.5 * primal_weight ||x||^2 + +// (0.5 / primal_weight) ||y||^2). +// This class implements the same methods as DiagonalTrustRegionProblem, but +// without the need to explicitly copy vectors. +class DiagonalTrustRegionProblemFromQp { + public: + // A reference to the objects passed in the constructor is kept, so they must + // outlive the DiagonalTrustRegionProblemFromQp instance. + DiagonalTrustRegionProblemFromQp(const QuadraticProgram* qp, + const VectorXd* primal_solution, + const VectorXd* dual_solution, + const VectorXd* primal_gradient, + const VectorXd* dual_gradient, + const double primal_weight) + : qp_(*qp), + primal_solution_(*primal_solution), + dual_solution_(*dual_solution), + primal_gradient_(*primal_gradient), + dual_gradient_(*dual_gradient), + primal_size_(primal_solution->size()), + primal_weight_(primal_weight) {} + + double CenterPoint(int64_t index) const { + return (index < primal_size_) ? primal_solution_[index] + : dual_solution_[index - primal_size_]; + } + + double NormWeight(int64_t index) const { + return (index < primal_size_) ? 0.5 * primal_weight_ : 0.5 / primal_weight_; + } + + double LowerBound(int64_t index) const { + if (index < primal_size_) { + return qp_.variable_lower_bounds[index]; + } else { + return std::isfinite(qp_.constraint_upper_bounds[index - primal_size_]) + ? -std::numeric_limits::infinity() + : 0.0; + } + } + + double UpperBound(int64_t index) const { + if (index < primal_size_) { + return qp_.variable_upper_bounds[index]; + } else { + return std::isfinite(qp_.constraint_lower_bounds[index - primal_size_]) + ? std::numeric_limits::infinity() + : 0.0; + } + } + + double Objective(int64_t index) const { + return (index < primal_size_) ? primal_gradient_[index] + : -dual_gradient_[index - primal_size_]; + } + + double ObjectiveMatrixDiagonalAt(int64_t index) const { + if (qp_.objective_matrix.has_value()) { + return (index < primal_size_) ? qp_.objective_matrix->diagonal()[index] + : 0.0; + } else { + return 0.0; + } + } + + private: + const QuadraticProgram& qp_; + const VectorXd& primal_solution_; + const VectorXd& dual_solution_; + const VectorXd& primal_gradient_; + const VectorXd& dual_gradient_; + const int64_t primal_size_; + const double primal_weight_; +}; + +// Computes a single coordinate projection of the scaled difference, +// sqrt(NormWeight(i)) * (x[i] - CenterPoint(i)), to the corresponding box +// constraints. As a function of scaling_factor, the difference is equal to +// (Q[i, i] / NormWeight(i)) + scaling_factor)^{-1} * +// (-c[i] / sqrt(NormWeight(i))), +// where Q, c are the objective matrix and vector, respectively. +template +double ProjectedValueOfScaledDifference( + const DiagonalTrustRegionProblem& problem, const int64_t index, + const double scaling_factor) { + const double weight = problem.NormWeight(index); + return std::min( + std::max((-problem.Objective(index) / std::sqrt(weight)) / + (problem.ObjectiveMatrixDiagonalAt(index) / weight + + scaling_factor), + std::sqrt(weight) * + (problem.LowerBound(index) - problem.CenterPoint(index))), + std::sqrt(weight) * + (problem.UpperBound(index) - problem.CenterPoint(index))); +} + +// Computes the norm of the projection of the difference vector, +// x - center_point, to the corresponding box constraints. We are using the +// standard Euclidean norm (instead of the weighted norm) because the solver +// implicitly reformulates the problem to one with a Euclidean ball constraint +// first. +template +double NormOfDeltaProjection(const DiagonalTrustRegionProblem& problem, + const Sharder& sharder, + const double scaling_factor) { + const double squared_norm = + sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_end = + shard_start + sharder.ShardSize(shard.Index()); + double sum = 0.0; + for (int64_t i = shard_start; i < shard_end; ++i) { + const double projected_coordinate = + ProjectedValueOfScaledDifference(problem, i, scaling_factor); + sum += MathUtil::Square(projected_coordinate); + } + return sum; + }); + return std::sqrt(squared_norm); +} + +// Finds an approximately optimal scaling factor for the solution of the trust +// region subproblem, which can be passed on to ProjectedCoordinate() to find +// an approximately optimal solution to the trust region subproblem. The value +// returned is guaranteed to be within `solve_tol * max(1, s*)` of the optimal +// scaling `s*`. +// TODO(user): figure out what accuracy is useful to callers and redo the +// stopping criterion accordingly. +template +double FindScalingFactor(const DiagonalTrustRegionProblem& problem, + const Sharder& sharder, const double target_radius, + const double solve_tol) { + // Determine a search interval using monotonicity of the squared norm of the + // candidate solution with respect to the scaling factor. + double scaling_factor_lower_bound = 0.0; + double scaling_factor_upper_bound = 1.0; + while (NormOfDeltaProjection(problem, sharder, scaling_factor_upper_bound) >= + target_radius) { + scaling_factor_lower_bound = scaling_factor_upper_bound; + scaling_factor_upper_bound *= 2; + } + // Invariant: bounds.upper_bound >= bounds.lower_bound. + while ((scaling_factor_upper_bound - scaling_factor_lower_bound) >= + solve_tol * std::max(1.0, scaling_factor_lower_bound)) { + const double middle = + (scaling_factor_lower_bound + scaling_factor_upper_bound) / 2.0; + // Norm is monotonically non-increasing as a function of scaling_factor. + if (NormOfDeltaProjection(problem, sharder, middle) <= target_radius) { + scaling_factor_upper_bound = middle; + } else { + scaling_factor_lower_bound = middle; + } + } + return (scaling_factor_upper_bound + scaling_factor_lower_bound) / 2.0; +} + +// Solves the diagonal trust region problem using a binary search algorithm. +// See comment above SolveDiagonalTrustRegion() in trust_region.h for the +// meaning of solve_tol. +template +TrustRegionResult SolveDiagonalTrustRegionProblem( + const DiagonalTrustRegionProblem& problem, const Sharder& sharder, + const double target_radius, const double solve_tol) { + CHECK_GE(target_radius, 0.0); + const bool norm_weights_are_positive = + sharder.ParallelTrueForAllShards([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + for (int64_t i = shard_start; + i < shard_start + sharder.ShardSize(shard.Index()); ++i) { + if (problem.NormWeight(i) <= 0) { + return false; + } + } + return true; + }); + CHECK(norm_weights_are_positive); + const double optimal_scaling = + FindScalingFactor(problem, sharder, target_radius, solve_tol); + VectorXd solution(sharder.NumElements()); + sharder.ParallelForEachShard([&](const Sharder::Shard& shard) { + const int64_t shard_start = sharder.ShardStart(shard.Index()); + const int64_t shard_size = sharder.ShardSize(shard.Index()); + for (int64_t i = shard_start; i < shard_start + shard_size; ++i) { + const double weight = problem.NormWeight(i); + const double projected_value = + ProjectedValueOfScaledDifference(problem, i, optimal_scaling); + solution[i] = + problem.CenterPoint(i) + std::sqrt(1 / weight) * projected_value; + } + }); + const double final_objective_value = + sharder.ParallelSumOverShards([&](const Sharder::Shard& shard) { + double local_sum = 0.0; + const int64_t shard_start = sharder.ShardStart(shard.Index()); + for (int64_t i = shard_start; + i < shard_start + sharder.ShardSize(shard.Index()); ++i) { + const double diff = solution[i] - problem.CenterPoint(i); + local_sum += + 0.5 * diff * problem.ObjectiveMatrixDiagonalAt(i) * diff + + diff * problem.Objective(i); + } + return local_sum; + }); + return {.solution_step_size = optimal_scaling, + .objective_value = final_objective_value, + .solution = solution}; +} + +TrustRegionResult SolveDiagonalTrustRegion( + const VectorXd& objective_vector, const VectorXd& objective_matrix_diagonal, + const VectorXd& variable_lower_bounds, + const VectorXd& variable_upper_bounds, const VectorXd& center_point, + const VectorXd& norm_weights, const double target_radius, + const Sharder& sharder, const double solve_tolerance) { + DiagonalTrustRegionProblem problem( + &objective_vector, &objective_matrix_diagonal, &variable_lower_bounds, + &variable_upper_bounds, ¢er_point, &norm_weights); + return SolveDiagonalTrustRegionProblem(problem, sharder, target_radius, + solve_tolerance); +} + +TrustRegionResult SolveDiagonalQpTrustRegion( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& primal_solution, + const VectorXd& dual_solution, const VectorXd& primal_gradient, + const VectorXd& dual_gradient, const double primal_weight, + double target_radius, const double solve_tolerance) { + const int64_t problem_size = sharded_qp.PrimalSize() + sharded_qp.DualSize(); + DiagonalTrustRegionProblemFromQp problem(&sharded_qp.Qp(), &primal_solution, + &dual_solution, &primal_gradient, + &dual_gradient, primal_weight); + + const Sharder joint_sharder(sharded_qp.PrimalSharder(), problem_size); + const bool norm_weights_are_positive = + joint_sharder.ParallelTrueForAllShards([&](const Sharder::Shard& shard) { + const int64_t shard_start = joint_sharder.ShardStart(shard.Index()); + for (int64_t i = shard_start; + i < shard_start + joint_sharder.ShardSize(shard.Index()); ++i) { + if (problem.NormWeight(i) <= 0) { + return false; + } + } + return true; + }); + CHECK(norm_weights_are_positive); + return SolveDiagonalTrustRegionProblem(problem, joint_sharder, target_radius, + solve_tolerance); +} + +namespace { + +struct MaxNormBoundResult { + // LagrangianPart.value from ComputePrimalGradient and ComputeDualGradient, + // respectively. + double part_of_lagrangian_value = 0.0; + // For the primal, the value + // ∇_x L(primal_solution, dual_solution)^T (x^* - primal_solution) where + // x^* is the solution of the primal trust region subproblem. + // For the dual, the value + // ∇_y L(primal_solution, dual_solution)^T (y^* - dual_solution) where + // y^* is the solution of the dual trust region subproblem. + // This will be a non-positive value for the primal and a non-negative + // value for the dual. + double trust_region_objective_delta = 0.0; +}; + +MaxNormBoundResult ComputeMaxNormPrimalTrustRegionBound( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& primal_solution, + const double primal_radius, const VectorXd& dual_product) { + LagrangianPart primal_part = + ComputePrimalGradient(sharded_qp, primal_solution, dual_product); + PrimalTrustRegionProblem primal_problem(&sharded_qp.Qp(), &primal_solution, + &primal_part.gradient); + TrustRegionResultStepSize trust_region_result = SolveTrustRegionStepSize( + primal_problem, primal_radius, sharded_qp.PrimalSharder()); + return {.part_of_lagrangian_value = primal_part.value, + .trust_region_objective_delta = trust_region_result.objective_value}; +} + +MaxNormBoundResult ComputeMaxNormDualTrustRegionBound( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& dual_solution, + const double dual_radius, const VectorXd& primal_product) { + LagrangianPart dual_part = + ComputeDualGradient(sharded_qp, dual_solution, primal_product); + DualTrustRegionProblem dual_problem(&sharded_qp.Qp(), &dual_solution, + &dual_part.gradient); + TrustRegionResultStepSize trust_region_result = SolveTrustRegionStepSize( + dual_problem, dual_radius, sharded_qp.DualSharder()); + return {.part_of_lagrangian_value = dual_part.value, + .trust_region_objective_delta = -trust_region_result.objective_value}; +} + +// Returns the largest radius that the primal could move (in Euclidean distance) +// to match the weighted_distance. This is the largest value of ||x||_2 such +// that there exists a y such that max{||x||_P, ||y||_D} <= weighted_distance. +double MaximumPrimalDistanceGivenWeightedDistance( + const double weighted_distance, const double primal_weight) { + return std::sqrt(2) * weighted_distance / std::sqrt(primal_weight); +} + +// Returns the largest radius that the dual could move (in Euclidean distance) +// to match the weighted_distance. This is the largest value of ||y||_2 such +// that there exists an x such that max{||x||_P, ||y||_D} <= weighted_distance. +double MaximumDualDistanceGivenWeightedDistance(const double weighted_distance, + const double primal_weight) { + return std::sqrt(2) * weighted_distance * std::sqrt(primal_weight); +} + +LocalizedLagrangianBounds ComputeMaxNormLocalizedLagrangianBounds( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& primal_solution, + const VectorXd& dual_solution, const double primal_weight, + const double radius, const Eigen::VectorXd& primal_product, + const Eigen::VectorXd& dual_product) { + const double primal_radius = + MaximumPrimalDistanceGivenWeightedDistance(radius, primal_weight); + const double dual_radius = + MaximumDualDistanceGivenWeightedDistance(radius, primal_weight); + + // The max norm means that the optimization over the primal and the dual can + // be done independently. + + MaxNormBoundResult primal_result = ComputeMaxNormPrimalTrustRegionBound( + sharded_qp, primal_solution, primal_radius, dual_product); + + MaxNormBoundResult dual_result = ComputeMaxNormDualTrustRegionBound( + sharded_qp, dual_solution, dual_radius, primal_product); + + const double lagrangian_value = primal_result.part_of_lagrangian_value + + dual_result.part_of_lagrangian_value; + + return LocalizedLagrangianBounds{ + .lagrangian_value = lagrangian_value, + .lower_bound = + lagrangian_value + primal_result.trust_region_objective_delta, + .upper_bound = + lagrangian_value + dual_result.trust_region_objective_delta, + .radius = radius}; +} + +LocalizedLagrangianBounds ComputeEuclideanNormLocalizedLagrangianBounds( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& primal_solution, + const VectorXd& dual_solution, const double primal_weight, + const double radius, const Eigen::VectorXd& primal_product, + const Eigen::VectorXd& dual_product, + const bool use_diagonal_qp_trust_region_solver, + const double diagonal_qp_trust_region_solver_tolerance) { + const QuadraticProgram& qp = sharded_qp.Qp(); + const LagrangianPart primal_part = + ComputePrimalGradient(sharded_qp, primal_solution, dual_product); + const LagrangianPart dual_part = + ComputeDualGradient(sharded_qp, dual_solution, primal_product); + + VectorXd trust_region_solution; + const double lagrangian_value = primal_part.value + dual_part.value; + + Sharder joint_sharder( + sharded_qp.PrimalSharder(), + /*num_elements=*/sharded_qp.PrimalSize() + sharded_qp.DualSize()); + + if (use_diagonal_qp_trust_region_solver) { + DiagonalTrustRegionProblemFromQp problem( + &qp, &primal_solution, &dual_solution, &primal_part.gradient, + &dual_part.gradient, primal_weight); + + trust_region_solution = SolveDiagonalTrustRegionProblem( + problem, joint_sharder, radius, + diagonal_qp_trust_region_solver_tolerance) + .solution; + } else { + JointTrustRegionProblem joint_problem(&qp, &primal_solution, &dual_solution, + &primal_part.gradient, + &dual_part.gradient, primal_weight); + + TrustRegionResultStepSize trust_region_result = + SolveTrustRegionStepSize(joint_problem, radius, joint_sharder); + + trust_region_solution = ComputeSolution( + joint_problem, trust_region_result.solution_step_size, joint_sharder); + } + + auto primal_trust_region_solution = + trust_region_solution.segment(0, sharded_qp.PrimalSize()); + auto dual_trust_region_solution = trust_region_solution.segment( + sharded_qp.PrimalSize(), sharded_qp.DualSize()); + + // ∇_x L(primal_solution, dual_solution)^T (x - primal_solution) + double primal_objective_delta = + sharded_qp.PrimalSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + return shard(primal_part.gradient) + .dot(shard(primal_trust_region_solution) - + shard(primal_solution)); + }); + + // Take into account the quadratic's contribution if the diagonal QP solver + // is enabled. + if (use_diagonal_qp_trust_region_solver && + sharded_qp.Qp().objective_matrix.has_value()) { + primal_objective_delta += sharded_qp.PrimalSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + const int shard_start = + sharded_qp.PrimalSharder().ShardStart(shard.Index()); + const int shard_size = + sharded_qp.PrimalSharder().ShardSize(shard.Index()); + double sum = 0.0; + for (int i = shard_start; i < shard_start + shard_size; ++i) { + sum += 0.5 * sharded_qp.Qp().objective_matrix->diagonal()[i] * + MathUtil::Square(primal_trust_region_solution[i] - + primal_solution[i]); + } + return sum; + }); + } + + // ∇_y L(primal_solution, dual_solution)^T (y - dual_solution) + const double dual_objective_delta = + sharded_qp.DualSharder().ParallelSumOverShards( + [&](const Sharder::Shard& shard) { + return shard(dual_part.gradient) + .dot(shard(dual_trust_region_solution) - shard(dual_solution)); + }); + + return LocalizedLagrangianBounds{ + .lagrangian_value = lagrangian_value, + .lower_bound = lagrangian_value + primal_objective_delta, + .upper_bound = lagrangian_value + dual_objective_delta, + .radius = radius}; +} + +} // namespace + +LocalizedLagrangianBounds ComputeLocalizedLagrangianBounds( + const ShardedQuadraticProgram& sharded_qp, const VectorXd& primal_solution, + const VectorXd& dual_solution, const PrimalDualNorm primal_dual_norm, + const double primal_weight, const double radius, + const VectorXd* primal_product, const VectorXd* dual_product, + const bool use_diagonal_qp_trust_region_solver, + const double diagonal_qp_trust_region_solver_tolerance) { + const QuadraticProgram& qp = sharded_qp.Qp(); + VectorXd primal_product_storage; + VectorXd dual_product_storage; + + if (primal_product == nullptr) { + primal_product_storage = TransposedMatrixVectorProduct( + sharded_qp.TransposedConstraintMatrix(), primal_solution, + sharded_qp.TransposedConstraintMatrixSharder()); + primal_product = &primal_product_storage; + } + if (dual_product == nullptr) { + dual_product_storage = + TransposedMatrixVectorProduct(qp.constraint_matrix, dual_solution, + sharded_qp.ConstraintMatrixSharder()); + dual_product = &dual_product_storage; + } + + switch (primal_dual_norm) { + case PrimalDualNorm::kMaxNorm: + return ComputeMaxNormLocalizedLagrangianBounds( + sharded_qp, primal_solution, dual_solution, primal_weight, radius, + *primal_product, *dual_product); + case PrimalDualNorm::kEuclideanNorm: + return ComputeEuclideanNormLocalizedLagrangianBounds( + sharded_qp, primal_solution, dual_solution, primal_weight, radius, + *primal_product, *dual_product, use_diagonal_qp_trust_region_solver, + diagonal_qp_trust_region_solver_tolerance); + } + LOG(FATAL) << "Unrecognized primal dual norm"; + + return LocalizedLagrangianBounds(); +} + +} // namespace operations_research::pdlp diff --git a/ortools/pdlp/trust_region.h b/ortools/pdlp/trust_region.h new file mode 100644 index 0000000000..0e07efab24 --- /dev/null +++ b/ortools/pdlp/trust_region.h @@ -0,0 +1,167 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PDLP_TRUST_REGION_H_ +#define PDLP_TRUST_REGION_H_ + +#include "Eigen/Core" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" + +namespace operations_research::pdlp { + +struct TrustRegionResult { + // The step_size of the solution. + double solution_step_size; + // The value objective_vector' * (solution - center_point) + // when using the linear-time solver for LPs and QPs with objective matrix not + // treated in the prox term. When using the approximate solver for QPs, this + // field contains the value + // 0.5 * (solution - center_point)' * objective_matrix * ( + // solution - center_point) + // + objective_vector' * (solution - center_point) + // instead. + double objective_value; + // The solution. + Eigen::VectorXd solution; +}; + +// Solves the following trust-region problem with bound constraints: +// min_x objective_vector' * (x - center_point) +// s.t. variable_lower_bounds <= x <= variable_upper_bounds +// || x - center_point ||_W <= target_radius +// where ||y||_W = sqrt(sum_i norm_weights[i] * y[i]^2) +// using an exact linear-time method. +// The sharder should have the same size as the number of variables in the +// problem. +// Assumes that there is always a feasible solution, that is, that +// variable_lower_bounds <= center_point <= variable_upper_bounds, and that +// norm_weights > 0, for 0 <= i < sharder.NumElements(). +TrustRegionResult SolveTrustRegion(const Eigen::VectorXd& objective_vector, + const Eigen::VectorXd& variable_lower_bounds, + const Eigen::VectorXd& variable_upper_bounds, + const Eigen::VectorXd& center_point, + const Eigen::VectorXd& norm_weights, + double target_radius, + const Sharder& sharder); + +// Solves the following trust-region problem with bound constraints: +// min_x (1/2) * (x - center_point)' * Q * (x - center_point) +// + objective_vector' * (x - center_point) +// s.t. variable_lower_bounds <= x <= variable_upper_bounds +// || x - center_point ||_W <= target_radius +// where ||y||_W = sqrt(sum_i norm_weights[i] * y[i]^2). +// It replaces the ball constraint || x - center_point ||_W <= target_radius +// with the equivalent constraint 0.5 * || x - center_point ||_W^2 <= 0.5 * +// target_radius^2 and does a binary search for a Lagrange multiplier for the +// latter constraint that is at most `solve_tolerance * max(1, lambda*)` away +// from the optimum Lagrange multiplier lambda*. +// The sharder should have the same size as the number of variables in the +// problem. +// Assumes that there is always a feasible solution, that is, that +// variable_lower_bounds <= center_point <= variable_upper_bounds, and that +// norm_weights > 0, for 0 <= i < sharder.NumElements(). +TrustRegionResult SolveDiagonalTrustRegion( + const Eigen::VectorXd& objective_vector, + const Eigen::VectorXd& objective_matrix_diagonal, + const Eigen::VectorXd& variable_lower_bounds, + const Eigen::VectorXd& variable_upper_bounds, + const Eigen::VectorXd& center_point, const Eigen::VectorXd& norm_weights, + double target_radius, const Sharder& sharder, double solve_tolerance); + +// Like SolveDiagonalTrustRegion, but extracts the problem data from a +// ShardedQuadraticProgram and implicitly concatenates the primal and dual parts +// before solving the trust-region subproblem. +TrustRegionResult SolveDiagonalQpTrustRegion( + const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_solution, + const Eigen::VectorXd& primal_gradient, + const Eigen::VectorXd& dual_gradient, const double primal_weight, + double target_radius, double solve_tolerance); + +struct LocalizedLagrangianBounds { + // The value of the Lagrangian function L(x, y) at the given solution. + double lagrangian_value; + // A lower bound on the Lagrangian value, valid for the given radius. + double lower_bound; + // An upper bound on the Lagrangian value, valid for the given radius. + double upper_bound; + // The radius used when computing the bounds. + double radius; +}; + +inline double BoundGap(const LocalizedLagrangianBounds& bounds) { + return bounds.upper_bound - bounds.lower_bound; +} + +// Defines a norm on a vector partitioned as (x, y) where x is the primal and y +// is the dual. The enum values define a joint norm as a function of ||x||_P and +// ||y||_D, whose definition depends on the context. +enum class PrimalDualNorm { + // The joint norm ||(x,y)||_PD = max{||x||_P, ||y||_D}. + kMaxNorm, + // The joint norm (||(x,y)||_PD)^2 = (||x||_P)^2 + (||y||_D)^2. + kEuclideanNorm +}; + +// Recall the saddle-point formulation OPT = min_x max_y L(x, y) defined at +// https://developers.google.com/optimization/lp/pdlp_math#saddle-point_formulation. +// This function computes lower and upper bounds on OPT with an additional ball +// or "trust- region" constraint on the domains of x and y. +// +// The bounds are derived from the solution of the following problem: +// min_{x,y} ∇_x L(primal_solution, dual_solution)^T (x - primal_solution) - +// ∇_y L(primal_solution, dual_solution)^T (y - dual_solution) +// subject to ||(x - primal_solution, y - dual_solution)||_PD <= radius, +// where x and y are constrained to their respective bounds and ||(x,y)||_PD is +// defined by primal_dual_norm. +// When use_diagonal_qp_trust_region_solver is true, the solver instead solves +// the following problem: +// min_{x,y} ∇_x L(primal_solution, dual_solution)^T (x - primal_solution) - +// ∇_y L(primal_solution, dual_solution)^T (y - dual_solution) + +// (1 / 2) * (x - primal_solution)^T * objective_matrix * (x - primal_solution), +// subject to ||(x - primal_solution, y - dual_solution)||_PD <= radius. +// use_diagonal_qp_trust_region_solver == true assumes that primal_dual_norm +// is the Euclidean norm and the objective matrix is diagonal. +// See SolveDiagonalTrustRegion() above for the meaning of +// diagonal_qp_trust_region_solver_tolerance. +// +// In the context of primal_dual_norm, the primal norm ||.||_P is defined as +// (||x||_P)^2 = (1 / 2) * primal_weight * ||x||_2^2, and the dual norm ||.||_D +// is defined as +// (||y||_D)^2 = (1 / 2) * (1 / primal_weight) * ||y||_2^2. +// +// Given an optimal solution (x, y) to the above problem, the lower bound is +// computed as L(primal_solution, dual_solution) + +// ∇_x L(primal_solution, dual_solution)^T (x - primal_solution) +// and the upper bound is computed as L(primal_solution, dual_solution) + +// ∇_y L(primal_solution, dual_solution)^T (y - dual_solution). +// +// The bounds are "localized" because they are guaranteed to bound OPT only if +// the ||.||_PD ball contains an optimal solution. +// The primal_product and dual_product arguments optionally specify the values +// of constraint_matrix * primal_solution and constraint_matrix.transpose() * +// dual_solution, respectively. If set to nullptr, they will be computed. +LocalizedLagrangianBounds ComputeLocalizedLagrangianBounds( + const ShardedQuadraticProgram& sharded_qp, + const Eigen::VectorXd& primal_solution, + const Eigen::VectorXd& dual_solution, PrimalDualNorm primal_dual_norm, + double primal_weight, double radius, const Eigen::VectorXd* primal_product, + const Eigen::VectorXd* dual_product, + bool use_diagonal_qp_trust_region_solver, + double diagonal_qp_trust_region_solver_tolerance); + +} // namespace operations_research::pdlp + +#endif // PDLP_TRUST_REGION_H_ diff --git a/ortools/pdlp/trust_region_test.cc b/ortools/pdlp/trust_region_test.cc new file mode 100644 index 0000000000..5fe5d3c0aa --- /dev/null +++ b/ortools/pdlp/trust_region_test.cc @@ -0,0 +1,1093 @@ +// Copyright 2010-2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ortools/pdlp/trust_region.h" + +#include +#include +#include +#include +#include + +#include "Eigen/Core" +#include "Eigen/SparseCore" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "ortools/pdlp/quadratic_program.h" +#include "ortools/pdlp/sharded_optimization_utils.h" +#include "ortools/pdlp/sharded_quadratic_program.h" +#include "ortools/pdlp/sharder.h" +#include "ortools/pdlp/test_util.h" + +namespace operations_research::pdlp { +namespace { + +using ::Eigen::VectorXd; +using ::testing::DoubleNear; +using ::testing::ElementsAre; + +constexpr double kInfinity = std::numeric_limits::infinity(); + +class TrustRegion : public testing::TestWithParam< + /*use_diagonal_solver=*/bool> {}; + +INSTANTIATE_TEST_SUITE_P( + TrustRegionSolvers, TrustRegion, testing::Bool(), + [](const testing::TestParamInfo& info) { + return (info.param) ? "UseApproximateTRSolver" : "UseLinearTimeTRSolver"; + }); + +TEST_P(TrustRegion, SolvesWithoutVariableBounds) { + // min x + y + // ||(x - 2.0, y - (-5.0))||_2 <= sqrt(2) + // [x*, y*] = [1.0, -6.0] + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2); + variable_lower_bounds << -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, -5.0; + objective_vector << 1.0, 1.0; + const double target_radius = std::sqrt(2.0); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(2); + expected_solution << 1.0, -6.0; + const double expected_objective_value = -2.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(2), target_radius, sharder, + /*solve_tolerance=*/1.0e-8); + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(2), target_radius, + sharder); + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegion, SolvesWithVariableBounds) { + // min x - y + z + // ||(x - 2.0, y - (-5.0), z - 1.0)||_2 <= sqrt(2.0) + // x >= 2.0 + // [x*, y*, z*] = [2.0, -4.0, 0.0] + VectorXd variable_lower_bounds(3), variable_upper_bounds(3), center_point(3), + objective_vector(3); + variable_lower_bounds << 2.0, -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity, kInfinity; + center_point << 2.0, -5.0, 1.0; + objective_vector << 1.0, -1.0, 1.0; + const double target_radius = std::sqrt(2.0); + + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(3); + expected_solution << 2.0, -4.0, 0.0; + const double expected_objective_value = -2.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(3), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(3), target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(3), target_radius, + sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegion, SolvesAtVariableBounds) { + // min x - y + // ||(x - 2.0, y - (-5.0))||_2 <= 1 + // x >= 2.0, y <= -5.0 + // [x*, y*] = [2.0, -5.0] + // The bound constraints block movement from the center point. + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2); + variable_lower_bounds << 2.0, -kInfinity; + variable_upper_bounds << kInfinity, -5.0; + center_point << 2.0, -5.0; + objective_vector << 1.0, -1.0; + const double target_radius = 1.0; + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(2); + expected_solution << 2.0, -5.0; + const double expected_objective_value = 0.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(2), target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(2), target_radius, + sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegion, SolvesWithInactiveRadius) { + // min x - y + z + // ||(x - 2.0, y - (-5.0), z - 1.0)||_2 <= 1 + // x >= 2.0, y <= -5.0, z >= 0.5 + // [x*, y*, z*] = [2.0, -5.0, 0.5] + // This is a corner case where the radius constraint is not active at the + // solution. + VectorXd variable_lower_bounds(3), variable_upper_bounds(3), center_point(3), + objective_vector(3); + variable_lower_bounds << 2.0, -kInfinity, 0.5; + variable_upper_bounds << kInfinity, -5.0, kInfinity; + center_point << 2.0, -5.0, 1.0; + objective_vector << 1.0, -1.0, 1.0; + const double target_radius = 1.0; + + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(3); + expected_solution << 2.0, -5.0, 0.5; + const double expected_objective_value = -0.5; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(3), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(3), target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(3), target_radius, + sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegion, SolvesWithInfiniteRadius) { + // min x - y + z + // ||(x - 2.0, y - (-5.0), z - 1.0)||_2 <= Infinity + // x >= 2.0, y <= -5.0, z >= 0.5 + // [x*, y*, z*] = [2.0, -5.0, 0.5] + VectorXd variable_lower_bounds(3), variable_upper_bounds(3), center_point(3), + objective_vector(3); + variable_lower_bounds << 2.0, -kInfinity, 0.5; + variable_upper_bounds << kInfinity, -5.0, kInfinity; + center_point << 2.0, -5.0, 1.0; + objective_vector << 1.0, -1.0, 1.0; + const double target_radius = kInfinity; + + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(3); + expected_solution << 2.0, -5.0, 0.5; + const double expected_objective_value = -0.5; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(3), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(3), target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(3), target_radius, + sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegion, SolvesWithMixedObjective) { + // min 2x + y + // ||(x - 2.0, y - 1.0)||_2 <= sqrt(1.25) + // x >= 1.0, y >= 0 + // [x*, y*] = [1.0, 0.5] + // We take a positive step in all coordinates. Only the first coordinate + // hits its bound. + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2); + variable_lower_bounds << 1.0, 0.0; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, 1.0; + objective_vector << 2.0, 1.0; + const double target_radius = std::sqrt(1.25); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(2); + expected_solution << 1.0, 0.5; + const double expected_objective_value = -2.5; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(2), target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 2.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(2), target_radius, + sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegion, SolvesWithZeroObjectiveNoBounds) { + // min 0*x + // ||(x - 2.0)||_2 <= 1 + // x* = 2.0 + VectorXd variable_lower_bounds(1), variable_upper_bounds(1), center_point(1), + objective_vector(1); + variable_lower_bounds << -kInfinity; + variable_upper_bounds << kInfinity; + center_point << 2.0; + objective_vector << 0.0; + const double target_radius = 1.0; + + Sharder sharder(/*num_elements=*/1, /*num_shards=*/1, nullptr); + + VectorXd expected_solution(1); + expected_solution << 2.0; + const double expected_objective_value = 0.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(1), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(1), target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, /*norm_weights=*/VectorXd::Ones(1), target_radius, + sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +class TrustRegionWithWeights : public testing::TestWithParam< + /*use_diagonal_solver=*/bool> {}; + +INSTANTIATE_TEST_SUITE_P( + TrustRegionSolverWithWeights, TrustRegionWithWeights, testing::Bool(), + [](const testing::TestParamInfo& info) { + return (info.param) ? "UseApproximateTRSolver" : "UseLinearTimeTRSolver"; + }); + +TEST_P(TrustRegionWithWeights, SolvesWithoutVariableBounds) { + // min x + 2.0 y + // ||(x - 2.0, y - (-5.0))||_W <= sqrt(3) + // norm_weights = [1.0, 2.0] + // [x*, y*] = [1.0, -6.0] + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2), norm_weights(2); + variable_lower_bounds << -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, -5.0; + objective_vector << 1.0, 2.0; + norm_weights << 1.0, 2.0; + const double target_radius = std::sqrt(3.0); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(2); + expected_solution << 1.0, -6.0; + const double expected_objective_value = -3.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + norm_weights, target_radius, sharder, /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-5); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, norm_weights, target_radius, sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegionWithWeights, SolvesWithVariableBounds) { + // min 0.5 x - 2.0 y + 3.0 z + // ||(x - 2.0, y - (-5.0), z - 1.0)||_W <= sqrt(5) + // x >= 2.0 + // norm_weights = [0.5, 2.0, 3.0] + // [x*, y*, z*] = [2.0, -4.0, 0.0] + VectorXd variable_lower_bounds(3), variable_upper_bounds(3), center_point(3), + objective_vector(3), norm_weights(3); + variable_lower_bounds << 2.0, -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity, kInfinity; + center_point << 2.0, -5.0, 1.0; + objective_vector << 0.5, -2.0, 3.0; + norm_weights << 0.5, 2.0, 3.0; + const double target_radius = std::sqrt(5.0); + + Sharder sharder(/*num_elements=*/3, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(3); + expected_solution << 2.0, -4.0, 0.0; + const double expected_objective_value = -5.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(3), + variable_lower_bounds, variable_upper_bounds, center_point, + norm_weights, target_radius, sharder, /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-5); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, norm_weights, target_radius, sharder); + + EXPECT_THAT(result.solution, EigenArrayEq(expected_solution)); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegionWithWeights, SolvesWithVariableThatHitsBounds) { + // min x + 2y + // ||(x - 2.0, y - 1.0)||_2 <= 1 + // x >= 1.0, y >= 0 + // [x*, y*] = [1.0, 0.5] + // norm_weights = [0.5, 2.0] + // We take a positive step in all coordinates. Only the first coordinate + // hits its bound. + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2), norm_weights(2); + variable_lower_bounds << 1.0, 0.0; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, 1.0; + objective_vector << 1.0, 2.0; + norm_weights << 0.5, 2.0; + const double target_radius = 1; + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(2); + expected_solution << 1.0, 0.5; + const double expected_objective_value = -2.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + norm_weights, target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, norm_weights, target_radius, sharder); + + EXPECT_THAT(result.solution, + ElementsAre(expected_solution[0], + DoubleNear(expected_solution[1], 1.0e-13))); + EXPECT_DOUBLE_EQ(result.objective_value, expected_objective_value); + } +} + +TEST_P(TrustRegionWithWeights, SolvesWithLargeWeight) { + // min 1000.0 x + 2y + // ||(x - 2.0, y - 1.0)||_W <= sqrt(500.5) + // x >= 1.0, y >= 0 + // [x*, y*] = [1.0, 0.5] + // norm_weights = [500.0, 2.0] + // We take a positive step in all coordinates. Only the first coordinate + // hits its bound. The large norm weight stresses the code. + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2), norm_weights(2); + variable_lower_bounds << 1.0, 0.0; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, 1.0; + objective_vector << 1000.0, 2.0; + norm_weights << 500.0, 2.0; + const double target_radius = std::sqrt(500.5); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + VectorXd expected_solution(2); + expected_solution << 1.0, 0.5; + const double expected_objective_value = -1001.0; + + if (GetParam()) { + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + norm_weights, target_radius, sharder, + /*solve_tolerance=*/1.0e-6); + + EXPECT_THAT(result.solution, EigenArrayNear(expected_solution, 1.0e-6)); + EXPECT_NEAR(result.objective_value, expected_objective_value, 1.0e-6); + } else { + TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, variable_upper_bounds, + center_point, norm_weights, target_radius, sharder); + + EXPECT_THAT(result.solution, + ElementsAre(expected_solution[0], + DoubleNear(expected_solution[1], 1.0e-13))); + EXPECT_DOUBLE_EQ(result.objective_value, -1001.0); + } +} + +TEST(TrustRegionDeathTest, CheckFailsWithNonPositiveWeights) { + // min x + y + // ||(x - 2.0, y - (-5.0))||_2 <= sqrt(2) + // [x*, y*] = [1.0, -6.0] + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2), norm_weights(2); + variable_lower_bounds << -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, -5.0; + objective_vector << 1.0, 1.0; + norm_weights << 0.0, 1.0; + const double target_radius = std::sqrt(2.0); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + EXPECT_DEATH(TrustRegionResult result = + SolveTrustRegion(objective_vector, variable_lower_bounds, + variable_upper_bounds, center_point, + norm_weights, target_radius, sharder), + "Check failed: norm_weights_are_positive"); +} + +TEST(TrustRegionDeathTest, CheckFailsWithNonPositiveWeightsForDiagonalSolver) { + // min x + y + // ||(x - 2.0, y - (-5.0))||_2 <= sqrt(2) + // [x*, y*] = [1.0, -6.0] + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2), norm_weights(2); + variable_lower_bounds << -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, -5.0; + objective_vector << 1.0, 1.0; + norm_weights << 0.0, 1.0; + const double target_radius = std::sqrt(2.0); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + EXPECT_DEATH( + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + norm_weights, target_radius, sharder, + /*solve_tolerance=*/1.0e-6), + "Check failed: norm_weights_are_positive"); +} + +TEST(TrustRegionDeathTest, CheckFailsWithNegativeRadius) { + // min x + y + // ||(x - 2.0, y - (-5.0))||_2 <= sqrt(2) + // [x*, y*] = [1.0, -6.0] + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2); + variable_lower_bounds << -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, -5.0; + objective_vector << 1.0, 1.0; + const double target_radius = -std::sqrt(2.0); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + EXPECT_DEATH(TrustRegionResult result = SolveTrustRegion( + objective_vector, variable_lower_bounds, + variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(2), target_radius, sharder), + "Check failed: target_radius >= 0.0"); +} + +TEST(TrustRegionDeathTest, CheckFailsWithNegativeRadiusForDiagonalSolver) { + // min x + y + // ||(x - 2.0, y - (-5.0))||_2 <= sqrt(2) + // [x*, y*] = [1.0, -6.0] + VectorXd variable_lower_bounds(2), variable_upper_bounds(2), center_point(2), + objective_vector(2); + variable_lower_bounds << -kInfinity, -kInfinity; + variable_upper_bounds << kInfinity, kInfinity; + center_point << 2.0, -5.0; + objective_vector << 1.0, 1.0; + const double target_radius = -std::sqrt(2.0); + + Sharder sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr); + + EXPECT_DEATH( + TrustRegionResult result = SolveDiagonalTrustRegion( + objective_vector, /*objective_matrix_diagonal=*/VectorXd::Zero(2), + variable_lower_bounds, variable_upper_bounds, center_point, + /*norm_weights=*/VectorXd::Ones(2), target_radius, sharder, + /*solve_tolerance=*/1.0e-6), + "Check failed: target_radius >= 0.0"); +} + +class ComputeLocalizedLagrangianBoundsTest + : public testing::TestWithParam> { + protected: + void SetUp() override { + const auto [primal_dual_norm, use_diagonal_qp_trust_region_solver] = + GetParam(); + if (use_diagonal_qp_trust_region_solver && + (primal_dual_norm == PrimalDualNorm::kMaxNorm)) { + GTEST_SKIP() << "The diagonal QP trust region solver can only be used " + << "when the underlying norms are Euclidean."; + } + } +}; + +INSTANTIATE_TEST_SUITE_P( + TrustRegionNorm, ComputeLocalizedLagrangianBoundsTest, + testing::Combine(testing::Values(PrimalDualNorm::kEuclideanNorm, + PrimalDualNorm::kMaxNorm), + testing::Bool()), + [](const testing::TestParamInfo< + ComputeLocalizedLagrangianBoundsTest::ParamType>& info) { + const absl::string_view suffix = + std::get<1>(info.param) ? "DiagonalTRSolver" : "LinearTimeTRSolver"; + switch (std::get<0>(info.param)) { + case PrimalDualNorm::kEuclideanNorm: + return absl::StrCat("EuclideanNorm", "_", suffix); + case PrimalDualNorm::kMaxNorm: + return absl::StrCat("MaxNorm", "_", suffix); + } + }); + +TEST_P(ComputeLocalizedLagrangianBoundsTest, ZeroGapAtOptimal) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution(4), dual_solution(4); + primal_solution << -1.0, 8.0, 1.0, 2.5; + dual_solution << -2.0, 0.0, 2.375, 2.0 / 3.0; + + const auto [primal_dual_norm, use_diagonal_qp_solver] = GetParam(); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, primal_dual_norm, + /*primal_weight=*/1.0, /*radius=*/1.0, + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, use_diagonal_qp_solver, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-2); + + EXPECT_DOUBLE_EQ(bounds.radius, 1.0); + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, -20.0); + EXPECT_DOUBLE_EQ(bounds.lower_bound, -20.0); + EXPECT_DOUBLE_EQ(bounds.upper_bound, -20.0); +} + +// Sets the radius to the exact distance to optimal and checks that the +// optimal lagrangian value is contained in the computed interval. +TEST_P(ComputeLocalizedLagrangianBoundsTest, OptimalInBoundRange) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + // x_3 has a lower bound of 2.5. + VectorXd primal_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + VectorXd dual_solution = VectorXd::Zero(4); + + const double primal_distance_to_optimal = + std::sqrt(0.5 * (1.0 + 8.0 * 8.0 + 1.0 + 0.5 * 0.5)); + const double dual_distance_to_optimal = + std::sqrt(0.5 * (4.0 + 2.375 * 2.375 + 4.0 / 6.0)); + const double max_norm_distance_to_optimal = + std::max(primal_distance_to_optimal, dual_distance_to_optimal); + + const auto [primal_dual_norm, use_diagonal_qp_solver] = GetParam(); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, primal_dual_norm, + /*primal_weight=*/1.0, + /*radius=*/max_norm_distance_to_optimal, + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, use_diagonal_qp_solver, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-6); + + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, 3.0); + EXPECT_LE(bounds.lower_bound, -20.0); + EXPECT_GE(bounds.upper_bound, -20.0); +} + +// When the radius is too small, the optimal value will not be contained in +// the computed interval. +TEST_P(ComputeLocalizedLagrangianBoundsTest, OptimalNotInBoundRange) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + // x_3 has a lower bound of 2.5. + VectorXd primal_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + VectorXd dual_solution = VectorXd::Zero(4); + + const auto [primal_dual_norm, use_diagonal_qp_solver] = GetParam(); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, primal_dual_norm, + /*primal_weight=*/1.0, + /*radius=*/0.1, + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, use_diagonal_qp_solver, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-6); + const double expected_lagrangian = 3.0; + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, expected_lagrangian); + + // The rough intervals below are based on the observations that: + // 1) All components of the gradients are between 1 and 10. + // 2) The radius of 0.1 is small enough that the variable bounds have a minor + // effect. + // 3) For the trust-region problem with no variable bounds, we have that: + // objective_vector' * (x - center_point) = -target_radius * + // ||objective_vector||. + + switch (primal_dual_norm) { + case PrimalDualNorm::kMaxNorm: + // ||objective_vector|| is between 2 and 20 (because the vector has + // length 4), and target_radius = sqrt(2) * 0.1 ≈ 0.14. + // So the bounds should move between 0.28 and 2.8. + EXPECT_LE(bounds.lower_bound, expected_lagrangian - 0.28); + EXPECT_GE(bounds.lower_bound, expected_lagrangian - 2.8); + EXPECT_GE(bounds.upper_bound, expected_lagrangian + 0.28); + EXPECT_LE(bounds.upper_bound, expected_lagrangian + 2.8); + break; + case PrimalDualNorm::kEuclideanNorm: + // In this case, the value of objective_vector' * (x - center_point) is + // upper_bound - lower_bound. ||objective_vector|| is between 2.8 and 28, + // and target_radius ≈ 0.14. + EXPECT_GE(bounds.upper_bound - bounds.lower_bound, 0.39); + EXPECT_LE(bounds.upper_bound - bounds.lower_bound, 3.9); + break; + } +} + +// kEuclideanNorm isn't covered by this test because the analysis of the +// correct solution is more complex. +TEST(ComputeLocalizedLagrangianBoundsTest, ProcessesPrimalWeight) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + // x_3 has a lower bound of 2.5. + VectorXd primal_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + VectorXd dual_solution = VectorXd::Zero(4); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, PrimalDualNorm::kMaxNorm, + /*primal_weight=*/100.0, + /*radius=*/0.1, + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, + /*use_diagonal_qp_trust_region_solver=*/false, + /*diagonal_qp_trust_region_solver_tolerance=*/0.0); + const double expected_lagrangian = 3.0; + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, expected_lagrangian); + + // Compared with OptimalNotInBoundRange, a primal weight of 100.0 translates + // to a 10x smaller radius in the primal and 10x larger radius in the dual. + EXPECT_LE(bounds.lower_bound, expected_lagrangian - 0.028); + EXPECT_GE(bounds.lower_bound, expected_lagrangian - 0.28); + EXPECT_GE(bounds.upper_bound, expected_lagrangian + 2.8); + EXPECT_LE(bounds.upper_bound, expected_lagrangian + 28); +} + +// Same as OptimalInBoundRange but providing primal_product and dual_product. +TEST_P(ComputeLocalizedLagrangianBoundsTest, AcceptsCachedProducts) { + ShardedQuadraticProgram lp(TestLp(), /*num_threads=*/2, /*num_shards=*/2); + + // x_3 has a lower bound of 2.5. + VectorXd primal_solution(4); + primal_solution << 0.0, 0.0, 0.0, 3.0; + VectorXd dual_solution = VectorXd::Zero(4); + + VectorXd primal_product(4); + primal_product << 6.0, 0.0, 0.0, -3.0; + VectorXd dual_product = VectorXd::Zero(4); + + const double primal_distance_to_optimal = + std::sqrt(0.5 * (1.0 + 8.0 * 8.0 + 1.0 + 0.5 * 0.5)); + const double dual_distance_to_optimal = + std::sqrt(0.5 * (4.0 + 2.375 * 2.375 + 4.0 / 6.0)); + const double max_norm_distance_to_optimal = + std::max(primal_distance_to_optimal, dual_distance_to_optimal); + + const auto [primal_dual_norm, use_diagonal_qp_solver] = GetParam(); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, primal_dual_norm, + /*primal_weight=*/1.0, + /*radius=*/max_norm_distance_to_optimal, + /*primal_product=*/&primal_product, + /*dual_product=*/&dual_product, use_diagonal_qp_solver, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-6); + + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, 3.0); + EXPECT_LE(bounds.lower_bound, -20.0); + EXPECT_GE(bounds.upper_bound, -20.0); +} + +// The LP: +// minimize 1.0 x +// s.t. 0 <= x <= 1 (as a constraint, not variable bound). +QuadraticProgram OneDimLp() { + QuadraticProgram lp(1, 1); + lp.constraint_lower_bounds << 0; + lp.constraint_upper_bounds << 1; + lp.variable_lower_bounds << -kInfinity; + lp.variable_upper_bounds << kInfinity; + std::vector> triplets = {{0, 0, 1}}; + lp.constraint_matrix.setFromTriplets(triplets.begin(), triplets.end()); + lp.objective_vector << 1.0; + return lp; +} + +// The QP: +// minimize 1.0 x + 1.0 * x^2 +// s.t. 0 <= x <= 1 (as a constraint, not variable bound). +QuadraticProgram OneDimQp() { + QuadraticProgram qp(1, 1); + qp.constraint_lower_bounds << 0; + qp.constraint_upper_bounds << 1; + qp.variable_lower_bounds << -kInfinity; + qp.variable_upper_bounds << kInfinity; + std::vector> constraint_matrix_triplets = { + {0, 0, 1}}; + qp.constraint_matrix.setFromTriplets(constraint_matrix_triplets.begin(), + constraint_matrix_triplets.end()); + qp.objective_matrix.emplace(); + qp.objective_matrix->resize(1); + qp.objective_matrix->diagonal() << 2; + qp.objective_vector << 1; + return qp; +} + +// Helper functions to compute the primal and dual gradient at a given point. +VectorXd GetPrimalGradient(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution, + const VectorXd& dual_solution) { + const auto dual_product = TransposedMatrixVectorProduct( + sharded_qp.Qp().constraint_matrix, dual_solution, + sharded_qp.ConstraintMatrixSharder()); + return ComputePrimalGradient(sharded_qp, primal_solution, dual_product) + .gradient; +} + +VectorXd GetDualGradient(const ShardedQuadraticProgram& sharded_qp, + const VectorXd& primal_solution, + const VectorXd& dual_solution) { + const auto primal_product = TransposedMatrixVectorProduct( + sharded_qp.TransposedConstraintMatrix(), primal_solution, + sharded_qp.TransposedConstraintMatrixSharder()); + return ComputeDualGradient(sharded_qp, dual_solution, primal_product) + .gradient; +} + +struct TestProblemData { + VectorXd objective_vector; + VectorXd objective_matrix_diagonal; + VectorXd center_point; + VectorXd variable_lower_bounds; + VectorXd variable_upper_bounds; + VectorXd norm_weights; +}; + +// Generates the problem data corresponding to OneDimLp() as raw vectors with +// center point [x, y] = [0, -1]. +TestProblemData GenerateTestLpProblemData(const double primal_weight) { + // Extract objective vector from primal and dual gradients. + VectorXd objective_vector(2), center_point(2), norm_weights(2), + variable_lower_bounds(2), variable_upper_bounds(2); + objective_vector << 2, -1; + center_point << 0, -1; + norm_weights << 0.5 * primal_weight, 0.5 / primal_weight; + variable_lower_bounds.fill(-kInfinity); + variable_upper_bounds.fill(kInfinity); + return {.objective_vector = objective_vector, + .objective_matrix_diagonal = VectorXd::Zero(2), + .center_point = center_point, + .variable_lower_bounds = variable_lower_bounds, + .variable_upper_bounds = variable_upper_bounds, + .norm_weights = norm_weights}; +} + +// Generates the problem data corresponding to OneDimQp() as raw vectors with +// center point [x, y] = [0, -1]. +TestProblemData GenerateTestQpProblemData(const double primal_weight) { + TestProblemData lp_data = GenerateTestLpProblemData(primal_weight); + lp_data.objective_matrix_diagonal[0] = 2.0; + return lp_data; +} + +// This is a tiny problem where we can compute the exact solution, checking +// that kMaxNorm and kEuclideanNorm give different answers. +TEST_P(ComputeLocalizedLagrangianBoundsTest, NormsBehaveDifferently) { + ShardedQuadraticProgram lp(OneDimLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution = VectorXd::Zero(1); + VectorXd dual_solution(1); + dual_solution << -1; // The upper bound is active. + + // The primal gradient is [2], and the dual gradient is [1]. Hence, the norm + // of the gradient is sqrt(5). + + const auto [primal_dual_norm, use_diagonal_qp_solver] = GetParam(); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, primal_dual_norm, + /*primal_weight=*/1.0, /*radius=*/1.0 / std::sqrt(2.0), + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, use_diagonal_qp_solver, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-6); + const double expected_lagrangian = -1; + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, expected_lagrangian); + + switch (primal_dual_norm) { + case PrimalDualNorm::kMaxNorm: + EXPECT_DOUBLE_EQ(bounds.lower_bound, expected_lagrangian - 2.0); + EXPECT_DOUBLE_EQ(bounds.upper_bound, expected_lagrangian + 1.0); + break; + case PrimalDualNorm::kEuclideanNorm: + if (use_diagonal_qp_solver) { + EXPECT_NEAR(bounds.lower_bound, + expected_lagrangian - 4.0 / std::sqrt(5), 1.0e-6); + EXPECT_NEAR(bounds.upper_bound, + expected_lagrangian + 1.0 / std::sqrt(5), 1.0e-6); + } else { + EXPECT_DOUBLE_EQ(bounds.lower_bound, + expected_lagrangian - 4.0 / std::sqrt(5)); + EXPECT_DOUBLE_EQ(bounds.upper_bound, + expected_lagrangian + 1.0 / std::sqrt(5)); + } + break; + } +} + +// Like NormsBehaveDifferently but with a larger primal weight. +TEST_P(ComputeLocalizedLagrangianBoundsTest, + NormsBehaveDifferentlyWithLargePrimalWeight) { + ShardedQuadraticProgram lp(OneDimLp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution = VectorXd::Zero(1); + VectorXd dual_solution(1); + dual_solution << -1; // The upper bound is active. + + // The primal gradient is [2], and the dual gradient is [1]. + + const auto [primal_dual_norm, use_diagonal_qp_solver] = GetParam(); + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + lp, primal_solution, dual_solution, primal_dual_norm, + /*primal_weight=*/100.0, /*radius=*/1.0 / std::sqrt(2.0), + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, use_diagonal_qp_solver, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-8); + const double expected_lagrangian = -1; + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, expected_lagrangian); + + switch (primal_dual_norm) { + case PrimalDualNorm::kMaxNorm: + EXPECT_DOUBLE_EQ(bounds.lower_bound, expected_lagrangian - 0.2); + EXPECT_DOUBLE_EQ(bounds.upper_bound, expected_lagrangian + 10.0); + break; + case PrimalDualNorm::kEuclideanNorm: + // Given c = [2.0, -1], w = [100.0, 0.01], this value is + // dot(c, (c ./ w) / norm(c ./ sqrt.(w))) (in Julia syntax). + if (use_diagonal_qp_solver) { + EXPECT_NEAR(bounds.upper_bound - bounds.lower_bound, 10.00199980003999, + 10.002 * 1.0e-8); + } else { + EXPECT_DOUBLE_EQ(bounds.upper_bound - bounds.lower_bound, + 10.00199980003999); + } + break; + } +} + +TEST(DiagonalTrustRegionSolverTest, JointSolverWorksWithOneDimQpUnitWeight) { + ShardedQuadraticProgram sharded_qp(OneDimQp(), /*num_threads=*/2, + /*num_shards=*/2); + const auto problem_data = GenerateTestQpProblemData(/*primal_weight=*/1.0); + TrustRegionResult result = SolveDiagonalTrustRegion( + problem_data.objective_vector, problem_data.objective_matrix_diagonal, + problem_data.variable_lower_bounds, problem_data.variable_upper_bounds, + problem_data.center_point, problem_data.norm_weights, + /*target_radius=*/0.5, + Sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr), + /*solve_tolerance=*/1.0e-6); + EXPECT_THAT(result.solution, + ElementsAre(DoubleNear(-0.5, 1.0e-6), DoubleNear(-0.5, 1.0e-6))); + EXPECT_NEAR(result.solution_step_size, 4.0, 4.0 * 1.0e-6); + EXPECT_NEAR(result.objective_value, -1.25, 1.0e-6); +} + +TEST(DiagonalTrustRegionSolverTest, + DiagonalQpSolverWorksWithOneDimQpUnitWeight) { + ShardedQuadraticProgram sharded_qp(OneDimQp(), /*num_threads=*/2, + /*num_shards=*/2); + VectorXd primal_solution = VectorXd::Zero(1); + VectorXd dual_solution = -1.0 * VectorXd::Ones(1); + VectorXd primal_gradient = + GetPrimalGradient(sharded_qp, primal_solution, dual_solution); + VectorXd dual_gradient = + GetDualGradient(sharded_qp, primal_solution, dual_solution); + TrustRegionResult result = SolveDiagonalQpTrustRegion( + sharded_qp, primal_solution, dual_solution, primal_gradient, + dual_gradient, /*primal_weight=*/1.0, /*target_radius=*/0.5, + /*solve_tolerance=*/1.0e-6); + EXPECT_THAT(result.solution, + ElementsAre(DoubleNear(-0.5, 1.0e-6), DoubleNear(-0.5, 1.0e-6))); + EXPECT_NEAR(result.solution_step_size, 4.0, 4.0 * 1.0e-6); + EXPECT_NEAR(result.objective_value, -1.25, 1.0e-6); +} + +TEST(DiagonalTrustRegionSolverTest, JointSolverWorksWithOneDimQpLargeWeight) { + ShardedQuadraticProgram sharded_qp(OneDimQp(), /*num_threads=*/2, + /*num_shards=*/2); + const auto problem_data = GenerateTestQpProblemData(/*primal_weight=*/100.0); + TrustRegionResult result = SolveDiagonalTrustRegion( + problem_data.objective_vector, problem_data.objective_matrix_diagonal, + problem_data.variable_lower_bounds, problem_data.variable_upper_bounds, + problem_data.center_point, problem_data.norm_weights, + /*target_radius=*/std::sqrt(2705.0 / 2) * (5.0 / 13), + Sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr), + /*solve_tolerance=*/1.0e-6); + EXPECT_NEAR(result.solution_step_size, 1.0, 1.0e-6); +} + +TEST(DiagonalTrustRegionSolverTest, + DiagonalQpSolverWorksWithOneDimQpLargeWeight) { + ShardedQuadraticProgram sharded_qp(OneDimQp(), /*num_threads=*/2, + /*num_shards=*/2); + VectorXd primal_solution = VectorXd::Zero(1); + VectorXd dual_solution = -1.0 * VectorXd::Ones(1); + VectorXd primal_gradient = + GetPrimalGradient(sharded_qp, primal_solution, dual_solution); + VectorXd dual_gradient = + GetDualGradient(sharded_qp, primal_solution, dual_solution); + TrustRegionResult result = SolveDiagonalQpTrustRegion( + sharded_qp, primal_solution, dual_solution, primal_gradient, + dual_gradient, /*primal_weight=*/100.0, + /*target_radius=*/std::sqrt(2705.0 / 2.0) * (5.0 / 13), + /*solve_tolerance=*/1.0e-6); + EXPECT_NEAR(result.solution_step_size, 1.0, 1.0e-6); +} + +TEST(DiagonalTrustRegionSolverTest, JointSolverWorksWithOneDimQpSmallWeight) { + ShardedQuadraticProgram sharded_qp(OneDimQp(), /*num_threads=*/2, + /*num_shards=*/2); + const auto problem_data = GenerateTestQpProblemData(/*primal_weight=*/0.01); + TrustRegionResult result = SolveDiagonalTrustRegion( + problem_data.objective_vector, problem_data.objective_matrix_diagonal, + problem_data.variable_lower_bounds, problem_data.variable_upper_bounds, + problem_data.center_point, problem_data.norm_weights, + /*target_radius=*/0.71063, + Sharder(/*num_elements=*/2, /*num_shards=*/2, nullptr), + /*solve_tolerance=*/1.0e-6); + EXPECT_THAT(result.solution, ElementsAre(DoubleNear(-0.99950025, 1.0e-6), + DoubleNear(-0.9, 1.0e-6))); + EXPECT_NEAR(result.solution_step_size, 0.2, 1.0e-6); + EXPECT_NEAR(result.objective_value, -1.0999996, 1.0e-6); +} + +TEST(DiagonalTrustRegionSolverTest, + DiagonalQpSolverWorksWithOneDimQpSmallWeight) { + ShardedQuadraticProgram sharded_qp(OneDimQp(), /*num_threads=*/2, + /*num_shards=*/2); + VectorXd primal_solution = VectorXd::Zero(1); + VectorXd dual_solution = -1.0 * VectorXd::Ones(1); + VectorXd primal_gradient = + GetPrimalGradient(sharded_qp, primal_solution, dual_solution); + VectorXd dual_gradient = + GetDualGradient(sharded_qp, primal_solution, dual_solution); + TrustRegionResult result = SolveDiagonalQpTrustRegion( + sharded_qp, primal_solution, dual_solution, primal_gradient, + dual_gradient, /*primal_weight=*/0.01, + /*target_radius=*/0.71063, + /*solve_tolerance=*/1.0e-6); + EXPECT_THAT(result.solution, ElementsAre(DoubleNear(-0.99950025, 1.0e-6), + DoubleNear(-0.9, 1.0e-6))); + EXPECT_NEAR(result.solution_step_size, 0.2, 1.0e-6); + EXPECT_NEAR(result.objective_value, -1.0999996, 1.0e-6); +} + +// This is a tiny QP where we can compute the exact solution. +TEST(ComputeLocalizedLagrangianBoundsTest, SolvesForTestQpUnitWeight) { + ShardedQuadraticProgram qp(OneDimQp(), /*num_threads=*/2, /*num_shards=*/2); + + VectorXd primal_solution = VectorXd::Zero(1); + VectorXd dual_solution(1); + dual_solution << -1; // The upper bound is active. + + // The primal gradient is [2], and the dual gradient is [1]. Hence, the norm + // of the gradient is sqrt(5). + + LocalizedLagrangianBounds bounds = ComputeLocalizedLagrangianBounds( + qp, primal_solution, dual_solution, PrimalDualNorm::kEuclideanNorm, + /*primal_weight=*/1.0, /*radius=*/0.5, + /*primal_product=*/nullptr, + /*dual_product=*/nullptr, /*use_diagonal_qp_trust_region_solver=*/true, + /*diagonal_qp_trust_region_solver_tolerance=*/1.0e-6); + const double expected_lagrangian = -1; + EXPECT_DOUBLE_EQ(bounds.lagrangian_value, expected_lagrangian); + EXPECT_NEAR(bounds.upper_bound, expected_lagrangian + 0.5, 1.0e-5); + EXPECT_NEAR(bounds.lower_bound, expected_lagrangian - 0.75, 1.0e-5); +} + +} // namespace +} // namespace operations_research::pdlp