diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a79b6d77d..bc107af00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [master] + branches: [main] pull_request: types: [opened, synchronize, reopened] jobs: @@ -21,13 +21,16 @@ jobs: - version: '1' os: macOS-latest arch: x64 + - version: '1' + os: windows-latest + arch: x64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v2 with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - - uses: actions/cache@v4 + - uses: actions/cache@v5 env: cache-name: cache-artifacts with: @@ -40,6 +43,6 @@ jobs: - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@v6 with: - file: lcov.info + files: lcov.info diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 94623d0d0..4636a2c2e 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -8,8 +8,10 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@latest with: # Build documentation on Julia 1.6 diff --git a/.github/workflows/format_check.yml b/.github/workflows/format_check.yml index a6fb91885..d01ea2ec8 100644 --- a/.github/workflows/format_check.yml +++ b/.github/workflows/format_check.yml @@ -13,7 +13,7 @@ jobs: - uses: julia-actions/setup-julia@latest with: version: '1' - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Format check shell: julia --color=yes {0} run: | diff --git a/.gitignore b/.gitignore index 1f73aaea0..504e02fcf 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ docs/src/index.md julia .vscode + +docs/src/examples/*.md +docs/src/examples/*.jl diff --git a/CITATION.bib b/CITATION.bib index 0827e7328..a93564dcd 100644 --- a/CITATION.bib +++ b/CITATION.bib @@ -1,7 +1,18 @@ -@article{miconvexfw, - doi = {10.48550/ARXIV.2208.11010}, - url = {https://arxiv.org/abs/2208.11010}, - author = {Hendrych, Deborah and Troppens, Hannah and Besançon, Mathieu and Pokutta, Sebastian}, - title = {Convex integer optimization with Frank-Wolfe methods}, - year = {2022} +@article{convexmip, + title={Convex mixed-integer optimization with {Frank-Wolfe} algorithms}, + year={2025}, + archivePrefix={arXiv}, + eprint={2208.11010}, + author={Hendrych, Deborah and Troppens, Hannah and Besançon, Mathieu and Pokutta, Sebastian}, + journal={Mathematical Programming Computation} +} + +@article{xiao2025bosciajlreviewtutorial, + title={{Boscia.jl}: a review and tutorial}, + author={Wenjie Xiao and Deborah Hendrych and Mathieu Besançon and Sebastian Pokutta}, + year={2025}, + eprint={2511.01479}, + archivePrefix={arXiv}, + primaryClass={math.OC}, + url={https://arxiv.org/abs/2511.01479}, } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..67516f771 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +# Contributing to FrankWolfe + +First, thanks for taking the time to contribute. +Contributions in any form, such as documentation, bug fix, examples or algorithmic improvement and extensions, are appreciated and welcome. + +We list below some guidelines to help you contribute to the package. + +## Community Standards + +Interactions on this repository must follow the Julia [Community Standards](https://julialang.org/community/standards/) including Pull Requests and issues. + +## Where can I get an overview? + +Check out the [paper](https://arxiv.org/abs/2208.11010) presenting the package +for a high-level overview of the feature and algorithms, the [tutorial paper](https://arxiv.org/abs/2511.01479), which +gives a more high-level overview and some example applications, and +the [documentation](https://zib-iol.github.io/Boscia.jl/dev/) for more details. + +## I just have a question + +If your question is related to Julia, its syntax or tooling, the best places to get help will be tied to the Julia community, +see [the Julia community page](https://julialang.org/community/) for a number of communication channels (Slack, Zulip, and Discourse being the most active). + +For now, the best way to ask a question is to file an issue or reach out to [Mathieu Besançon](https://github/matbesancon), +[Deborah Hendrych](https://github.com/dhendryc) or [Sebastian Pokutta](https://github.com/pokutta). +You can also ask your question on [discourse.julialang.org](https://discourse.julialang.org) in the optimization topic or on the Julia Slack +on `#mathematical-optimization`, see [the Julia community page](https://julialang.org/community/) to gain access. + +## How can I file an issue? + +If you found a bug or want to propose a feature, we track our issues within the [GitHub repository](https://github.com/ZIB-IOL/Boscia.jl/issues). +Once opened, you can edit the issue or add new comments to continue the conversation. + +If you encounter a bug, send the stack trace (the lines appearing after the error occurred containing some source files) +and ideally a Minimal Working Example (MWE), a small program that reproduces the bug. + +## How can I contribute + +Contributing to the repository will likely be made in a Pull Request (PR). +You will need to: +1. Fork the repository +2. Clone it on your machine to perform the changes +3. Create a branch for your modifications, based on the branch you want to merge on (typically main) +4. Push to this branch on your fork +5. The GitHub web interface will then automatically suggest opening a PR onto the original repository. + +See the GitHub [guide to creating PRs](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) for more help on workflows using Git and GitHub. + +A PR should do a single thing to reduce the amount of code that must be reviewed. +Do not run the formatter on the whole repository except if your PR is specifically about formatting. + +### Improve the documentation + +The documentation can be improved by changing the files in `docs/src`, +for example to add a section in the documentation, expand a paragraph or add a plot. +The documentation attached to a given type of function can be modified in the source files directly, it appears above the function / type / thingy you try to document +with three double quotation marks like this: +```julia +""" +This explains what the function `f` does, it supports markdown. +""" +function f(x) + # ... +end +``` + +### Provide a new example or test + +If you fix a bug, one would typically expect to add a test that validates that the bug is gone. +A test would be added in a file in the `test/` folder, for which the entry point is `runtests.jl`. + +The `examples/` folder features several examples covering different problem settings and algorithms. +The examples are expected to run with the same environment and dependencies as the tests using +[TestEnv](https://github.com/JuliaTesting/TestEnv.jl). +If the example is lightweight enough, it can be added to the `docs/src/examples/` folder which generates +pages for the documentation based on Literate.jl. + +### Provide a new feature + +Contributions bringing new features are also welcome. +If the feature is likely to impact performance, some benchmarks should be run with `BenchmarkTools` on several +of the examples to assert the effect at different problem sizes. +If the feature should only be active in some cases, a keyword should be added to one of the settings dictionaries or +be linked to a specific mode. + +Some typical features to implement are: +1. A new Linear Minimization Oracle (LMO) - if this requires additional (big) package imports, please consider adding the LMO to [CombinatorialLinearOracles.jl](https://github.com/ZIB-IOL/CombinatorialLinearOracles.jl) instead. +2. A new heuristic - for examples, see `src/heuristics.jl`. +3. A new branching or traverse strategy. + +### Code style + +We try to follow the [Julia documentation guidelines](https://docs.julialang.org/en/v1/manual/documentation/). +We run [`JuliaFormatter.jl`](https://github.com/domluna/JuliaFormatter.jl) on the repo in the way set in the `.JuliaFormatter.toml` file, which enforces a number of conventions. + +This contribution guide was inspired by [ColPrac](https://github.com/SciML/ColPrac) and the one in [Manopt.jl](https://github.com/JuliaManifolds/Manopt.jl). diff --git a/Project.toml b/Project.toml index 426c0546c..39ee062aa 100644 --- a/Project.toml +++ b/Project.toml @@ -1,14 +1,12 @@ name = "Boscia" uuid = "36b166db-dac5-4d05-b36a-e6c4cef071c9" +version = "0.2.12" authors = ["ZIB IOL"] -version = "0.1.39" [deps] Bonobo = "f7b14807-3d4d-461a-888a-05dd4bca8bc3" -DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" FrankWolfe = "f55ce6ea-fdc5-4628-88c5-0087fe54bd30" -Hungarian = "e91730f6-4275-51fb-a7a0-7064cfbd3b39" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" MathOptSetDistances = "3b969827-a86c-476c-9527-bb6f1a8fbad5" @@ -27,20 +25,21 @@ BosciaSCIPExt = "SCIP" [compat] Aqua = "0.8" -Bonobo = "0.1.3" -DataStructures = "0.18" +Bonobo = "0.1.5" +CSV = "0.10" +CombinatorialLinearOracles = "0.1.2" Dates = "1.6" Distributions = "0.25" DoubleFloats = "1" -FrankWolfe = "0.5" +FrankWolfe = "0.6" +Graphs = "1.13" HiGHS = "1" -Hungarian = "0.7.0" LinearAlgebra = "1.6" MathOptInterface = "1" MathOptSetDistances = "0.2" Printf = "1.6" Random = "1.6" -SCIP = "0.11, 0.12" +SCIP = "0.12" SparseArrays = "1.10" StableRNGs = "1" Statistics = "1.6" @@ -50,8 +49,12 @@ julia = "1.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +Bonobo = "f7b14807-3d4d-461a-888a-05dd4bca8bc3" +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +CombinatorialLinearOracles = "0002e35e-4a6a-41c8-a2f5-6940c7e5949f" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" DoubleFloats = "497a8b3b-efae-58df-a0af-a86822472b78" +Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" @@ -59,4 +62,4 @@ Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "HiGHS", "Distributions", "SCIP", "DoubleFloats", "StableRNGs", "Aqua", "Suppressor"] +test = ["Test", "HiGHS", "Distributions", "SCIP", "DoubleFloats", "StableRNGs", "Aqua", "Suppressor", "Graphs", "Bonobo", "CSV", "CombinatorialLinearOracles"] diff --git a/README.md b/README.md index 9bd44a26a..2a1cd1650 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ The paper presenting the package with mathematical explanations and numerous exa > Convex mixed-integer optimization with Frank-Wolfe methods: [2208.11010](https://arxiv.org/abs/2208.11010) +A review and tutorial paper presenting the intuition behind the framework and showcasing its use on three examples can be found here: + +> Boscia.jl: A review and tutorial: [2511.01479](https://arxiv.org/abs/2511.01479) + `Boscia.jl` uses [`FrankWolfe.jl`](https://github.com/ZIB-IOL/FrankWolfe.jl) for solving the convex subproblems, [`Bonobo.jl`](https://github.com/Wikunia/Bonobo.jl) for managing the search tree, and oracles optimizing linear functions over the feasible set, for instance calling [SCIP](https://scipopt.org) or any MOI-compatible solver to solve MIP subproblems. ## Installation @@ -83,7 +87,9 @@ function grad!(storage, x) @. storage = x-diffw end -x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose = true)) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) Boscia Algorithm. diff --git a/docs/Project.toml b/docs/Project.toml index bdad030f3..457139200 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,7 +1,9 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" +Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" [compat] Documenter = "1.8" +Literate = "2" diff --git a/docs/make.jl b/docs/make.jl index a1a2378fb..130378f02 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,17 +1,52 @@ -using Documenter -using Boscia +# Add the parent directory to LOAD_PATH so we can import Boscia +# This doesn't modify Project.toml, keeping it clean like FrankWolfe.jl +pushfirst!(LOAD_PATH, joinpath(@__DIR__, "..")) + +using Documenter, Boscia +using SparseArrays +using LinearAlgebra + +using Literate + +EXAMPLE_DIR = joinpath(dirname(@__DIR__), "examples") +DOCS_EXAMPLE_DIR = joinpath(@__DIR__, "src", "examples") +DOCS_REFERENCE_DIR = joinpath(@__DIR__, "src", "reference") + +function file_list(dir, extension) + return filter(file -> endswith(file, extension), sort(readdir(dir))) +end + +function literate_directory(jl_dir, md_dir) + # Remove old markdown files first + for filename in file_list(md_dir, ".md") + filepath = joinpath(md_dir, filename) + rm(filepath) + end + + # Process all .jl files that start with "docs" + for filename in file_list(jl_dir, ".jl") + if startswith(filename, "docs") + filepath = joinpath(jl_dir, filename) + Literate.markdown(filepath, md_dir; documenter=true, flavor=Literate.DocumenterFlavor()) + end + end + return nothing +end + +# Generate markdown files from example .jl files +literate_directory(EXAMPLE_DIR, DOCS_EXAMPLE_DIR) working_dir = @__DIR__ # Function to copy contents of README.md to index.md function copy_readme_to_index() - readme_path = joinpath(working_dir, "..", "README.md") # Adjusted path + readme_path = joinpath(working_dir, "..", "README.md") index_path = joinpath(working_dir, "src", "index.md") readme_content = read(readme_path, String) return write(index_path, readme_content) end -# Call the functions to update index.md and 1_algorithms.md +# Call the function to update index.md copy_readme_to_index() # Generate documentation @@ -22,6 +57,11 @@ makedocs( pages=[ "Home" => "index.md", "How does it work?" => "basics.md", + "Examples" => [ + "Network Design Problem" => "examples/docs-01-network-design.md", + "Graph Isomorphism Problem" => "examples/docs-02-graph-isomorphism.md", + "Optimal Design of Experiments" => "examples/docs-03-optimal-design.md", + ], "API Reference" => [ #"reference/0_reference.md", "reference/1_algorithms.md", diff --git a/docs/src/examples.md b/docs/src/examples.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/examples/.gitkeep b/docs/src/examples/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/index.md b/docs/src/index.md index 9e33c8cd4..2a1cd1650 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,8 +1,11 @@ # Boscia.jl [![Build Status](https://github.com/ZIB-IOL/Boscia.jl/workflows/CI/badge.svg)](https://github.com/ZIB-IOL/Boscia.jl/actions) +[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://zib-iol.github.io/Boscia.jl/dev/) +[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://zib-iol.github.io/Boscia.jl/stable/) [![Coverage](https://codecov.io/gh/ZIB-IOL/Boscia.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/ZIB-IOL/Boscia.jl) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12720675.svg)](https://doi.org/10.5281/zenodo.12720675) +[![Aqua QA](https://raw.githubusercontent.com/JuliaTesting/Aqua.jl/master/badge.svg)](https://github.com/JuliaTesting/Aqua.jl) A solver for Mixed-Integer Convex Optimization that uses Frank-Wolfe methods for convex relaxations and a branch-and-bound algorithm. @@ -20,6 +23,10 @@ The paper presenting the package with mathematical explanations and numerous exa > Convex mixed-integer optimization with Frank-Wolfe methods: [2208.11010](https://arxiv.org/abs/2208.11010) +A review and tutorial paper presenting the intuition behind the framework and showcasing its use on three examples can be found here: + +> Boscia.jl: A review and tutorial: [2511.01479](https://arxiv.org/abs/2511.01479) + `Boscia.jl` uses [`FrankWolfe.jl`](https://github.com/ZIB-IOL/FrankWolfe.jl) for solving the convex subproblems, [`Bonobo.jl`](https://github.com/Wikunia/Bonobo.jl) for managing the search tree, and oracles optimizing linear functions over the feasible set, for instance calling [SCIP](https://scipopt.org) or any MOI-compatible solver to solve MIP subproblems. ## Installation @@ -41,10 +48,6 @@ For the installation of `SCIP.jl`, see [here](https://github.com/scipopt/SCIP.jl Note, for Windows users, you do not need to download the SCIP binaries, you can also use the installer provided by SCIP. - - - - ## Getting started Here is a simple example to get started. For more examples, see the examples folder in the package. @@ -84,41 +87,36 @@ function grad!(storage, x) @. storage = x-diffw end -x, _, result = Boscia.solve(f, grad!, lmo, verbose = true) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) Boscia Algorithm. Parameter settings. Tree traversal strategy: Move best bound Branching strategy: Most infeasible + FrankWolfe variant: Blended Pairwise Conditional Gradient + Line Search Method: Secant + Lazification: true + Lazification Tolerance: 2 Absolute dual gap tolerance: 1.000000e-06 Relative dual gap tolerance: 1.000000e-02 - Frank-Wolfe subproblem tolerance: 1.000000e-05 - Total number of varibales: 6 - Number of integer variables: 0 - Number of binary variables: 6 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - Iteration Open Bound Incumbent Gap (abs) Gap (rel) Time (s) Nodes/sec FW (ms) LMO (ms) LMO (calls c) FW (Its) #ActiveSet Discarded ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -* 1 2 -1.202020e-06 7.500000e-01 7.500012e-01 Inf 3.870000e-01 7.751938e+00 237 2 9 13 1 0 - 100 27 6.249998e-01 7.500000e-01 1.250002e-01 2.000004e-01 5.590000e-01 2.271914e+02 0 0 641 0 1 0 - 127 0 7.500000e-01 7.500000e-01 0.000000e+00 0.000000e+00 5.770000e-01 2.201040e+02 0 0 695 0 1 0 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - -Postprocessing - -Blended Pairwise Conditional Gradient Algorithm. -MEMORY_MODE: FrankWolfe.InplaceEmphasis() STEPSIZE: Adaptive EPSILON: 1.0e-7 MAXITERATION: 10000 TYPE: Float64 -GRADIENTTYPE: Nothing LAZY: true lazy_tolerance: 2.0 -[ Info: In memory_mode memory iterates are written back into x0! - ----------------------------------------------------------------------------------------------------------------- - Type Iteration Primal Dual Dual Gap Time It/sec #ActiveSet ----------------------------------------------------------------------------------------------------------------- - Last 0 7.500000e-01 7.500000e-01 0.000000e+00 1.086583e-03 0.000000e+00 1 ----------------------------------------------------------------------------------------------------------------- - PP 0 7.500000e-01 7.500000e-01 0.000000e+00 1.927792e-03 0.000000e+00 1 ----------------------------------------------------------------------------------------------------------------- + Frank-Wolfe subproblem tolerance: 1.000000e-02 + Frank-Wolfe dual gap decay factor: 8.000000e-01 + Additional kwargs: + Total number of variables: 6 + Number of integer variables: 6 + + +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Iter Open Bound Incumbent Gap (abs) Gap (rel) Time (s) Nodes/sec FW (ms) LMO (ms) LMO (calls c) FW (its) #activeset #shadow +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +* 1 2 0.000000e+00 7.500000e-01 7.500000e-01 Inf 2.000000e-03 1.500000e+03 1 1 4 2 1 0 + 100 27 6.250000e-01 7.500000e-01 1.250000e-01 2.000000e-01 6.400000e-02 1.984375e+03 0 0 326 0 1 0 + 127 0 7.500000e-01 7.500000e-01 0.000000e+00 0.000000e+00 7.300000e-02 1.739726e+03 0 0 380 0 1 0 +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Solution Statistics. Solution Status: Optimal (tree empty) @@ -128,9 +126,15 @@ Solution Statistics. Search Statistics. Total number of nodes processed: 127 - Total number of lmo calls: 699 - Total time (s): 0.58 - LMO calls / sec: 1205.1724137931035 - Nodes / sec: 218.96551724137933 - LMO calls / node: 5.503937007874016 + Total number of lmo calls: 380 + Total time (s): 0.074 + LMO calls / sec: 5135.135135135135 + Nodes / sec: 1716.2162162162163 + LMO calls / node: 2.9921259842519685 + + Total number of global tightenings: 0 + Global tightenings / node: 0.0 + Total number of local tightenings: 0 + Local tightenings / node: 0.0 + Total number of potential local tightenings: 0 ``` diff --git a/docs/src/reference/1_algorithms.md b/docs/src/reference/1_algorithms.md index a384a2d1d..de6fcc814 100644 --- a/docs/src/reference/1_algorithms.md +++ b/docs/src/reference/1_algorithms.md @@ -1,6 +1,7 @@ # Algorithm Interface Boscia's `solve` function only requires the oracles of the objective function `f` and its gradient `g` as well as the BLMO encoding the feasible region. +For the possible settings, see further down the page. ```@autodocs Modules = [Boscia] @@ -9,8 +10,14 @@ Pages = ["src/interface.jl"] ## Optional settings -Boscia has a lot of settings to customize the solving process. These are grouped by general Branch-and-Bound settings, settings specific for Frank-Wolfe, tolerances settings for both the tree as well as Frank-Wolfe. -Furthermore, there are settings for the heuristics, for bound tightenings, postprocessing and for the case of a non-trivial domain, i.e. the objective cannot be evaluated at all points of the feasible region. +Boscia has a lot of settings to customize the solving process. These are grouped by +* general Branch-and-Bound settings +* settings specific for Frank-Wolfe +* tolerances settings for both the tree as well as the Frank-Wolfe algorithm +* settings for the heuristics +* bound tightenings settings +* postprocessing settings +* parameters for the case of a non-trivial domain, i.e. the objective cannot be evaluated at all points of the feasible region ```@autodocs Modules = [Boscia] diff --git a/examples/HiGHS_example.jl b/examples/HiGHS_example.jl index 1574f2307..988d6ac41 100644 --- a/examples/HiGHS_example.jl +++ b/examples/HiGHS_example.jl @@ -7,6 +7,8 @@ import MathOptInterface const MOI = MathOptInterface +println("\nHiGHS Example") + n = 6 const diffw = 0.5 * ones(n) @@ -21,7 +23,7 @@ for xi in x MOI.add_constraint(o, xi, MOI.LessThan(1.0)) MOI.add_constraint(o, xi, MOI.ZeroOne()) end -lmo = FrankWolfe.MathOptLMO(o) +blmo = Boscia.MathOptBLMO(o) function f(x) return 0.5 * sum((x .- diffw) .^ 2) @@ -31,4 +33,6 @@ function grad!(storage, x) @. storage = x - diffw end -x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) diff --git a/examples/Petersen.csv b/examples/Petersen.csv new file mode 100644 index 000000000..f7dfb9935 --- /dev/null +++ b/examples/Petersen.csv @@ -0,0 +1,10 @@ +0,1,0,0,1,1,0,0,0,0 +1,0,1,0,0,0,1,0,0,0 +0,1,0,1,0,0,0,1,0,0 +0,0,1,0,1,0,0,0,1,0 +1,0,0,1,0,0,0,0,0,1 +1,0,0,0,0,0,0,1,1,0 +0,1,0,0,0,0,0,0,1,1 +0,0,1,0,0,1,0,0,0,1 +0,0,0,1,0,1,1,0,0,0 +0,0,0,0,1,0,1,1,0,0 diff --git a/examples/approx_planted_point.jl b/examples/approx_planted_point.jl index 19c58489c..efe77ee77 100644 --- a/examples/approx_planted_point.jl +++ b/examples/approx_planted_point.jl @@ -10,6 +10,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nApproximate Planted Point Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -38,9 +40,13 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 MOI.add_constraint(o, xi, MOI.LessThan(1.0)) MOI.add_constraint(o, xi, MOI.ZeroOne()) # or MOI.Integer() end - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) @test x == round.(diffi) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -56,7 +62,9 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 end blmo = CubeBLMO(n, int_vars, bounds) - x, _, result = Boscia.solve(f, grad!, blmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) @test x == round.(diffi) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -67,10 +75,12 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = FrankWolfe.ZeroOneHypercubeLMO() + blmo = Boscia.ManagedBoundedLMO(sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) - x, _, result = - Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) @test x == round.(diffi) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -101,9 +111,13 @@ end MOI.add_constraint(o, xi, MOI.ZeroOne()) # or MOI.Integer() end end - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) sol = diffi sol[int_vars] = round.(sol[int_vars]) @@ -119,7 +133,9 @@ end end blmo = CubeBLMO(n, int_vars, bounds) - x, _, result = Boscia.solve(f, grad!, blmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) sol = diffi sol[int_vars] = round.(sol[int_vars]) @@ -131,10 +147,12 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = FrankWolfe.BoxLMO(lbs, ubs) + blmo = Boscia.ManagedBoundedLMO(sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) - x, _, result = - Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) sol = diffi sol[int_vars] = round.(sol[int_vars]) diff --git a/examples/big_float_example.jl b/examples/big_float_example.jl index 4e5dea3af..6137c6ef5 100644 --- a/examples/big_float_example.jl +++ b/examples/big_float_example.jl @@ -3,6 +3,8 @@ using Test using DoubleFloats using StableRNGs +println("\nBig Float Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -25,23 +27,17 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) custom_heuristics = [ Boscia.Heuristic(Boscia.rounding_lmo_01_heuristic, 0.7, :rounding_lmo_01_heuristic), Boscia.Heuristic(Boscia.probability_rounding, 0.7, :probability_rounding), ] - x, _, result = Boscia.solve( - f, - grad!, - sblmo, - lbs[int_vars], - ubs[int_vars], - int_vars, - n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=custom_heuristics), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.heuristic[:custom_heuristics] = custom_heuristics + x, _, result = Boscia.solve(f, grad!, sblmo, lbs, ubs, int_vars, n, settings=settings) if result[:total_time_in_sec] < 125 @test x == round.(diffi) @@ -64,23 +60,17 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) custom_heuristics = [ Boscia.Heuristic(Boscia.rounding_lmo_01_heuristic, 0.7, :rounding_lmo_01_heuristic), Boscia.Heuristic(Boscia.probability_rounding, 0.7, :probability_rounding), ] - x, _, result = Boscia.solve( - f, - grad!, - sblmo, - lbs[int_vars], - ubs[int_vars], - int_vars, - n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=custom_heuristics), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.heuristic[:custom_heuristics] = custom_heuristics + x, _, result = Boscia.solve(f, grad!, sblmo, lbs, ubs, int_vars, n, settings=settings) if result[:total_time_in_sec] < 125 @test x == round.(diffi) diff --git a/examples/birkhoff_decomposition.jl b/examples/birkhoff_decomposition.jl index f831523db..291fba102 100644 --- a/examples/birkhoff_decomposition.jl +++ b/examples/birkhoff_decomposition.jl @@ -9,6 +9,8 @@ const MOI = MathOptInterface import HiGHS using StableRNGs +println("\nBirkhoff Decomposition Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -87,14 +89,18 @@ function build_birkhoff_lmo() MOI.add_constraint.(o, 1.0 * theta[i] .- Y[i] .+ X[i], MOI.LessThan(1.0)) end MOI.add_constraint(o, sum(theta, init=0.0), MOI.EqualTo(1.0)) - return FrankWolfe.MathOptLMO(o) + return Boscia.MathOptBLMO(o) end lmo = build_birkhoff_lmo() -x, _, _ = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) -x, _, _ = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_frank_wolfe=Boscia.settings_frank_wolfe(lazy=false, variant=Boscia.DecompositionInvariantConditionalGradient())) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +x, _, _ = Boscia.solve(f, grad!, lmo, settings=settings) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +settings.frank_wolfe[:lazy] = false +settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() +x, _, _ = Boscia.solve(f, grad!, lmo, settings=settings) # TODO the below needs to be fixed @@ -109,14 +115,18 @@ x, _, _ = Boscia.solve(f, grad!, lmo, @testset "Birkhoff decomposition" begin lmo = build_birkhoff_lmo() - x, _, result_baseline = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) - @test f(x) <= f(result_baseline[:raw_solution]) + 1e-6 + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result_baseline = Boscia.solve(f, grad!, lmo, settings=settings) + @test f(x) <= f(result_baseline[:raw_solution]) + 1e-4 lmo = build_birkhoff_lmo() blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - MOI.set(branching_strategy.bounded_lmo.o, MOI.Silent(), true) - x_strong, _, result_strong = - Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true, branching_strategy=branching_strategy)) + MOI.set(branching_strategy.lmo.o, MOI.Silent(), true) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:branching_strategy] = branching_strategy + x_strong, _, result_strong = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x), f(x_strong), atol=1e-5, rtol=1e-2) - @test f(x) <= f(result_strong[:raw_solution]) + 1e-6 + @test f(x) <= f(result_strong[:raw_solution]) + 1e-4 end diff --git a/examples/cube_blmo.jl b/examples/cube_blmo.jl index 2ae7a1e8a..cda076fd2 100644 --- a/examples/cube_blmo.jl +++ b/examples/cube_blmo.jl @@ -30,13 +30,13 @@ using Boscia using Bonobo using Dates - +using FrankWolfe """ CubeBLMO A Bounded Linear Minimization Oracle over a cube. """ -mutable struct CubeBLMO <: Boscia.BoundedLinearMinimizationOracle +mutable struct CubeBLMO <: FrankWolfe.LinearMinimizationOracle n::Int int_vars::Vector{Int} bounds::Boscia.IntegerBounds @@ -188,6 +188,6 @@ function Boscia.is_valid_split(tree::Bonobo.BnBTree, blmo::CubeBLMO, vidx::Int) end ## Logs -function Boscia.get_BLMO_solve_data(blmo::CubeBLMO) +function Boscia.get_LMO_solve_data(blmo::CubeBLMO) return blmo.solving_time, 0.0, 0.0 end diff --git a/examples/docs-01-network-design.jl b/examples/docs-01-network-design.jl new file mode 100644 index 000000000..8c1bbbf3b --- /dev/null +++ b/examples/docs-01-network-design.jl @@ -0,0 +1,494 @@ +# # Network Design Problem +# +# We demonstrate solving a network design problem using Boscia.jl. +# We want to minimize the total travel time over a network: +# ```math +# \begin{aligned} +# \min_{\mathbf{x}, \mathbf{y}} \quad & r^T \mathbf{y} + c(\mathbf{x}) && \\ +# \text{s.t.} \quad & x_e = \sum_{z \in \mathcal{Z}} x_e^z && \forall e \in \mathcal{E} \\ +# & \mathbf{x}^z \in \mathcal{X}^z = +# \begin{cases} +# \sum_{e \in \delta^{+}(i)} x_e^z - \sum_{e \in \delta^{-}(i)} x_e^z = 0, & \forall i \in \mathcal{V} \setminus (\mathcal{O} \cup \mathcal{Z}) \\ +# \sum_{e \in \delta^{+}(i)} x_e^z = d_i^z, & \forall i \in \mathcal{O} \\ +# \sum_{e \in \delta^{-}(z)} x_e^z = \sum_{i \in \mathcal{O}} d_i^z +# \end{cases} && \forall z \in \mathcal{Z}. \\ +# & y_e = 0 \Rightarrow x_e \leq 0 && \forall e \in \mathcal{R} \\ +# & \mathbf{y} \in \mathcal{Y} \subset \{0,1\}^{|\mathcal{R}|} +# \end{aligned} +# ``` +# where +# ```math +# c(x) = \sum_{e \in E} c_e(x) = α_e + β_e*x_e + γ_e*x_e^{ρ_e} +# ``` +# with $α_e$, $β_e$, and $γ_e$ are constants and the exponent $ρ_e > 1$ model the congestion effect. +# Given a set of purchasable/optional edges $\mathcal{R}$, we want to decide which edges to build/restore. +# $\mathcal{E} denotes the set of edges, $\mathcal{S}$ and +# $\mathcal{O}$ denote the set of source and destination nodes, respectively. +# The design cost is linear and the operating cost of the +# network is modeled as a traffic assignment problem. +# We solve the problem with two approaches based on the formulations in ["Network design for the traffic +# assignment problem with mixed-integer Frank-Wolfe"](https://arxiv.org/abs/2402.00166) by Sharma et al.: +# 1. Using [MathOptInterface.jl](https://github.com/jump-dev/MathOptInterface.jl) (MOI) to model the feasible region +# 2. A penalty formulation using a customized Linear Minimization Oracle based on shortest path algorithms + +# ## Imports and Setup +# +# We start by generating the network. +using Boscia +using FrankWolfe +using Graphs +using SparseArrays +using LinearAlgebra +import MathOptInterface +const MOI = MathOptInterface +using HiGHS + +println("\nDocumentation Example 01: Network Design Problem") + +# The graph structure is shown below. +mutable struct NetworkData + num_nodes::Int + num_edges::Int + init_nodes::Vector{Int} + term_nodes::Vector{Int} + free_flow_time::Vector{Float64} + capacity::Vector{Float64} + b::Vector{Float64} # BPR function parameter + power::Vector{Float64} # BPR function exponent + travel_demand::Matrix{Float64} + num_zones::Int +end + +# The example is a small network with 8 nodes. +# Nodes 1 and 2 are the sources, node 3 is the destination, and nodes 4-8 are the intermediate nodes. +# The network is a directed graph with 12 edges. +# The edge from 4 to 5 will be the purchasable edge, i.e. an edge for which we have to +# decide to restore it or keep it closed. +# Travel demand is 1 unit from each source to the destination. +function load_braess_network() + init_nodes = [1, 2, 4, 5, 5, 6, 6, 7, 7, 8, 8, 4] + term_nodes = [4, 6, 6, 4, 3, 4, 7, 6, 8, 7, 3, 5] + free_flow_time = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] + capacity = [10.0, 10.0, 10.0, 10.0, 1.5, 10.0, 10.0, 10.0, 10.0, 10.0, 1.5, 10.0] + b = [0.1, 0.1, 0.1, 0.1, 3.0, 0.1, 0.1, 0.1, 0.1, 0.1, 3.0, 0.1] + power = [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0] + travel_demand = [0.0 0.0 1.0; 0.0 0.0 1.0; 0.0 0.0 0.0] + return NetworkData(8, length(init_nodes), init_nodes, term_nodes, free_flow_time, + capacity, b, power, travel_demand, 3) +end + +# ## Direct modelling via MathOptInterface +# +# With MOI, we can directly model the feasible region. +# The linking constraints $y_e = 0 \Rightarrow x_e \leq 0 \forall e \in \mathcal{R}$ +# can be modelled either as bigM-constraints or indicator constraints (if the chosen MIP solver supports them). +function build_moi_model(net_data, removed_edges, use_big_m=true) + optimizer = HiGHS.Optimizer() + MOI.set(optimizer, MOI.Silent(), true) + num_zones = net_data.num_zones + num_edges = net_data.num_edges + num_removed = length(removed_edges) + num_flow_vars = num_zones * num_edges # x[dest, edge] + num_agg_vars = num_edges # x_agg[edge] + num_design_vars = num_removed # y[removed_edge] binary + total_vars = num_flow_vars + num_agg_vars + num_design_vars + x = MOI.add_variables(optimizer, num_flow_vars) + x_agg = MOI.add_variables(optimizer, num_agg_vars) + y = MOI.add_variables(optimizer, num_design_vars) + for i in 1:num_flow_vars + MOI.add_constraint(optimizer, x[i], MOI.GreaterThan(0.0)) + end + for i in 1:num_agg_vars + MOI.add_constraint(optimizer, x_agg[i], MOI.GreaterThan(0.0)) + end + for i in 1:num_design_vars + MOI.add_constraint(optimizer, y[i], MOI.ZeroOne()) + end + edge_list = [(net_data.init_nodes[i], net_data.term_nodes[i]) for i in 1:num_edges] + edge_dict = Dict(edge_list[i] => i for i in eachindex(edge_list)) + incoming = Dict{Int, Vector{Int}}() + outgoing = Dict{Int, Vector{Int}}() + + for (idx, (src, dst)) in enumerate(edge_list) + if !haskey(outgoing, src) + outgoing[src] = Int[] + end + push!(outgoing[src], idx) + + if !haskey(incoming, dst) + incoming[dst] = Int[] + end + push!(incoming[dst], idx) + end + for dest in 1:num_zones + for node in 1:net_data.num_nodes + terms = MOI.ScalarAffineTerm{Float64}[] + if haskey(outgoing, node) + for edge_idx in outgoing[node] + push!(terms, MOI.ScalarAffineTerm(1.0, x[(dest-1)*num_edges + edge_idx])) + end + end + if haskey(incoming, node) + for edge_idx in incoming[node] + push!(terms, MOI.ScalarAffineTerm(-1.0, x[(dest-1)*num_edges + edge_idx])) + end + end + if node == dest + rhs = -sum(net_data.travel_demand[:, dest]) + elseif node <= num_zones + rhs = net_data.travel_demand[node, dest] + else + rhs = 0.0 + end + MOI.add_constraint(optimizer, + MOI.ScalarAffineFunction(terms, 0.0), + MOI.EqualTo(rhs)) + end + end + for edge_idx in 1:num_edges + terms = [MOI.ScalarAffineTerm(1.0, x_agg[edge_idx])] + for dest in 1:num_zones + push!(terms, MOI.ScalarAffineTerm(-1.0, x[(dest-1)*num_edges + edge_idx])) + end + MOI.add_constraint(optimizer, + MOI.ScalarAffineFunction(terms, 0.0), + MOI.EqualTo(0.0)) + end + max_flow = 1.5 * sum(net_data.travel_demand) + for (y_idx, edge) in enumerate(removed_edges) + edge_idx = edge_dict[edge] + for dest in 1:num_zones + var_idx = (dest - 1) * num_edges + edge_idx + if use_big_m + terms = [ + MOI.ScalarAffineTerm(1.0, x[var_idx]), + MOI.ScalarAffineTerm(-max_flow, y[y_idx]) + ] + MOI.add_constraint(optimizer, + MOI.ScalarAffineFunction(terms, 0.0), + MOI.LessThan(0.0)) + else + indicator_func = MOI.VectorAffineFunction( + [ + MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, y[y_idx])), + MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(1.0, x[var_idx])) + ], + [0.0, 0.0] + ) + MOI.add_constraint(optimizer, indicator_func, + MOI.Indicator{MOI.ACTIVATE_ON_ZERO}(MOI.EqualTo(0.0))) + end + end + end + return optimizer, edge_list +end + +# **BPR (Bureau of Public Roads) travel time function and gradient (for MOI-based LMO)** +# +# This function builds the objective function and gradient for the MOI-based approach. +# The objective function computes: +# - BPR travel time: t = t0 * (flow + b * flow^(power+1) / capacity^power / (power+1)) +# - Design cost: sum of cost_per_edge[i] * y[i] for each restored edge +# +# The gradient function computes derivatives of the objective with respect to: +# - Aggregate flows: d/d(flow) of BPR function +# - Design variables: cost_per_edge[i] for each restored edge +function build_objective_and_gradient(net_data, removed_edges, cost_per_edge) + num_zones = net_data.num_zones + num_edges = net_data.num_edges + num_removed = length(removed_edges) + function f(x) + x = max.(x, 0.0) + total = 0.0 + agg_start = num_zones * num_edges + 1 + agg_end = num_zones * num_edges + num_edges + x_agg = @view x[agg_start:agg_end] + for i in 1:num_edges + flow = x_agg[i] + t0 = net_data.free_flow_time[i] + b = net_data.b[i] + cap = net_data.capacity[i] + p = net_data.power[i] + total += t0 * (flow + b * flow^(p + 1) / cap^p / (p + 1)) + end + design_start = num_zones * num_edges + num_edges + 1 + for i in 1:num_removed + total += cost_per_edge[i] * x[design_start + i - 1] + end + return total + end + function grad!(storage, x) + x = max.(x, 0.0) + fill!(storage, 0.0) + agg_start = num_zones * num_edges + 1 + agg_end = num_zones * num_edges + num_edges + x_agg = @view x[agg_start:agg_end] + for i in 1:num_edges + flow = x_agg[i] + t0 = net_data.free_flow_time[i] + b = net_data.b[i] + cap = net_data.capacity[i] + p = net_data.power[i] + storage[agg_start + i - 1] = t0 * (1 + b * flow^p / cap^p) + end + for dest in 1:num_zones + for edge in 1:num_edges + storage[(dest - 1) * num_edges + edge] = storage[agg_start + edge - 1] + end + end + design_start = num_zones * num_edges + num_edges + 1 + for i in 1:num_removed + storage[design_start + i - 1] = cost_per_edge[i] + end + return storage + end + return f, grad! +end + +# ## Calling Boscia on the MOI formulation + +# Define potentially purchasable edges (edges that need design decision). +removed_edges = [(4, 5)] # Optional edge from node_1 (intermediate node 4) to node_2 (intermediate node 5) +cost_per_edge = [0.5] # Cost to purchase the edge + +net_data = load_braess_network() + +optimizer, _ = build_moi_model(net_data, removed_edges, true) +lmo_moi = FrankWolfe.MathOptLMO(optimizer) + +f_moi, grad_moi! = build_objective_and_gradient(net_data, removed_edges, cost_per_edge) + +# This problem does not require any particular settings. +# We only enable the logs and run with the default settings. +settings_moi = Boscia.create_default_settings() +settings_moi.branch_and_bound[:verbose] = true + +x_moi, _, result_moi = Boscia.solve(f_moi, grad_moi!, lmo_moi, settings=settings_moi) + +@show x_moi + +# ## Penalty formulation and custom LMO +# +# The LMO of the previous formulation is computationally expensive due +# to the linking constraints. Also, we cannot really exploit the network +# structure. Thus, Sharma et al. introduce a penalty formulation adding the +# linking constraints to the objective. +# ```math +# \mu \sum_{z \in \mathcal{Z}} \sum_{e \in \mathcal{R}} \max(x_e^z - M^z y_e, 0)^p +# ``` +# The advantage of this formulation is that we can separate the LMO call for +# flow variables $x$ and design variables $y$. +# On the other hand, we have estimate $\mu$ to solve the problem exactly. +# The LMO for the flow variables implements a shortest path algorithm. +# As for the design space $\mathcal{Y}$, we assume it is simply the hypercube. + +# We create a custom LMO for the penalty formulation. +# The bound management will be handled by Boscia itself, +# so we only need to implement the `bounded_compute_extreme_point` and `is_simple_linear_feasible` methods. +struct ShortestPathLMO <: FrankWolfe.LinearMinimizationOracle + graph::Graphs.SimpleDiGraph{Int} + net_data::NetworkData + link_dic::SparseMatrixCSC{Int, Int} + edge_list::Vector{Tuple{Int, Int}} +end + +# Add demand to flow vector following shortest path +function add_demand_to_path!(x, demand, state, origin, destination, link_dic, edge_list, num_zones) + current = destination + parent = -1 + edge_count = length(edge_list) + agg_start = edge_count * num_zones + + while parent != origin && origin != destination && current != 0 + parent = state.parents[current] + if parent != 0 + link_idx = link_dic[parent, current] + if link_idx != 0 + x[(destination - 1) * edge_count + link_idx] += demand + x[agg_start + link_idx] += demand + end + end + current = parent + end +end + +# All-or-nothing assignment: route all flow on shortest paths +function all_or_nothing_assignment(travel_time_vector, net_data, graph, link_dic, edge_list) + num_zones = net_data.num_zones + edge_count = net_data.num_edges + travel_time = travel_time_vector[num_zones * edge_count + 1 : (num_zones + 1) * edge_count] + x = zeros(length(travel_time_vector)) + + for origin in 1:num_zones + state = Graphs.dijkstra_shortest_paths(graph, origin) + + for destination in 1:num_zones + demand = net_data.travel_demand[origin, destination] + if demand > 0 + add_demand_to_path!(x, demand, state, origin, destination, + link_dic, edge_list, num_zones) + end + end + end + + return x +end + +function Boscia.bounded_compute_extreme_point(lmo::ShortestPathLMO, direction, + lower_bounds, upper_bounds, int_vars) + x = all_or_nothing_assignment(direction, lmo.net_data, lmo.graph, + lmo.link_dic, lmo.edge_list) + for (i, var_idx) in enumerate(int_vars) + if direction[var_idx] < 0 + x[var_idx] = upper_bounds[i] + else + x[var_idx] = lower_bounds[i] + end + end + return x +end + +function Boscia.is_simple_linear_feasible(lmo::ShortestPathLMO, x) + num_zones = lmo.net_data.num_zones + num_edges = lmo.net_data.num_edges + return all(x .>= -1e-6) +end + + +# **BPR objective WITH penalty terms for linking constraints (for Custom LMO)** +# +# This function builds the objective function and gradient for the Custom LMO approach. +# Since the shortest-path oracle cannot enforce linking constraints x[dest,edge] <= M * y[edge] +# as hard constraints, we add penalty terms to the objective function to discourage violations. +# +# The objective function computes: +# - BPR travel time: t = t0 * (flow + b * flow^(power+1) / capacity^power / (power+1)) +# - Design cost: sum of cost_per_edge[i] * y[i] for each restored edge +# - Penalty terms: penalty_weight * sum_i sum_dest max(0, x[dest,removed_edge_i] - M * y[i])^penalty_exponent +# +# The gradient function computes derivatives of the objective with respect to: +# - Aggregate flows: d/d(flow) of BPR function + penalty gradient w.r.t. flows +# - Design variables: cost_per_edge[i] + penalty gradient w.r.t. design variables +function build_objective_and_gradient_with_penalty(net_data, removed_edges, cost_per_edge, + penalty_weight=1e6, penalty_exponent=2.0) + num_zones = net_data.num_zones + num_edges = net_data.num_edges + num_removed = length(removed_edges) + edge_list = [(net_data.init_nodes[i], net_data.term_nodes[i]) for i in 1:num_edges] + removed_edge_indices = [findfirst(e -> e == removed_edge, edge_list) + for removed_edge in removed_edges] + max_flow = 1.5 * sum(net_data.travel_demand) + function f(x) + x = max.(x, 0.0) + total = 0.0 + agg_start = num_zones * num_edges + 1 + agg_end = num_zones * num_edges + num_edges + x_agg = @view x[agg_start:agg_end] + for i in 1:num_edges + flow = x_agg[i] + t0 = net_data.free_flow_time[i] + b = net_data.b[i] + cap = net_data.capacity[i] + p = net_data.power[i] + total += t0 * (flow + b * flow^(p + 1) / cap^p / (p + 1)) + end + design_start = num_zones * num_edges + num_edges + 1 + for i in 1:num_removed + total += cost_per_edge[i] * x[design_start + i - 1] + end + for (y_idx, edge_idx) in enumerate(removed_edge_indices) + if edge_idx !== nothing + y_val = x[design_start + y_idx - 1] + for dest in 1:num_zones + flow_idx = (dest - 1) * num_edges + edge_idx + flow_val = x[flow_idx] + violation = max(0.0, flow_val - max_flow * y_val) + total += penalty_weight * violation^penalty_exponent + end + end + end + return total + end + function grad!(storage, x) + x = max.(x, 0.0) + fill!(storage, 0.0) + agg_start = num_zones * num_edges + 1 + agg_end = num_zones * num_edges + num_edges + x_agg = @view x[agg_start:agg_end] + for i in 1:num_edges + flow = x_agg[i] + t0 = net_data.free_flow_time[i] + b = net_data.b[i] + cap = net_data.capacity[i] + p = net_data.power[i] + storage[agg_start + i - 1] = t0 * (1 + b * flow^p / cap^p) + end + for dest in 1:num_zones + for edge in 1:num_edges + storage[(dest - 1) * num_edges + edge] = storage[agg_start + edge - 1] + end + end + design_start = num_zones * num_edges + num_edges + 1 + for i in 1:num_removed + storage[design_start + i - 1] = cost_per_edge[i] + end + for (y_idx, edge_idx) in enumerate(removed_edge_indices) + if edge_idx !== nothing + y_val = x[design_start + y_idx - 1] + for dest in 1:num_zones + flow_idx = (dest - 1) * num_edges + edge_idx + flow_val = x[flow_idx] + violation = max(0.0, flow_val - max_flow * y_val) + if violation > 1e-10 + grad_coeff = penalty_weight * penalty_exponent * violation^(penalty_exponent - 1) + storage[flow_idx] += grad_coeff + storage[design_start + y_idx - 1] += grad_coeff * (-max_flow) + end + end + end + end + return storage + end + return f, grad! +end + +# ## Calling Boscia on the penalty formulation + +penalty_weight = 1e3 +penalty_exponent = 1.5 + +# Generate the graph structure. +graph = Graphs.SimpleDiGraph(net_data.num_nodes) +edge_list_custom = Tuple{Int,Int}[] +for i in 1:net_data.num_edges + Graphs.add_edge!(graph, net_data.init_nodes[i], net_data.term_nodes[i]) + push!(edge_list_custom, (net_data.init_nodes[i], net_data.term_nodes[i])) +end + +link_dic = sparse(net_data.init_nodes, net_data.term_nodes, + collect(1:net_data.num_edges)) + +custom_lmo = ShortestPathLMO(graph, net_data, link_dic, edge_list_custom) + +# Set the bounds for the binary variables. +num_zones = net_data.num_zones +num_edges = net_data.num_edges +num_removed = length(removed_edges) +total_vars = num_zones * num_edges + num_edges + num_removed + +int_vars = collect((num_zones * num_edges + num_edges + 1):total_vars) # last num_removed variables +lower_bounds = zeros(Float64, num_removed) # Binary: lower bound = 0 +upper_bounds = ones(Float64, num_removed) # Binary: upper bound = 1 + +# To have Boscia handle the bounds, we need to wrap our LMO in an instance of `ManagedLMO`. +bounded_lmo = Boscia.ManagedLMO(custom_lmo, lower_bounds, upper_bounds, int_vars, total_vars) + +f_custom, grad_custom! = build_objective_and_gradient_with_penalty(net_data, removed_edges, cost_per_edge, + penalty_weight, penalty_exponent) + +settings_custom = Boscia.create_default_settings() +settings_custom.branch_and_bound[:verbose] = true + +x_custom, _, result_custom = Boscia.solve(f_custom, grad_custom!, bounded_lmo, settings=settings_custom) + +@show x_custom diff --git a/examples/docs-02-graph-isomorphism.jl b/examples/docs-02-graph-isomorphism.jl new file mode 100644 index 000000000..fd1c3c4c3 --- /dev/null +++ b/examples/docs-02-graph-isomorphism.jl @@ -0,0 +1,261 @@ +# # Graph Isomorphism Problem +# +# This example shows how to use Boscia to certify whether two graphs are isomorphic. +# Given adjacency matrices A and B, the graphs are isomorphic if and only if there exists a +# permutation matrix $X \in \mathcal{P}_n$ such that: +# ```math +# X * A = B * X +# ``` +# where $\mathcal{P}_n$ denotes the set of permutation matrices. +# Equivalently, we consider the optimization problem +# ```math +# \min_{X\in \mathcal{P}_n} f(X) = \| X A - B X \|_F^2, +# ``` +# whose optimum is exactly 0 if and only if the graphs are isomorphic. We solve over the Birkhoff +# polytope (convex hull of permutation matrices) with a branch-and-bound scheme +# plus Frank–Wolfe in the nodes; the lower bound allows pruning, and a zero +# incumbent certifies isomorphism. + +# ## Imports and graph generation utilities +# We begin by importing the packages used in this example. +using Boscia +using Random +using SparseArrays +using FrankWolfe +using Bonobo +using CSV +using StableRNGs +using CombinatorialLinearOracles +const CLO = CombinatorialLinearOracles + +println("\nDocumentation Example 02: Graph Isomorphism Problem") + +seed = rand(UInt64) +@show seed +rng = StableRNG(seed) + +# To create test instances, we provide two helper functions that construct +# pairs of graphs with either matching or mismatching structure. +# +# **Isomorphic case:** +# Given an adjacency matrix A, we sample a permutation matrix P and form +# B = P A P′. This produces a graph that is isomorphic to A by construction. +function randomIsomorphic(A) + n = size(A, 1) + p = randperm(n) + P = sparse(1:n, p, ones(Float64, n), n, n) + B = P * A * P' + return B, P +end + +# **Non-isomorphic case:** +# To obtain a simple counterexample, we toggle a single undirected edge of A +# to produce B. Such a small perturbation typically breaks isomorphism while +# preserving symmetry of the adjacency matrix. +function randomNonIsomorphic(A::AbstractMatrix) + B = copy(A) + n = size(B, 1) + i = rand(1:(n-1)) + j = rand((i+1):n) + B[i, j] = 1 - B[i, j] + B[j, i] = B[i, j] + return B +end + +# For this example, we work with the Petersen graph, provided as a CSV file +# containing its adjacency matrix. +# After loading A, we generate an isomorphic graph B using the routine above. +path = joinpath(@__DIR__, "Petersen.csv") +rows = [collect(Int, r) for r in CSV.File(path; header=false, types=Int)] +const A = sparse(reduce(vcat, (permutedims(r) for r in rows))) +n = size(A, 1) + +B, P = randomIsomorphic(Matrix(A)) +B = sparse(B) + +# ## Objective and gradient +# +# To measure how well a matrix X satisfies the relation X A = B X, we minimize the +# Frobenius norm of the mismatch: +# +# ```math +# f(X) = \lVert X A - B X \rVert_F^{2}. +# ``` +# +# The gradient has the form +# +# ```math +# \nabla f(X) +# = 2\,(X A - B X)\,A^{\top} +# - 2\,B^{\top}(X A - B X). +# ``` +# +# In the implementation, X is stored in vectorized form for compatibility with the solver. +function f(x) + X = reshape(x, n, n) + R = X * A - B * X + return sum(abs2, R) +end + +function grad!(storage, x) + X = reshape(x, n, n) + grad_matrix = 2 * (X * A - B * X) * A' - 2 * B' * (X * A - B * X) + return storage .= vec(grad_matrix) +end + +# ## Linear Minimization Oracle (LMO) +# +# The feasible region of the optimization is the Birkhoff polytope, the convex hull of +# permutation matrices. +# We use the Birkhoff LMO provided by +# [CombinatorialLinearOracles](https://github.com/ZIB-IOL/CombinatorialLinearOracles.jl), which +# performs the required linear subproblem via the Hungarian algorithm. +# +# In [CombinatorialLinearOracles](https://github.com/ZIB-IOL/CombinatorialLinearOracles.jl), +# we implement the full LMO interface for the Birkhoff polytope. +# The simpler approach using the `ManagedLMO` wrapper would not be able to +# efficiently support additional bound information, such as +# tracking and updating reduced matrices after entries of the permutation matrix have +# been fixed. Without a structure-aware oracle, each call to compute an extreme point +# would require rebuilding the reduced assignment problem, introducing avoidable +# overhead. +# +# The custom BirkhoffLMO avoids this by recording fixed indices and maintaining the +# corresponding reduced assignment matrix. Linear minimization is then performed on +# the reduced matrix via the Hungarian algorithm, and the resulting solution is +# lifted back by adding the fixed entries. This ensures that node-specific +# constraints are handled consistently and that each oracle call remains efficient. +blmo = CLO.BirkhoffLMO(n, collect(1:(n^2))) + +# ## Branching & pruning callbacks +# +# We define callback routines to steer the branch-and-bound process. +# +# **Branch callback:** +# At a given node, if the node’s lower bound is already strictly positive, +# no permutation can achieve an objective value of zero in that subtree. +# On such nodes, we do not need to branch further.. +function build_branch_callback() + return function (tree, node, vidx::Int) + x = Bonobo.get_relaxed_values(tree, node) + primal = tree.root.problem.f(x) + lower_bound = primal - node.dual_gap + if lower_bound > 0.0 + eps() + println("No need to branch here. Node lower bound already positive.") + end + valid_lower = lower_bound > 0.0 + eps() + return valid_lower, valid_lower + end +end + +# **Tree callback:** +# The search can be terminated early under either of two conditions: +# 1. The current incumbent reaches objective value of 0.0, certifying isomorphism. +# 2. The lower bound of the B&B tree becomes strictly positive, implying that no +# permutation satisfies X A = B X, and thus the graphs are not isomorphic. +function build_tree_callback() + return function (tree, node; worse_than_incumbent=false, node_infeasible=false, lb_update=false) + if isapprox(tree.incumbent, 0.0, atol=eps()) + tree.root.problem.solving_stage = Boscia.USER_STOP + println("Optimal solution found.") + end + if Boscia.tree_lb(tree::Bonobo.BnBTree) > 0.0 + eps() + tree.root.problem.solving_stage = Boscia.USER_STOP + println("Tree lower bound already positive. No solution possible.") + end + end +end + +# ## Neighborhood heuristic over the Birkhoff polytope +# +# We include a simple neighborhood heuristic that generates a few alternative +# permutations around the current incumbent. +# This provides additional candidates that the solver may consider during the +# search. +# +# We generate k = ⌊√n⌋ neighbor candidates during each invocation of the heuristic. +function random_k_neighbor_matrix( + tree::Bonobo.BnBTree, + blmo::Boscia.TimeTrackingLMO, + x, + k::Int, + use_mip=false, +) + P = tree.incumbent_solution.solution + n0 = size(P, 1) + n = Int(sqrt(n0)) + P = reshape(P, n, n) + new_P = copy(P) + Ps = [] + for _ in 1:k + i, j = rand(1:n, 2) + while i == j + j = rand(1:n) + end + col_i = findfirst(x -> x == 1, new_P[i, :]) + col_j = findfirst(x -> x == 1, new_P[j, :]) + new_P[i, col_i] = 0 + new_P[i, col_j] = 1 + new_P[j, col_j] = 0 + new_P[j, col_i] = 1 + new_p = use_mip ? vec(new_P) : sparsevec(vec(new_P)) + push!(Ps, new_p) + end + return Ps, false +end + +k = Int(round(sqrt(n))) +swap_heu = Boscia.Heuristic( + (tree, blmo, x) -> random_k_neighbor_matrix(tree, blmo, x, k, false), + 1.0, + :swap, +) + + +# ## Solver configuration and solve call +# +# We configure Boscia with the problem-specific callbacks, the swap-based +# neighborhood heuristic, and a decomposition-invariant Frank-Wolfe method +# using a secant line search. +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +settings.branch_and_bound[:print_iter] = 10 +settings.branch_and_bound[:bnb_callback] = build_tree_callback() +settings.branch_and_bound[:branch_callback] = build_branch_callback() +settings.heuristic[:custom_heuristics] = [swap_heu] +settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() +settings.frank_wolfe[:line_search] = FrankWolfe.Secant() +settings.frank_wolfe[:lazy] = true +settings.frank_wolfe[:max_fw_iter] = 1000 + +# We now call `Boscia.solve` with the objective, gradient, and Birkhoff LMO. +# If A and B are isomorphic, the solver should identify a permutation matrix +# X with objective value f(X) = 0. +x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) + +# A successful solve provides a permutation matrix X such that: +# +# ```math +# A \approx X^{\top} B X . +# ``` +# +# This equality certifies that the two graphs are isomorphic. +X = reshape(x, n, n) +@assert A ≈ X' * B * X +println("Certificate verified: graphs are isomorphic (A ≈ X' * B * X)") + + +# ## Complement: Non-isomorphic case +# +# To certify non-isomorphism, we can replace B by a perturbed version (e.g., by toggling an edge). +# In that case, no permutation satisfies X A = B X, and the optimization yields a strictly positive +# lower bound: +# +# ```math +# \text{dual bound} \;>\; 0 . +# ``` + +B = randomNonIsomorphic(A) +x, _, result = Boscia.solve(f, grad!, blmo, settings = settings) +@assert result[:dual_bound] > 0.0 +println("Graphs are not isomorphic (lower bound > 0)") diff --git a/examples/docs-03-optimal-design.jl b/examples/docs-03-optimal-design.jl new file mode 100644 index 000000000..ddfea5262 --- /dev/null +++ b/examples/docs-03-optimal-design.jl @@ -0,0 +1,262 @@ +# # Optimal Design of Experiments +# +# Given a large set of experiments, the *Optimal Design of Experiments (OEDP)* problem aims to +# select a subset of experiments that maximizes the information gain. +# Formally, we are given a experiment matrix $A \in \mathbb{R}^{m \times n}$ encoding the +# experiments data where $m$ denotes the number of experiments, $n$ denotes the number of parameters +# and generally $m \gg n$. +# To quantify information, we utilize the Fisher information matrix defined as: +# ```math +# X(x) = A' * \text{diag}(x) * A +# ``` +# where $x \in \mathbb{Z}^n$ is the design vector. +# There exist multiple information measures, i.e. a function that maps the Fisher information matrix to a real number, +# for a comprehensive overview, see the book by Friedrich Pukelsheim titled "Optimal Design of Experiments". +# For this example, we consider the A-criterion and D-criterion. + +# ## Imports and problem setup +# We start by generating the experiment matrix $A$ randomly. +using Boscia +using Random +using Distributions +using LinearAlgebra +using FrankWolfe +using Statistics +using Test +using StableRNGs + +println("\nDocumentation Example 03: Optimal Design of Experiments") + +seed = rand(UInt64) +@show seed #seed = 0x7be8a16f815cd122 +rng = StableRNG(seed) + +m = 50 +n = Int(floor(m / 10)) +N = round(Int, 1.5 * n) + +B = rand(rng, m, n) +B = B' * B +@assert isposdef(B) +const D = MvNormal(randn(rng, n), B) + +const A = rand(D, m)' +@assert rank(A) == n + +# Next, we define the two criteria and their gradients. +# The A-criterion is defined as: +# ```math +# f_a(x) = \text{Tr}\left(X(x)^{-1}\right) +# ``` +# so the trace of the inverse of the Fisher information matrix. +function f_a(x) + X = transpose(A) * diagm(x) * A + X = Symmetric(X) + U = cholesky(X) + X_inv = U \ I + return LinearAlgebra.tr(X_inv) +end + +function grad_a!(storage, x) + X = transpose(A) * diagm(x) * A + X = Symmetric(X * X) + F = cholesky(X) + for i in 1:length(x) + storage[i] = LinearAlgebra.tr(-(F \ A[i, :]) * transpose(A[i, :])) + end + return storage +end +# The D-criterion is defined as: +# ```math +# f_d(x) = -\log(\det(X(x))). +# ``` +function f_d(x) + X = transpose(A) * diagm(x) * A + X = Symmetric(X) + return float(-log(det(X))) +end + +function grad_d!(storage, x) + X = transpose(A) * diagm(x) * A + X = Symmetric(X) + F = cholesky(X) + for i in 1:length(x) + storage[i] = LinearAlgebra.tr(-(F \ A[i, :]) * transpose(A[i, :])) + end + return storage +end + +# ## Issue: Restricted function domain +# The feasible region is a scaled and truncated probability simplex. +# ```math +# \Delta = \left\{x \in \mathbb{R}^n, 0 \leq x \leq u, \sum_{i=1}^n x_i = N\right\} +# ``` +# where $N$ is the budget and $u$ are upper bounds. + +# An issue arising in OEDP is that the objective functions and their gradients are not well defined +# over the entire feasible region. +# Note that for both the A-criterion and D-criterion, the associated Fisher information matrix has to be positive definite. +# Thus, we cannot start Boscia, and by extension Frank-Wolfe, at an arbitrary start point. +# Additionally, we have to be careful not to leave the domain during computation of the step size for Frank-Wolfe in the line search. +# To address this problem, we first need to define a domain oracle that given a point $x$ returns true if $x$ is feasible. +# There are different ways to check domain feasibility, we choose to test if the minimum +# eigenvalue i strictly positive (up to numerical tolerance). +ub = floor(N/3) +u = rand(rng, 1.0:ub, m) +simplex_lmo = Boscia.ProbabilitySimplexLMO(N) +lmo = Boscia.ManagedLMO(simplex_lmo, fill(0.0, m), u, collect(1:m), m) + +function domain_oracle(x) + X = transpose(A) * diagm(x) * A + X = Symmetric(X) + #return LinearAlgebra.isposdef(X) + return minimum(eigvals(X)) > sqrt(eps()) +end + +# Next, we have to ensure that the start points of the child nodes are also domain feasible. +# Observe that the vertices in the active set are not necessarily domain feasible. +# Therefore, while branching, we can have initial points that are not domain feasible. +# To address this, we need to define a domain point function that given the current node bounds returns +# a domain feasible point respecting the bounds, if possible. +# For OEDP, we start by setting $x$ equal to the current lower bounds and +# finding n linearly independent rows of $A$. +# If $x$ does not yet satisfy the knapsack constraint, we increase the values of $X$, first by sampling +# from the linearly independent rows and then by adding 1 to the smallest value of $x$ while respecting the upper bounds $u$. +function linearly_independent_rows(A; u=fill(1, size(A, 1))) +S = [] +m, n = size(A) +for i in 1:m + if iszero(u[i]) + continue + end + S_i = vcat(S, i) + if rank(A[S_i, :]) == length(S_i) + S = S_i + end + if length(S) == n # we only n linearly independent points + return S + end +end +return S # then x= zeros(m) and x[S] = 1 +end +function add_to_min(x, u) +perm = sortperm(x) +for i in perm + if x[i] < u[i] + x[i] += 1 + break + end +end +return x +end +function domain_point(local_bounds) + lb = fill(0.0, m) + ub = copy(u) + x = zeros(m) + for idx in 1:m + if haskey(local_bounds.lower_bounds, idx) + lb[idx] = max(0.0, local_bounds.lower_bounds[idx]) + end + if haskey(local_bounds.upper_bounds, idx) + ub[idx] = min(u[idx], local_bounds.upper_bounds[idx]) + end + end + if sum(lb) > N + return nothing + end + if !domain_oracle(ub) + return nothing + end + x = lb + S = linearly_independent_rows(A, u=(.!(iszero.(ub)))) + while sum(x) <= N + if sum(x) == N + if domain_oracle(x) + return x + else + @warn "Domain feasible point not found." + return nothing + end + end + if !iszero(x[S] - ub[S]) + y = add_to_min(x[S], ub[S]) + x[S] = y + else + x = add_to_min(x, ub) + end + end + return x +end +# Note that the domain point function does not necessarily has to return an integral feasible point. +# The generated point is used to solve a projection problem over the feasible region to move the current iterate into the domain. +# To that end, the generated point should not be at the boundary of the domain as this can lead to numerical issues later in the node solve. + +# ## Generating the initial start point +# We can use the same principal Boscia uses to generate domain feasible starting points for the +# child nodes to generate an initial start point. +# To this end, we use the `find_domain_point` function to generate a domain feasible point respecting the bounds. +# The projection problem can be solved using Frank-Wolfe. +# Note that Boscia expects the initial point to be given via an active set. +initial_bounds = Boscia.IntegerBounds(fill(0.0, m), u, collect(1:m)) +x0 = domain_point(initial_bounds) +f_help(x) = 1 / 2 * LinearAlgebra.norm(x - x0)^2 +grad_help!(storage, x) = storage .= x - x0 + +# We do not need to solve this problem to optimality. +# However, we do not want to stop as soon as we reach the domain because this can lead to numerical issues later in the node solve. +# Therefore, we count the iteration after entering the domain and stop if we have not found a feasible point after 5 iterations. +function build_inner_callback() + domain_counter = 0 + return function inner_callback(state, active_set, kwargs...) + if domain_oracle(state.x) + if domain_counter > 10 + return false + end + domain_counter += 1 + end + end +end + +inner_callback = build_inner_callback() +v0 = compute_extreme_point(lmo, collect(1.0:m)) + +_, _, _, _, _, _, active_set = FrankWolfe.blended_pairwise_conditional_gradient( + f_help, + grad_help!, + lmo, + v0, + callback=inner_callback, + lazy=true, +) + +# ## Calling Boscia + +# Now we have everything set up and ready to use Boscia to solve the problem. +# As line search, we use the Secant method which receives the domain oracle as input. +# We also set some heuristics to be used during the node solve by specifying a probability for each heuristic. +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +settings.domain[:active_set] = copy(active_set) # this will be overwritten by Boscia during the solve +settings.domain[:domain_oracle] = domain_oracle +settings.domain[:find_domain_point] = domain_point +settings.domain[:depth_domain] = 10 +settings.heuristic[:hyperplane_aware_rounding_prob] = 0.7 +settings.heuristic[:rounding_lmo_01_prob] = 0.5 +settings.frank_wolfe[:line_search] = FrankWolfe.Secant(domain_oracle=domain_oracle) +settings.frank_wolfe[:lazy] = true + +x_a, _, _ = Boscia.solve(f_a, grad_a!, lmo, settings=settings) + + +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +settings.domain[:active_set] = copy(active_set) +settings.domain[:domain_oracle] = domain_oracle +settings.domain[:find_domain_point] = domain_point +settings.domain[:depth_domain] = 10 +settings.heuristic[:hyperplane_aware_rounding_prob] = 0.7 +settings.heuristic[:rounding_lmo_01_prob] = 0.5 +settings.frank_wolfe[:line_search] = FrankWolfe.Secant(domain_oracle=domain_oracle) +settings.frank_wolfe[:lazy] = true + +x_d, _, _ = Boscia.solve(f_d, grad_d!, lmo, settings=settings) diff --git a/examples/e-optimal_design.jl b/examples/e-optimal_design.jl new file mode 100644 index 000000000..bd259a1a1 --- /dev/null +++ b/examples/e-optimal_design.jl @@ -0,0 +1,548 @@ +# Minimal working example for E-optimal design with Boscia. +# Fully self-contained - no ODWB dependency. Use in Boscia repo for testing. +# +# Run: julia --project=. e_optimal_boscia_minimal.jl +# Or: include("e_optimal_boscia_minimal.jl") +# +# Dependencies (Boscia brings these in): Boscia, FrankWolfe, Bonobo, LogExpFunctions + +using Boscia +using FrankWolfe +using Bonobo +using LinearAlgebra +using LogExpFunctions +using Random +using Dates +using SCIP +using MathOptInterface +const MOI = MathOptInterface +using Hypatia +using JuMP + +#ENV["JULIA_DEBUG"] = "Boscia" + +# ============== Parameters ============== +seed = 1 +m = 50 +n = Int(floor(sqrt(m))) +corr = false +N = Int(floor(1.5 * n * log(n))) +time_limit = 300 +zero_one = true + +# ============== Build data (from utilities.jl) ============== +# For corr=true, add: using Distributions and use MvNormal in the corr branch +function build_data(seed, m, n, fusion, corr; scaling_C=false, zero_one=false, N=-Inf) + Random.seed!(seed) + if corr + error("corr=true requires: using Distributions. Use corr=false for minimal deps.") + end + A = rand(m, n) + @assert rank(A) == n + C_hat = rand(2n, n) + C = scaling_C ? 1 / (2n) * transpose(C_hat) * C_hat : transpose(C_hat) * C_hat + @assert rank(C) == n + + if fusion + N = N == -Inf ? rand(floor(Int, m / 20):floor(Int, m / 3)) : N + ub = rand(1.0:m / 10, m) + else + N = N == -Inf ? floor(Int, 1.5 * n) : N + u = floor(Int, N / 3) + ub = rand(1.0:u, m) + end + + if zero_one + return A, C, N, fill(1.0, m), C_hat + end + return A, C, N, ub, C_hat +end + +A, C, N, ub, _ = build_data(seed, m, n, false, corr; zero_one=zero_one, N=N) +@show m, n, N + +# ============== Build E-criterion ============== +function build_e_criterion(A) + m, n = size(A) + function inf_matrix(x) + return Symmetric(A' * diagm(x) * A) + end + + function f(x) + X = inf_matrix(x) + return (-1) * minimum(eigvals(X)) + end + + function sub_grad!(storage, x) + X = inf_matrix(x) + λ, V = eigen(X) + λ_min = minimum(λ) + tolerance = max(1e-10 * abs(λ_min), 1e-10) + mult = count(λ_i -> abs(λ_i - λ_min) <= tolerance, λ) + for i in 1:mult + push!(storage, -(A * V[:, i]).^2) + end + return storage + end + + function generate_smoothing_function(μ) + function f_mu(x) + X = inf_matrix(x) + λ = eigvals(X) + return μ * LogExpFunctions.logsumexp(-λ ./ μ) - μ * log(n) + end + + function grad_mu!(storage, x) + X = inf_matrix(x) + λ, V = eigen(X) + frac = -1 / exp(LogExpFunctions.logsumexp(-λ ./ μ)) + storage .= frac * sum(LogExpFunctions.xexpy.((A * V[:, j]).^2, -λ[j] / μ) for j in 1:n) + return storage + end + return f_mu, grad_mu! + end + + return f, sub_grad!, generate_smoothing_function +end + +f, sub_grad!, generate_smoothing_function = build_e_criterion(A) + +# ============== Build LMO (from utilities.jl build_blmo) ============== +function build_blmo(m, N, ub) + simplex_lmo = Boscia.ProbabilitySimplexSimpleBLMO(N) + blmo = Boscia.ManagedBoundedLMO(simplex_lmo, fill(0.0, m), ub, collect(1:m), m) + return blmo +end + +function build_moi_lmo(m, N, ub) + o = SCIP.Optimizer() + MOI.set(o, MOI.Silent(), true) + x = MOI.add_variables(o, m) + for i in 1:m + MOI.add_constraint(o, x[i], MOI.GreaterThan(0.0)) + MOI.add_constraint(o, x[i], MOI.LessThan(1.0)) + MOI.add_constraint(o, x[i], MOI.ZeroOne()) + end + MOI.add_constraint(o, MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.(ones(m), x), 0.0), MOI.EqualTo(Float64(N))) + return FrankWolfe.MathOptLMO(o) +end + +lmo = build_blmo(m, N, ub) +#lmo = build_moi_lmo(m, N, ub) + + +# =============== Exclusion criterion ============== +function set_objective_and_solve!( + model::JuMP.Model, + sense::MOI.OptimizationSense, + objective_expr, +) + JuMP.set_objective_sense(model, sense) + JuMP.set_objective_function(model, objective_expr) + JuMP.optimize!(model) + return model +end + +function model_exclusion(A, m, n, UB, LB, M; u=fill(1.0, m), x=fill(0.0, m)) + fixed_indices = Int64[] + opt = optimizer_with_attributes(Hypatia.Optimizer, MOI.Silent() => true) + model = Model(opt) + @variable(model, Z[1:n, 1:n]) + @constraint(model, sum(Z[i,i] for i in 1:n) == 1) + @constraint(model, Z in PSDCone()) + for i in 1:m + @constraint(model, M * A[i, :]' * Z * A[i, :] <= UB) + end + + for i in 1:m + if x[i] != 0.0 || u[i] == 0.0 + continue + end + objective = M * A[i, :]' * Z * A[i, :] + set_objective_and_solve!(model, MOI.MAX_SENSE, objective) + obj = objective_value(model) + #@show i, obj, LB + if obj <= LB + push!(fixed_indices, i) + end + end + # @show fixed_indices + return model, fixed_indices +end + +function exclusion_criterion(A, N, m, generate_smoothing_function, sub_grad!) + M = Float64(N) #1.0 # 1.0 + ex_lmo = FrankWolfe.ProbabilitySimplexLMO(M) + gradient = rand(m) + x0 = FrankWolfe.compute_extreme_point(ex_lmo, gradient) + f_ex, grad_ex! = generate_smoothing_function(1e-1) + y, _, primal, dual_gap, _, _ = FrankWolfe.decomposition_invariant_conditional_gradient( + f_ex, + grad_ex!, + ex_lmo, + x0; + epsilon=1e-5,#1e-1, #max(1e-7, exp10(-m/10)), + verbose=true + ) + + @show y + + X = A' * diagm(y) * A + λ, V = eigen(X) + λ_min = minimum(λ) + tolerance = max(1e-10 * abs(λ_min), 1e-10) + mult = count(λ_i -> abs(λ_i - λ_min) <= tolerance, λ) + sub_grads = [] + sub_grad!(sub_grads, y) + fx = -f(y) + @assert isapprox(fx, minimum(λ), atol=1e-6) "fx: $(fx) minimum(λ): $(minimum(λ))" + + W = Symmetric(sum(V[:, j] * V[:, j]' for j in 1:mult)) #+ I(n) + + @show minimum(eigvals(W)) + + W -= n/2 * minimum(eigvals(W)) * I(n) + W = 1/LinearAlgebra.tr(W) * W + + UB = M * maximum(A[j,:]' * W * A[j,:] for j in 1:m) + @show UB + + sub_dual_gap = Inf + for sub_grad in sub_grads + v_sub = FrankWolfe.compute_extreme_point(ex_lmo, sub_grad) + sub_dual_gap = min(sub_dual_gap, dot(sub_grad, y - v_sub)) + end + lower_bound = fx # + sub_dual_gap + @show fx, sub_dual_gap, lower_bound, UB + + _, fixed_indices = model_exclusion(A, m, n, UB, lower_bound, M) + + @show length(fixed_indices), fixed_indices + ub[fixed_indices] .= 0.0 + lmo = build_blmo(m, N, ub) + return lmo +end + +function build_bnb_callback(A, N, f, sub_grad!) + return function bnb_callback(tree, + node; + worse_than_incumbent=false, + node_infeasible=false, + lb_update=false,) + m, n = size(A) + if node.depth > m/10 + return + end + u = fill(1.0, m) + for i in tree.root.problem.integer_variables + ub = get(node.local_bounds.upper_bounds, i, Inf) + lb = get(node.local_bounds.lower_bounds, i, -Inf) + if ub == 0.0 || lb == 1.0 + u[i] = 0.0 + end + end + x = node.active_set.x + X = A' * diagm(x) * A + λ, V = eigen(X) + λ_min = minimum(λ) + tolerance = max(1e-10 * abs(λ_min), 1e-10) + mult = count(λ_i -> abs(λ_i - λ_min) <= tolerance, λ) + #sub_grads = [] + #sub_grad!(sub_grads, y) + fx = -f(x) + #@assert isapprox(fx, minimum(λ), atol=1e-6) "fx: $(fx) minimum(λ): $(minimum(λ))" + + W = Symmetric(sum(V[:, j] * V[:, j]' for j in 1:mult)) #+ I(n) + #@show minimum(eigvals(W)) + W -= n/2 * minimum(eigvals(W)) * I(n) + W = 1/LinearAlgebra.tr(W) * W + + #W = 1/n * I(n) + + #@assert isposdef(W) && isapprox(LinearAlgebra.tr(W), 1, atol=1e-6) "Trace of W is not 1: + # Tr W = $(LinearAlgebra.tr(W)) or W not positive definite: $(isposdef(W)) min eig: $(minimum(eigvals(W)))" + + UB = N * maximum(A[j,:]' * W * A[j,:] for j in 1:m) + _, fixed_indices = model_exclusion(A, m, n, UB, fx, N, u=u, x=x) + + for i in fixed_indices + node.local_bounds.upper_bounds[i] = 0.0 + end + end +end + +#lmo = exclusion_criterion(A, N, m, generate_smoothing_function, sub_grad!) +bnb_callback = build_bnb_callback(A, N, f, sub_grad!) +# ============== Heuristics ============== +function linearly_independent_rows(A, m, n_target) + S = Int[] + for i in 1:m + S_i = vcat(S, i) + if rank(A[S_i, :]) == length(S_i) + S = S_i + end + if length(S) == n_target + return S + end + end + return S +end + +function find_large_leverage_set(A, initial_idx_set, target_size) + m, n = size(A) + if target_size >= length(initial_idx_set) + return initial_idx_set, false + end + @assert target_size >= n + current_set = copy(initial_idx_set) + + if length(current_set) == target_size + if rank(A[current_set, :]) == n + return current_set, true + else + indep = linearly_independent_rows(A[current_set, :], length(current_set), min(n, length(current_set))) + current_set = current_set[indep] + end + end + + if length(current_set) < n || rank(A[current_set, :]) < min(n, length(current_set)) + if !isempty(current_set) + indep = linearly_independent_rows(A[current_set, :], length(current_set), min(n, length(current_set))) + current_set = current_set[indep] + end + while length(current_set) < n + remaining = setdiff(1:m, current_set) + isempty(remaining) && break + best_idx = nothing + for idx in remaining + test_set = vcat(current_set, idx) + if rank(A[test_set, :]) > rank(A[current_set, :]) + best_idx = idx + break + end + end + push!(current_set, best_idx !== nothing ? best_idx : remaining[1]) + end + end + + while length(current_set) < target_size + remaining = setdiff(1:m, current_set) + isempty(remaining) && break + best_idx = nothing + for idx in remaining + test_set = vcat(current_set, idx) + if rank(A[test_set, :]) == n + best_idx = idx + break + end + end + if best_idx === nothing + cr = rank(A[current_set, :]) + for idx in remaining + test_set = vcat(current_set, idx) + if rank(A[test_set, :]) >= cr + best_idx = idx + break + end + end + end + push!(current_set, best_idx !== nothing ? best_idx : remaining[1]) + end + return current_set, true +end + +function build_follow_subgradient_heuristic(A, k) + m, n = size(A) + return function follow_gradient_heuristic(tree, tlmo, x) + x_new = copy(x) + sols = [] + sol_hashes = Set{UInt}() + for i in 1:k + time = float(Dates.value(Dates.now() - tree.root.problem.tlmo.time_ref)) + if tree.root.options[:time_limit] < Inf && time / 1000.0 ≥ tree.root.options[:time_limit] - 10 + break + end + X = A' * Diagonal(x_new) * A + λ, V = eigen(X) + nabla = (A * V[:, 1]).^2 # direction to maximize λ_min + x_new = Boscia.compute_extreme_point(tlmo, nabla) + sol_hash = hash(x_new) + sol_hash in sol_hashes && break + push!(sols, x_new) + push!(sol_hashes, sol_hash) + end + return sols, false + end +end + +function build_simple_randomized_rounding_heuristic(A, N, max_iter; rng=Random.default_rng()) + return function simple_randomized_rounding_heuristic(tree, tlmo, x) + x_new = copy(x) + sols = [] + for k in 1:max_iter + for (i, x_i) in zip(1:length(x), x) + x_new[i] = rand(rng) < x_i ? min(1.0, ceil(x_i)) : max(0.0, floor(x_i)) + end + if sum(x_new) == N + push!(sols, x_new) + return sols, false + end + end + return sols, false + end +end + +function build_pipage_rounding_heuristic(A, N; threshold=0.8, epsilon=1) + m, n = size(A) + inf_matrix(x) = A' * Diagonal(x) * A + return function pipage_rounding_heuristic(tree, tlmo, x) + x_new = copy(x) + idx_set = findall(x .> threshold) + cut_off = Int(floor(min(max(n * log(n) / epsilon^2, length(idx_set)), N))) + S, feasible = find_large_leverage_set(A, idx_set, cut_off) + if !feasible + return [x], true + end + node = tree.nodes[tree.root.current_node_id[]] + original_bounds = copy(node.local_bounds) + local_bounds = Boscia.IntegerBounds() + for i in S + push!(local_bounds, (i, 1.0), :lessthan) + push!(local_bounds, (i, 1.0), :greaterthan) + end + x_new[S] .= 1.0 + x_new[setdiff(1:m, S)] .= 0.0 + X_inv = inv(inf_matrix(x_new)) + for i in setdiff(1:m, S) + leverage = A[i, :]' * X_inv * A[i, :] + if leverage > epsilon^2 / (10 * log(n)) || isapprox(x[i], 0.0, atol=1e-10) + push!(local_bounds, (i, 0.0), :lessthan) + push!(local_bounds, (i, 0.0), :greaterthan) + else + push!(local_bounds, (i, 1.0), :lessthan) + push!(local_bounds, (i, 0.0), :greaterthan) + end + end + Boscia.build_LMO(tlmo, tree.root.problem.integer_variable_bounds, local_bounds, tree.root.problem.integer_variables) + status = Boscia.check_feasibility(tlmo) + if status == Boscia.INFEASIBLE || status == Boscia.UNBOUNDED + Boscia.build_LMO(tlmo, tree.root.problem.integer_variable_bounds, original_bounds, tree.root.problem.integer_variables) + return [x], true + end + v = Boscia.compute_extreme_point(tlmo, rand(length(x))) + active_set = FrankWolfe.ActiveSet([(1.0, v)]) + x_pipage, _, _, _ = Boscia.solve_frank_wolfe( + tree.root.options[:variant], + tree.root.problem.f, + tree.root.problem.g, + tree.root.problem.tlmo, + active_set; + epsilon=node.fw_dual_gap_limit, + max_iteration=tree.root.options[:max_fw_iter], + line_search=tree.root.options[:line_search], + lazy=tree.root.options[:lazy], + lazy_tolerance=tree.root.options[:lazy_tolerance], + callback=tree.root.options[:boscia_fw_callback], + verbose=tree.root.options[:fw_verbose], + ) + for (idx, x_i) in enumerate(x_pipage) + x_pipage[idx] = rand() < x_i ? min(1.0, ceil(x_i)) : max(0.0, floor(x_i)) + end + Boscia.build_LMO(tlmo, tree.root.problem.integer_variable_bounds, original_bounds, tree.root.problem.integer_variables) + return [x_pipage], false + end +end + +function build_greedy_fedorov_heuristic(A, N, max_iter; tolerance=0.0) + m, n = size(A) + inf_matrix(x) = A' * Diagonal(x) * A + return function greedy_fedorov_heuristic(tree, tlmo, x) + z = copy(tree.incumbent_solution.solution) + sols = [] + improved = false + f = tree.root.options[:mode] == Boscia.SMOOTHING_MODE ? tree.root.options[:original_objective] : tree.root.problem.f + for k in 1:max_iter + improved && break + z_idx = findall(z .> 0.0) + leverage = [A[idx, :]' * inv(inf_matrix(z)) * A[idx, :] for idx in z_idx] + perm = sortperm(leverage) + for i in perm + best_idx = 0 + for j in setdiff(1:m, z_idx) + z_new = copy(z) + z_new[j] = 1.0 + z_new[z_idx[i]] = 0.0 + if sum(z_new) == N && f(z_new) > f(z) + tolerance + best_idx = j + break + end + end + if best_idx != 0 + z[z_idx[i]] = 0.0 + z[best_idx] = 1.0 + improved = true + push!(sols, copy(z)) + break + end + end + end + return sols, false + end +end + +# ============== Assemble heuristics ============== +custom_heu = [] +push!(custom_heu, Boscia.Heuristic(build_follow_subgradient_heuristic(A, n), 0.5, :follow_subgradient)) +push!(custom_heu, Boscia.Heuristic(build_simple_randomized_rounding_heuristic(A, N, 20), 1.0, :sr_rounding)) +if N > 1.5 * n + push!(custom_heu, Boscia.Heuristic(build_pipage_rounding_heuristic(A, N), 0.3, :pipage_rounding)) +end +push!(custom_heu, Boscia.Heuristic(build_greedy_fedorov_heuristic(A, N, 10), 0.4, :fedorov)) + +# ============== Settings ============== +branching_strategy = Bonobo.MOST_INFEASIBLE() +#branching_strategy = Boscia.BRANCH_ALL() +settings = Boscia.create_default_settings(mode=Boscia.SMOOTHING_MODE) +settings.branch_and_bound[:verbose] = true +settings.branch_and_bound[:time_limit] = time_limit +settings.branch_and_bound[:use_shadow_set] = true +settings.branch_and_bound[:branching_strategy] = branching_strategy +settings.branch_and_bound[:print_iter] = 10 +settings.branch_and_bound[:bnb_callback] = bnb_callback + +settings.tolerances[:rel_dual_gap] = 1e-2 +settings.tolerances[:fw_epsilon] = 1e-3 +settings.tolerances[:min_node_fw_epsilon] = 1e-7 + +settings.smoothing[:generate_smoothing_objective] = generate_smoothing_function +settings.smoothing[:smoothing_start] = m/10 +settings.smoothing[:smoothing_min] = exp10(-20/m) +settings.smoothing[:smoothing_min_valid] = false +settings.smoothing[:smoothing_decay] = 0.9 +settings.smoothing[:use_sub_grad_info] = true +settings.smoothing[:best_sol_by_original] = false +settings.smoothing[:resolve_integer_solution] = true + +settings.frank_wolfe[:max_fw_iter] = 5000 +settings.frank_wolfe[:line_search] = FrankWolfe.Secant() +settings.frank_wolfe[:fw_verbose] = false +settings.frank_wolfe[:lazy] = false +settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + +settings.tightening[:dual_tightening] = true +settings.tightening[:global_dual_tightening] = true + +settings.heuristic[:hyperplane_aware_rounding_prob] = 0.0 +settings.heuristic[:follow_gradient_prob] = 0.7 +settings.heuristic[:follow_gradient_steps] = n +settings.heuristic[:custom_heuristics] = custom_heu + +# ============== Solve ============== +x, _, result = Boscia.solve(f, sub_grad!, lmo, mode=Boscia.SMOOTHING_MODE, settings=settings) + +# ============== Output ============== +@show x +@show result[:primal_objective] +@show result[:status] +@show result[:solution_source] +@show f(x) diff --git a/examples/int_sparse_reg.jl b/examples/int_sparse_reg.jl index 04cd663e8..6be03b8ea 100644 --- a/examples/int_sparse_reg.jl +++ b/examples/int_sparse_reg.jl @@ -8,6 +8,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nInteger Sparse Regression Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -66,7 +68,7 @@ const y_d = D * sol_x MOI.add_constraint(o, sum(z, init=0.0), MOI.LessThan(1.0 * k)) # MOI.add_constraint(o, MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.(zeros(n),x), sum(Float64.(iszero.(x)))), MOI.GreaterThan(1.0*(n-k))) # MOI.add_constraint(o, MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.(ones(n),z), 0.0), MOI.GreaterThan(1.0*k)) - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) function f(x) xv = @view(x[1:n]) @@ -86,10 +88,11 @@ const y_d = D * sol_x MOI.set(branching_strategy.pstrong.optimizer, MOI.Silent(), true)=# - x, _, result = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_frank_wolfe=Boscia.settings_frank_wolfe(max_fw_iter=10001), - settings_tolerances=Boscia.settings_tolerances(rel_dual_gap=1e-3)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.frank_wolfe[:max_fw_iter] = 10001 + settings.tolerances[:rel_dual_gap] = 1e-3 + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) val_min, x_min = Boscia.sparse_min_via_enum(f, n, k, fill(0:l, n)) #@show x_min diff --git a/examples/lasso.jl b/examples/lasso.jl index e422c6838..0a3766c8f 100644 --- a/examples/lasso.jl +++ b/examples/lasso.jl @@ -12,6 +12,8 @@ using Dates using Printf using StableRNGs +println("\nLasso Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -25,10 +27,10 @@ rng = StableRNG(seed) # z_i ∈ {0,1} for i = 1,..,p n = 20 -p = 5 * n +p = 2 * n k = ceil(n / 5) group_size = convert(Int64, floor(p / k)) -M_g = 5.0 +const M_g = 5.0 const lambda_0_g = 0.0 const lambda_2_g = 0.0 @@ -50,7 +52,7 @@ for i in 1:p end k = p - k -groups = [] +const groups = [] for i in 1:(k_int-1) push!(groups, ((i-1)*group_size+1):(i*group_size)) end @@ -108,14 +110,7 @@ push!(groups, ((k_int-1)*group_size+1):p) MOI.GreaterThan(1.0), ) end - lmo = FrankWolfe.MathOptLMO(o) - global_bounds = Boscia.IntegerBounds() - for i in 1:p - push!(global_bounds, (i + p, 0.0), :greaterthan) - push!(global_bounds, (i + p, 1.0), :lessthan) - push!(global_bounds, (i, -M_g), :greaterthan) - push!(global_bounds, (i, M_g), :lessthan) - end + blmo = Boscia.MathOptBLMO(o) function f(x) return sum((y_g - A_g * x[1:p]) .^ 2) + @@ -130,9 +125,11 @@ push!(groups, ((k_int-1)*group_size+1):p) return storage end - x, _, result = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_tolerances=Boscia.settings_tolerances(rel_dual_gap=1e-2, dual_gap=1e-5)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.tolerances[:rel_dual_gap] = 1e-2 + settings.tolerances[:dual_gap] = 1e-5 + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) # println("Solution: $(x[1:p])") z = x[p+1:2p] diff --git a/examples/low_dim_in_high_dim.jl b/examples/low_dim_in_high_dim.jl index 2f39e8013..439a08ff7 100644 --- a/examples/low_dim_in_high_dim.jl +++ b/examples/low_dim_in_high_dim.jl @@ -10,6 +10,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nLow-dimensional function in high-dimensional space Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -44,9 +46,11 @@ end MOI.add_constraint(o, xi, MOI.LessThan(1.0)) MOI.add_constraint(o, xi, MOI.ZeroOne()) end - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) if n < 15 # only do for small n valopt, xopt = Boscia.min_via_enum(f, n) @@ -56,18 +60,19 @@ end @test f(x) <= f(result[:raw_solution]) + 1e-6 end -@testset "Low-dimensional function (CubeSimpleBLMO)" begin +@testset "Low-dimensional function (BoxLMO)" begin int_vars = collect(1:n) lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) # modified solve call from managed_blmo.jl automatically wraps sblmo into a managed_blmo - x, _, result = - Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, sblmo, lbs, ubs, int_vars, n, settings=settings) if n < 15 # only do for small n valopt, xopt = Boscia.min_via_enum(f, n) diff --git a/examples/mps-example.jl b/examples/mps-example.jl index 97a833b43..21c09e5f7 100644 --- a/examples/mps-example.jl +++ b/examples/mps-example.jl @@ -8,6 +8,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nMPS Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -28,10 +30,10 @@ o = SCIP.Optimizer() MOI.copy_to(o, src) MOI.set(o, MOI.Silent(), true) n = MOI.get(o, MOI.NumberOfVariables()) -lmo = FrankWolfe.MathOptLMO(o) +blmo = FrankWolfe.MathOptLMO(o) #trick to push the optimum towards the interior -const vs = [FrankWolfe.compute_extreme_point(lmo, randn(rng, n)) for _ in 1:20] +const vs = [Boscia.compute_extreme_point(blmo, randn(rng, n)) for _ in 1:20] # done to avoid one vertex being systematically selected unique!(vs) filter!(vs) do v @@ -58,6 +60,8 @@ function grad!(storage, x) end @testset "MPS 22433 instance" begin - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) @test f(x) <= f(result[:raw_solution]) end diff --git a/examples/mps-examples/mip-examples.jl b/examples/mps-examples/mip-examples.jl index f9ea0e7a6..67a13c58e 100644 --- a/examples/mps-examples/mip-examples.jl +++ b/examples/mps-examples/mip-examples.jl @@ -89,12 +89,12 @@ test_instance = string("MPS ", example, " instance") @testset "$test_instance" begin println("Example $(example)") lmo, f, grad! = build_example(example, num_v) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, print_iter=10, time_limit=600), - settings_tolerances=Boscia.settings_tolerances(fw_epsilon=1e-1, min_node_fw_epsilon=1e-3), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:print_iter] = 10 + settings.branch_and_bound[:time_limit] = 600 + settings.tolerances[:fw_epsilon] = 1e-1 + settings.tolerances[:min_node_fw_epsilon] = 1e-3 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x) <= f(result[:raw_solution]) end diff --git a/examples/nonlinear.jl b/examples/nonlinear.jl index feba19bac..2646372b1 100644 --- a/examples/nonlinear.jl +++ b/examples/nonlinear.jl @@ -9,6 +9,8 @@ using Printf using Dates using StableRNGs +println("\nNonlinear Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -35,14 +37,14 @@ n = 30 ################################################################ -# LMO via CubeSimpleBLMO +# LMO via BoxLMO ################################################################ int_vars = collect(1:n) lbs = zeros(n) ubs = ones(n) -sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) +sblmo = Boscia.BoxLMO(lbs, ubs) # wrap the sblmo into a bound manager lmo = Boscia.ManagedBoundedLMO(sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) @@ -120,12 +122,11 @@ heu2 = Boscia.Heuristic(Boscia.rounding_lmo_01_heuristic, 0.8, :lmo_rounding) heuristics = [heu, heu2] # heuristics = [] -x, _, _ = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, print_iter=500, time_limit=300), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=heuristics), -) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +settings.branch_and_bound[:print_iter] = 500 +settings.branch_and_bound[:time_limit] = 300 +settings.heuristic[:custom_heuristics] = heuristics +x, _, _ = Boscia.solve(f, grad!, lmo, settings=settings) @show x diff --git a/examples/oed_utils.jl b/examples/oed_utils.jl index 7fe8b3f01..7914cf096 100644 --- a/examples/oed_utils.jl +++ b/examples/oed_utils.jl @@ -259,8 +259,8 @@ If no such point can be constructed, return nothing. """ function build_domain_point_function(domain_oracle, A, N, int_vars, initial_lb, initial_ub) return function domain_point(local_bounds) - lb = initial_lb - ub = initial_ub + lb = copy(initial_lb) + ub = copy(initial_ub) for idx in int_vars if haskey(local_bounds.lower_bounds, idx) lb[idx] = max(initial_lb[idx], local_bounds.lower_bounds[idx]) @@ -277,7 +277,7 @@ function build_domain_point_function(domain_oracle, A, N, int_vars, initial_lb, if !domain_oracle(ub) return nothing end - x = lb + x = copy(lb) S = linearly_independent_rows(A, u=(.!(iszero.(ub)))) while sum(x) <= N @@ -333,22 +333,21 @@ end """ Check if given point is in the domain of f, i.e. X = transpose(A) * diagm(x) * A -positive definite. - -(a) Check the rank of A restricted to the rows activated by x. -(b) Check if the resulting information matrix A' * diagm(x) * A is psd. - -(b) is a bit faster for smaller dimensions (< 100). For larger (> 200) (a) is faster. +positive definite using LinearAlgebra.isposdef. """ function build_domain_oracle(A, n) return function domain_oracle(x) - S = findall(x -> !iszero(x), x) - return length(S) >= n && rank(A[S, :]) == n + X = transpose(A) * diagm(x) * A + X = Symmetric(X) + return LinearAlgebra.isposdef(X) end end -function build_domain_oracle2(A) - return function domain_oracle2(x) - return isposdef(Symmetric(A' * diagm(x) * A)) +function build_domain_oracle2(A, n) + return function domain_oracle(x) + X = transpose(A) * diagm(x) * A + X = Symmetric(X) + return minimum(eigvals(X)) > sqrt(eps()) end end + diff --git a/examples/optimal_experiment_design.jl b/examples/optimal_experiment_design.jl index 3e9083b95..7e2817ac7 100644 --- a/examples/optimal_experiment_design.jl +++ b/examples/optimal_experiment_design.jl @@ -7,6 +7,8 @@ using Statistics using Test using StableRNGs +println("\nOptimal Experiment Design Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -63,51 +65,86 @@ verbose = true build_domain_point_function(domain_oracle, Ex_mat, N, collect(1:m), fill(0.0, m), ub) # precompile - line_search = FrankWolfe.MonotonicGenericStepsize(FrankWolfe.Adaptive(), domain_oracle) + line_search = FrankWolfe.Adaptive(domain_oracle=domain_oracle) x0, active_set = build_start_point(Ex_mat, N, ub) z = greedy_incumbent(Ex_mat, N, ub) - x, _, _ = Boscia.solve( - g, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(time_limit=10, verbose=false, start_solution=z), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=[heu]), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_domain=Boscia.settings_domain(active_set=active_set, domain_oracle=domain_oracle, find_domain_point=domain_point), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:time_limit] = 10 + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x, _, _ = Boscia.solve(g, grad!, blmo, settings=settings) # proper run with MGLS and Adaptive - line_search = FrankWolfe.MonotonicGenericStepsize(FrankWolfe.Adaptive(), domain_oracle) + line_search = FrankWolfe.Adaptive(domain_oracle=domain_oracle) x0, active_set = build_start_point(Ex_mat, N, ub) z = greedy_incumbent(Ex_mat, N, ub) - x, _, result = Boscia.solve( - g, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(verbose=verbose, start_solution=z), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=[heu]), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_domain=Boscia.settings_domain(active_set=active_set, domain_oracle=domain_oracle, find_domain_point=domain_point), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x, _, result = Boscia.solve(g, grad!, blmo, settings=settings) # Run with Secant x0, active_set = build_start_point(Ex_mat, N, ub) z = greedy_incumbent(Ex_mat, N, ub) line_search = FrankWolfe.Secant(domain_oracle=domain_oracle) - - x_s, _, result_s = Boscia.solve( - g, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(verbose=verbose, start_solution=z), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=[heu]), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_domain=Boscia.settings_domain(active_set=active_set, domain_oracle=domain_oracle, find_domain_point=domain_point), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x_s, _, result_s = Boscia.solve(g, grad!, blmo, settings=settings) @test result_s[:dual_bound] <= g(x) + 1e-3 @test result[:dual_bound] <= g(x_s) + 1e-3 @test isapprox(g(x), g(x_s), atol=1e-3) + + # Run explicitly with DICG and BDICG + x0, active_set = build_start_point(Ex_mat, N, ub) + z = greedy_incumbent(Ex_mat, N, ub) + line_search = FrankWolfe.Secant(domain_oracle=domain_oracle) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x_dicg, _, result_dicg = Boscia.solve(g, grad!, blmo, settings=settings) + + x0, active_set = build_start_point(Ex_mat, N, ub) + z = greedy_incumbent(Ex_mat, N, ub) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x_bdicg, _, result_bdicg = Boscia.solve(g, grad!, blmo, settings=settings) + + @test result_dicg[:dual_bound] <= g(x_dicg) + 1e-3 + @test result_bdicg[:dual_bound] <= g(x_bdicg) + 1e-3 + @test isapprox(g(x_dicg), g(x_s), atol=1e-3) + @test isapprox(g(x_bdicg), g(x_s), atol=1e-3) end ## D-Optimal Design Problem @@ -117,54 +154,89 @@ end g, grad! = build_d_criterion(Ex_mat, build_safe=false) blmo = build_blmo(m, N, ub) heu = Boscia.Heuristic(Boscia.rounding_hyperplane_heuristic, 0.7, :hyperplane_aware_rounding) - domain_oracle = build_domain_oracle(Ex_mat, n) + domain_oracle = build_domain_oracle2(Ex_mat, n) domain_point = build_domain_point_function(domain_oracle, Ex_mat, N, collect(1:m), fill(0.0, m), ub) # precompile - line_search = FrankWolfe.MonotonicGenericStepsize(FrankWolfe.Adaptive(), domain_oracle) + line_search = FrankWolfe.Adaptive(domain_oracle=domain_oracle) x0, active_set = build_start_point(Ex_mat, N, ub) z = greedy_incumbent(Ex_mat, N, ub) - x, _, _ = Boscia.solve( - g, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(time_limit=10, verbose=false, start_solution=z), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=[heu]), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_domain=Boscia.settings_domain(active_set=active_set, domain_oracle=domain_oracle, find_domain_point=domain_point), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:time_limit] = 10 + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x, _, _ = Boscia.solve(g, grad!, blmo, settings=settings) # proper run with MGLS and Adaptive - line_search = FrankWolfe.MonotonicGenericStepsize(FrankWolfe.Adaptive(), domain_oracle) + line_search = FrankWolfe.Adaptive(domain_oracle=domain_oracle) x0, active_set = build_start_point(Ex_mat, N, ub) z = greedy_incumbent(Ex_mat, N, ub) - x, _, result = Boscia.solve( - g, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(verbose=verbose, start_solution=z), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=[heu]), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_domain=Boscia.settings_domain(active_set=active_set, domain_oracle=domain_oracle, find_domain_point=domain_point), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x, _, result = Boscia.solve(g, grad!, blmo, settings=settings) # Run with Secant x0, active_set = build_start_point(Ex_mat, N, ub) z = greedy_incumbent(Ex_mat, N, ub) line_search = FrankWolfe.Secant(domain_oracle=domain_oracle) - - x_s, _, result_s = Boscia.solve( - g, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(verbose=verbose, start_solution=z), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=[heu]), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_domain=Boscia.settings_domain(active_set=active_set, domain_oracle=domain_oracle, find_domain_point=domain_point), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x_s, _, result_s = Boscia.solve(g, grad!, blmo, settings=settings) @test result_s[:dual_bound] <= g(x) @test result[:dual_bound] <= g(x_s) @test isapprox(g(x), g(x_s), rtol=1e-2) + + # Run explicitly with DICG and BDICG + x0, active_set = build_start_point(Ex_mat, N, ub) + z = greedy_incumbent(Ex_mat, N, ub) + line_search = FrankWolfe.Secant(domain_oracle=domain_oracle) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x_dicg, _, result_dicg = Boscia.solve(g, grad!, blmo, settings=settings) + + x0, active_set = build_start_point(Ex_mat, N, ub) + z = greedy_incumbent(Ex_mat, N, ub) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:start_solution] = z + settings.heuristic[:custom_heuristics] = [heu] + settings.frank_wolfe[:line_search] = line_search + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + settings.domain[:active_set] = active_set + settings.domain[:domain_oracle] = domain_oracle + settings.domain[:find_domain_point] = domain_point + x_bdicg, _, result_bdicg = Boscia.solve(g, grad!, blmo, settings=settings) + + @test result_dicg[:dual_bound] <= g(x_dicg) + @test result_bdicg[:dual_bound] <= g(x_bdicg) + @test isapprox(g(x_dicg), g(x_s), rtol=1e-2) + @test isapprox(g(x_bdicg), g(x_s), rtol=1e-2) end diff --git a/examples/plot_utilities.jl b/examples/plot_utilities.jl new file mode 100644 index 000000000..c246a0766 --- /dev/null +++ b/examples/plot_utilities.jl @@ -0,0 +1,188 @@ +# Plot Utilities for Boscia.jl Examples +# +# This file contains utility functions for creating publication-quality plots +# of optimization progress (bounds over time and nodes). + +using PyPlot +using PyCall + +""" + plot_bounds_progress(result::Dict, filename::String; kwargs...) + +Creates a two-panel plot showing the evolution of lower and upper bounds +over time and over the number of nodes explored. + +# Arguments +- `result::Dict`: Result dictionary from Boscia.solve containing keys + `:list_time`, `:list_num_nodes`, `:list_lb`, and `:list_ub` +- `filename::String`: Output filename (should end in .pdf, .png, etc.) + +# Keyword Arguments +- `title_prefix::String = ""`: Prefix for plot titles (e.g., "A-Criterion") +- `font_family::String = "serif"`: Font family to use +- `font_size::Int = 11`: Base font size +- `use_latex::Bool = true`: Whether to use LaTeX rendering +- `latex_preamble::String = "\\\\usepackage{charter}\\\\usepackage[charter]{mathdesign}"`: + LaTeX preamble for font setup +- `lower_color::String = "C0"`: Color for lower bound line (matplotlib color spec) +- `upper_color::String = "C1"`: Color for upper bound line (matplotlib color spec) +- `linewidth::Real = 2`: Line width for plots +- `figsize::Tuple{Real,Real} = (12, 4)`: Figure size in inches (width, height) +- `dpi::Int = 300`: DPI for saved figure +- `show_grid::Bool = true`: Whether to show grid +- `grid_alpha::Real = 0.3`: Grid transparency (0-1) +- `legend_loc::String = "best"`: Legend location + +# Returns +- `fig`: PyPlot figure object + +# Example +```julia +using Boscia + +# ... run Boscia.solve to get result dictionary ... +x, tlmo, result = Boscia.solve(f, grad!, lmo, settings=settings) + +# Create plot with default settings +plot_bounds_progress(result, "output.pdf", title_prefix="My Problem") + +# Create plot with custom colors and no LaTeX +plot_bounds_progress(result, "output.png", + use_latex=false, + lower_color="blue", + upper_color="red", + font_size=14) +``` +""" +function plot_bounds_progress( + result::Dict, + filename::String; + title_prefix::String = "", + font_family::String = "serif", + font_size::Int = 11, + use_latex::Bool = true, + latex_preamble::String = "\\usepackage{charter}\\usepackage[charter]{mathdesign}", + lower_color::String = "C0", + upper_color::String = "C1", + linewidth::Real = 2, + figsize::Tuple{Real,Real} = (12, 4), + dpi::Int = 300, + show_grid::Bool = true, + grid_alpha::Real = 0.3, + legend_loc::String = "best", +) + # Set up fonts + PyPlot.rc("text", usetex=use_latex) + if use_latex + PyPlot.rc("text.latex", preamble=latex_preamble) + end + PyPlot.rc("font", family=font_family, size=font_size) + + # Extract data from result dictionary + times = result[:list_time] ./ 1000.0 # Convert from milliseconds to seconds + nodes = result[:list_num_nodes] + lower_bounds = result[:list_lb] + upper_bounds = result[:list_ub] + + # Create figure with two subplots + fig = PyPlot.figure(figsize=figsize) + + # Subplot 1: Bounds over Time + PyPlot.subplot(1, 2, 1) + PyPlot.plot(times, lower_bounds, label="Lower Bound", linewidth=linewidth, color=lower_color) + PyPlot.plot(times, upper_bounds, label="Upper Bound", linewidth=linewidth, color=upper_color) + PyPlot.xlabel("Time (s)") + PyPlot.ylabel("Objective Value") + #if !isempty(title_prefix) + # PyPlot.title("$(title_prefix): Bounds over Time") + #else + # PyPlot.title("Bounds over Time") + #end + PyPlot.legend(loc=legend_loc) + if show_grid + PyPlot.grid(true, alpha=grid_alpha) + end + + # Subplot 2: Bounds over Nodes + PyPlot.subplot(1, 2, 2) + PyPlot.plot(nodes, lower_bounds, label="Lower Bound", linewidth=linewidth, color=lower_color) + PyPlot.plot(nodes, upper_bounds, label="Upper Bound", linewidth=linewidth, color=upper_color) + PyPlot.xlabel("Number of Nodes") + PyPlot.ylabel("Objective Value") + # if !isempty(title_prefix) + # PyPlot.title("$(title_prefix): Bounds over Nodes") + #else + # PyPlot.title("Bounds over Nodes") + #end + PyPlot.legend(loc=legend_loc) + if show_grid + PyPlot.grid(true, alpha=grid_alpha) + end + + # Adjust layout and save + PyPlot.tight_layout() + PyPlot.savefig(filename, bbox_inches="tight", dpi=dpi) + println("Saved plot to: $filename") + + return fig +end + +""" + setup_plot_font(; kwargs...) + +Convenience function to set up matplotlib fonts globally. + +# Keyword Arguments +- `font_family::String = "serif"`: Font family to use +- `font_size::Int = 11`: Base font size +- `use_latex::Bool = true`: Whether to use LaTeX rendering +- `latex_preamble::String = "\\\\usepackage{charter}\\\\usepackage[charter]{mathdesign}"`: + LaTeX preamble for font setup + +# Example +```julia +setup_plot_font(use_latex=false, font_family="sans-serif", font_size=12) +``` +""" +function setup_plot_font(; + font_family::String = "serif", + font_size::Int = 11, + use_latex::Bool = true, + latex_preamble::String = "\\usepackage{charter}\\usepackage[charter]{mathdesign}", +) + PyPlot.rc("text", usetex=use_latex) + if use_latex + PyPlot.rc("text.latex", preamble=latex_preamble) + end + PyPlot.rc("font", family=font_family, size=font_size) + println("Configured plotting with font_family=$font_family, font_size=$font_size, use_latex=$use_latex") +end + +""" + list_available_fonts() + +Lists all available fonts that matplotlib can use. +Useful for debugging font issues. +""" +function list_available_fonts() + fm = PyCall.pyimport("matplotlib.font_manager") + available_fonts = [f.name for f in fm.fontManager.ttflist] + return unique(sort(available_fonts)) +end + +""" + find_fonts(pattern::String) + +Find fonts matching a pattern (case-insensitive regex). + +# Example +```julia +find_fonts("charter") # Find all fonts with "charter" in the name +find_fonts("serif") # Find all fonts with "serif" in the name +``` +""" +function find_fonts(pattern::String) + fonts = list_available_fonts() + return filter(f -> occursin(Regex(pattern, "i"), f), fonts) +end + diff --git a/examples/poisson_reg.jl b/examples/poisson_reg.jl index 4aa8a3dc2..6309b011c 100644 --- a/examples/poisson_reg.jl +++ b/examples/poisson_reg.jl @@ -10,6 +10,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nPoisson Sparse Regression Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -83,7 +85,7 @@ Ns = 0.10 MOI.add_constraint(o, sum(z, init=0.0), MOI.GreaterThan(1.0)) MOI.add_constraint(o, b, MOI.LessThan(Ns)) MOI.add_constraint(o, b, MOI.GreaterThan(-Ns)) - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) α = 1.3 function f(θ) @@ -108,11 +110,12 @@ Ns = 0.10 storage[1:p] .-= 1 / n * ys[i] * xi storage[end] += 1 / n * (exp(a) - ys[i]) end - storage ./= norm(storage) return storage end - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) #@show x @show result[:raw_solution] @test f(x) <= f(result[:raw_solution]) + 1e-6 diff --git a/examples/portfolio.jl b/examples/portfolio.jl index 56c69079f..85150cffb 100644 --- a/examples/portfolio.jl +++ b/examples/portfolio.jl @@ -8,6 +8,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nPortfolio Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -45,7 +47,7 @@ const Mi = (Ai + Ai') / 2 MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.(ones(n), x), 0.0), MOI.GreaterThan(1.0), ) - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) function f(x) return 1 / 2 * Ωi * dot(x, Mi, x) - dot(ri, x) @@ -56,14 +58,23 @@ const Mi = (Ai + Ai') / 2 return storage end - heu = Boscia.Heuristic((tree, blmo, x) -> Boscia.follow_gradient_heuristic(tree,blmo,x, length(x)), 0.8, :follow_gradient) + heu = Boscia.Heuristic( + (tree, blmo, x) -> Boscia.follow_gradient_heuristic(tree, blmo, x, length(x)), + 0.8, + :follow_gradient, + ) heuristics = [heu] # heuristics = [] - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=120)) - x_heu, _, result_heu = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=600), - settings_heuristic=Boscia.settings_heuristic(custom_heuristics=heuristics)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 120 + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 600 + settings.heuristic[:custom_heuristics] = heuristics + x_heu, _, result_heu = Boscia.solve(f, grad!, blmo, settings=settings) @test dot(ai, x_heu) <= bi + 1e-2 @test f(x_heu) <= f(result[:raw_solution]) + 1e-6 diff --git a/examples/quadratic_over_birkhoff.jl b/examples/quadratic_over_birkhoff.jl index f7896ffaf..a7cf97f63 100644 --- a/examples/quadratic_over_birkhoff.jl +++ b/examples/quadratic_over_birkhoff.jl @@ -5,9 +5,13 @@ using Random using SCIP using LinearAlgebra import MathOptInterface +using CombinatorialLinearOracles +const CLO = CombinatorialLinearOracles const MOI = MathOptInterface import HiGHS +println("\nQuadratic over Birkhoff Example") + # min_{X} 1/2 * || X - Xhat ||_F^2 # X ∈ P_n (permutation matrix) @@ -22,12 +26,12 @@ function build_objective(n, append_by_column=true) end function f(x) - X = append_by_column ? reshape(x, (n,n)) : transpose(reshape(x, (n,n))) - return 1/2 * LinearAlgebra.tr(LinearAlgebra.transpose(X .- Xstar)*(X .- Xstar)) + X = append_by_column ? reshape(x, (n, n)) : transpose(reshape(x, (n, n))) + return 1 / 2 * LinearAlgebra.tr(LinearAlgebra.transpose(X .- Xstar) * (X .- Xstar)) end function grad!(storage, x) - X = append_by_column ? reshape(x, (n,n)) : transpose(reshape(x, (n,n))) + X = append_by_column ? reshape(x, (n, n)) : transpose(reshape(x, (n, n))) storage .= if append_by_column reduce(vcat, X .- Xstar) else @@ -59,46 +63,46 @@ function build_birkhoff_mip(n) vec(sum(X, dims=2, init=MOI.ScalarAffineFunction{Float64}([], 0.0))), MOI.EqualTo(1.0), ) - return Boscia.MathOptBLMO(o) + return FrankWolfe.MathOptLMO(o) end - @testset "Birkhoff" begin +@testset "Birkhoff" begin f, grad! = build_objective(n) x = zeros(n, n) + int_vars = collect(1:n^2) @testset "Birkhoff BLMO (BPCG)" begin - sblmo = Boscia.BirkhoffBLMO(n, collect(1:n^2)) - - lower_bounds = fill(0.0, n^2) - upper_bounds = fill(1.0, n^2) + lmo = CLO.BirkhoffLMO(n, collect(1:n^2)) - x, _, result = Boscia.solve(f, grad!, sblmo, lower_bounds, upper_bounds, collect(1:n^2), n^2, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x) <= f(result[:raw_solution]) + 1e-6 - @test Boscia.is_simple_linear_feasible(sblmo, x) + @test Boscia.is_linear_feasible(lmo, x) end - x_dicg = zeros(n,n) + x_dicg = zeros(n, n) @testset "Birkhoff BLMO (DICG)" begin - sblmo = Boscia.BirkhoffBLMO(n, collect(1:n^2)) - - lower_bounds = fill(0.0, n^2) - upper_bounds = fill(1.0, n^2) + lmo = CLO.BirkhoffLMO(n, collect(1:n^2)) - x_dicg, _, result_dicg = Boscia.solve(f, grad!, sblmo, lower_bounds, upper_bounds, collect(1:n^2), n^2, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_frank_wolfe=Boscia.settings_frank_wolfe(variant=Boscia.DecompositionInvariantConditionalGradient())) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_dicg, _, result_dicg = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x_dicg) <= f(result_dicg[:raw_solution]) + 1e-6 - @test Boscia.is_simple_linear_feasible(sblmo, x_dicg) + @test Boscia.is_linear_feasible(lmo, x_dicg) end - x_mip = zeros(n,n) + x_mip = zeros(n, n) @testset "MIP BLMO" begin lmo = build_birkhoff_mip(n) - x_mip, _, result_mip = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x_mip, _, result_mip = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x_mip) <= f(result_mip[:raw_solution]) + 1e-6 @test Boscia.is_linear_feasible(lmo, x_mip) - end + end @show x @show x_mip @@ -106,4 +110,4 @@ end @show f(x), f(x_mip), f(x_dicg) @test isapprox(f(x_mip), f(x), atol=1e-6, rtol=1e-2) @test isapprox(f(x_dicg), f(x), atol=1e-6, rtol=1e-2) -end \ No newline at end of file +end diff --git a/examples/readme-example.jl b/examples/readme-example.jl index 1ff8f3459..7d83c8898 100644 --- a/examples/readme-example.jl +++ b/examples/readme-example.jl @@ -6,6 +6,8 @@ using LinearAlgebra import MathOptInterface const MOI = MathOptInterface +println("\nReadme Example") + n = 6 const diffw = 0.5 * ones(n) @@ -21,7 +23,7 @@ for xi in x MOI.add_constraint(o, xi, MOI.ZeroOne()) end -lmo = FrankWolfe.MathOptLMO(o) +blmo = Boscia.MathOptBLMO(o) function f(x) return sum(0.5 * (x .- diffw) .^ 2) @@ -31,4 +33,6 @@ function grad!(storage, x) @. storage = x - diffw end -x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) +settings = Boscia.create_default_settings() +settings.branch_and_bound[:verbose] = true +x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) diff --git a/examples/sparse_reg.jl b/examples/sparse_reg.jl index 394399535..5e42f88fe 100644 --- a/examples/sparse_reg.jl +++ b/examples/sparse_reg.jl @@ -12,6 +12,8 @@ using Test using StableRNGs using Statistics +println("\nSparse Regression Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -69,7 +71,7 @@ const M = 2 * var(A) MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.(ones(p), x[p+1:2p]), 0.0), MOI.LessThan(k), ) - lmo = FrankWolfe.MathOptLMO(o) + blmo = Boscia.MathOptBLMO(o) function f(x) xv = @view(x[1:p]) @@ -82,9 +84,11 @@ const M = 2 * var(A) return storage end - x, _, result = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, print_iter=10), - settings_tolerances=Boscia.settings_tolerances(fw_epsilon=1e-3)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:print_iter] = 10 + settings.tolerances[:fw_epsilon] = 1e-3 + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) # @show result // too large to be output @test f(x) <= f(result[:raw_solution]) + 1e-6 diff --git a/examples/strong_branching_portfolio.jl b/examples/strong_branching_portfolio.jl index 2790c56dc..029f6705c 100644 --- a/examples/strong_branching_portfolio.jl +++ b/examples/strong_branching_portfolio.jl @@ -10,6 +10,8 @@ import HiGHS using SCIP using StableRNGs +println("\nStrong Branching Portfolio Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -53,8 +55,8 @@ function prepare_portfolio_lmo() MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.(ones(n), x), 0.0), MOI.GreaterThan(1.0), ) - lmo = FrankWolfe.MathOptLMO(o) - return lmo + blmo = Boscia.MathOptBLMO(o) + return blmo end function f(x) @@ -67,18 +69,22 @@ function grad!(storage, x) end @testset "Portfolio strong branching" begin - lmo = prepare_portfolio_lmo() - x, _, result_baseline = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + blmo = prepare_portfolio_lmo() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result_baseline = Boscia.solve(f, grad!, blmo, settings=settings) @test dot(ai, x) <= bi + 1e-6 @test f(x) <= f(result_baseline[:raw_solution]) + 1e-6 blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - MOI.set(branching_strategy.bounded_lmo.o, MOI.Silent(), true) + MOI.set(branching_strategy.lmo.o, MOI.Silent(), true) - lmo = prepare_portfolio_lmo() - x, _, result_strong_branching = - Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true, branching_strategy=branching_strategy)) + blmo_main = prepare_portfolio_lmo() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result_strong_branching = Boscia.solve(f, grad!, blmo_main, settings=settings) @test dot(ai, x) <= bi + 1e-3 @test f(x) <= f(result_baseline[:raw_solution]) + 1e-6 diff --git a/examples/worst-case.jl b/examples/worst-case.jl index dcd24ad76..8af621093 100644 --- a/examples/worst-case.jl +++ b/examples/worst-case.jl @@ -9,6 +9,8 @@ import MathOptInterface const MOI = MathOptInterface using StableRNGs +println("\nWorst-case Example") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -56,7 +58,7 @@ n = 10 lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) # wrap the sblmo into a bound manager lmo = Boscia.ManagedBoundedLMO(sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) @@ -68,7 +70,9 @@ n = 10 @. storage = x - diff_point end - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) # build optimal solution xopt = zeros(n) @@ -88,14 +92,16 @@ n = 10 println() # test if number of nodes is still correct when stopping FW early - x, _, result = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_tolerances=Boscia.settings_tolerances(min_number_lower=5)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.tolerances[:min_number_lower] = 5 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test result[:number_nodes] == 2^(n + 1) - 1 - x_strong, _, result_strong = Boscia.solve(f, grad!, lmo, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_tightening=Boscia.settings_tightening(strong_convexity=1.0)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.tightening[:strong_convexity] = 1.0 + x_strong, _, result_strong = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x_strong) == f(x) end diff --git a/src/Boscia.jl b/src/Boscia.jl index 856cf930e..9aa5cb109 100644 --- a/src/Boscia.jl +++ b/src/Boscia.jl @@ -3,6 +3,8 @@ module Boscia using FrankWolfe import FrankWolfe: compute_extreme_point export compute_extreme_point +import FrankWolfe: LinearMinimizationOracle +export LinearMinimizationOracle import FrankWolfe: is_decomposition_invariant_oracle export is_decomposition_invariant_oracle @@ -11,6 +13,18 @@ export compute_inface_extreme_point import FrankWolfe: dicg_maximum_step export dicg_maximum_step + +import FrankWolfe: UnitSimplexLMO +export UnitSimplexLMO + +import FrankWolfe: ZeroOneHypercubeLMO +export ZeroOneHypercubeLMO + +import FrankWolfe: ProbabilitySimplexLMO +export ProbabilitySimplexLMO + +import FrankWolfe: BoxLMO +export BoxLMO using Random using LinearAlgebra import Bonobo @@ -18,7 +32,6 @@ using Printf using Dates using MathOptInterface using SparseArrays -using Hungarian import Statistics: mean const MOI = MathOptInterface const MOIU = MOI.Utilities diff --git a/src/MOI_bounded_oracle.jl b/src/MOI_bounded_oracle.jl index 7b2cf0ec9..d8dc9905b 100644 --- a/src/MOI_bounded_oracle.jl +++ b/src/MOI_bounded_oracle.jl @@ -1,7 +1,9 @@ """ - MathOptBLMO{OT<:MOI.AbstractOptimizer} <: BoundedLinearMinimizationOracle + MathOptBLMO{OT<:MOI.AbstractOptimizer} <: LinearMinimizationOracle -BoundedLinearMinimizationOracle for solvers supporting MathOptInterface. +LinearMinimizationOracle for solvers supporting MathOptInterface. + +WILL DEPRECATED! """ # Store extra information of solving inface extrem points. @@ -14,7 +16,7 @@ mutable struct Inface_point_solve_data end end -struct MathOptBLMO{OT<:MOI.AbstractOptimizer} <: BoundedLinearMinimizationOracle +struct MathOptBLMO{OT<:MOI.AbstractOptimizer} <: LinearMinimizationOracle o::OT use_modify::Bool inface_point_solve_data::Inface_point_solve_data @@ -51,12 +53,14 @@ end Is implemented in the FrankWolfe package in file "moi_oracle.jl". """ +#= function compute_extreme_point(blmo::MathOptBLMO, d; kwargs...) lmo = convert(FrankWolfe.MathOptLMO, blmo) v = FrankWolfe.compute_extreme_point(lmo, d; kwargs) @assert blmo isa MathOptBLMO return v end +=# """ get_list_of_variables(blmo::MathOptBLMO) @@ -64,8 +68,8 @@ end Get list of variables indices and the total number of variables. If the problem has n variables, they are expected to contiguous and ordered from 1 to n. """ -function get_list_of_variables(blmo::MathOptBLMO) - v_indices = MOI.get(blmo.o, MOI.ListOfVariableIndices()) +function get_list_of_variables(lmo::FrankWolfe.MathOptLMO) + v_indices = MOI.get(lmo.o, MOI.ListOfVariableIndices()) n = length(v_indices) if v_indices != MOI.VariableIndex.(1:n) error("Variables are expected to be contiguous and ordered from 1 to N") @@ -78,8 +82,8 @@ end Get list of binary variables. """ -function get_binary_variables(blmo::MathOptBLMO) - return MOI.get(blmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.ZeroOne}()) +function get_binary_variables(lmo::FrankWolfe.MathOptLMO) + return MOI.get(lmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.ZeroOne}()) end """ @@ -87,9 +91,9 @@ end Get list of integer variables. """ -function get_integer_variables(blmo::MathOptBLMO) - bin_var = get_binary_variables(blmo) - int_var = MOI.get(blmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.Integer}()) +function get_integer_variables(lmo::FrankWolfe.MathOptLMO) + bin_var = get_binary_variables(lmo) + int_var = MOI.get(lmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.Integer}()) return vcat(getproperty.(int_var, :value), getproperty.(bin_var, :value)) end @@ -98,7 +102,7 @@ end Get the index of the integer variable the bound is working on. """ -function get_int_var(blmo::MathOptBLMO, c_idx) +function get_int_var(lmo::FrankWolfe.MathOptLMO, c_idx) return c_idx.value end @@ -107,11 +111,8 @@ end Get the list of lower bounds. """ -function get_lower_bound_list(blmo::MathOptBLMO) - return MOI.get( - blmo.o, - MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.GreaterThan{Float64}}(), - ) +function get_lower_bound_list(lmo::FrankWolfe.MathOptLMO) + return MOI.get(lmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.GreaterThan{Float64}}()) end """ @@ -119,8 +120,8 @@ end Get the list of upper bounds. """ -function get_upper_bound_list(blmo::MathOptBLMO) - return MOI.get(blmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.LessThan{Float64}}()) +function get_upper_bound_list(lmo::FrankWolfe.MathOptLMO) + return MOI.get(lmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.LessThan{Float64}}()) end """ @@ -128,11 +129,11 @@ end Change the value of the bound c_idx. """ -function set_bound!(blmo::MathOptBLMO, c_idx, value, sense::Symbol) +function set_bound!(lmo::FrankWolfe.MathOptLMO, c_idx, value, sense::Symbol) if sense == :lessthan - MOI.set(blmo.o, MOI.ConstraintSet(), c_idx, MOI.LessThan(value)) + MOI.set(lmo.o, MOI.ConstraintSet(), c_idx, MOI.LessThan(value)) elseif sense == :greaterthan - MOI.set(blmo.o, MOI.ConstraintSet(), c_idx, MOI.GreaterThan(value)) + MOI.set(lmo.o, MOI.ConstraintSet(), c_idx, MOI.GreaterThan(value)) else error("Allowed values for sense are :lessthan and :greaterthan!") end @@ -143,8 +144,8 @@ end Read bound value for c_idx. """ -function get_bound(blmo::MathOptBLMO, c_idx, sense::Symbol) - return MOI.get(blmo.o, MOI.ConstraintSet(), c_idx) +function get_bound(lmo::FrankWolfe.MathOptLMO, c_idx, sense::Symbol) + return MOI.get(lmo.o, MOI.ConstraintSet(), c_idx) end """ @@ -152,7 +153,7 @@ end Check if the subject of the bound c_idx is an integer variable (recorded in int_vars). """ -function is_constraint_on_int_var(blmo::MathOptBLMO, c_idx, int_vars) +function is_constraint_on_int_var(lmo::FrankWolfe.MathOptLMO, c_idx, int_vars) return c_idx.value in int_vars end @@ -161,7 +162,7 @@ end To check if there is bound for the variable in the global or node bounds. """ -function is_bound_in(blmo::MathOptBLMO, c_idx, bounds) +function is_bound_in(lmo::FrankWolfe.MathOptLMO, c_idx, bounds) return haskey(bounds, c_idx.value) end @@ -170,9 +171,9 @@ end Delete bounds. """ -function delete_bounds!(blmo::MathOptBLMO, cons_delete) +function delete_bounds!(lmo::FrankWolfe.MathOptLMO, cons_delete) for (d_idx, _) in cons_delete - MOI.delete(blmo.o, d_idx) + MOI.delete(lmo.o, d_idx) end end @@ -181,11 +182,11 @@ end Add bound constraint. """ -function add_bound_constraint!(blmo::MathOptBLMO, key, value, sense::Symbol) +function add_bound_constraint!(lmo::FrankWolfe.MathOptLMO, key, value, sense::Symbol) if sense == :lessthan - MOI.add_constraint(blmo.o, MOI.VariableIndex(key), MOI.LessThan(value)) + MOI.add_constraint(lmo.o, MOI.VariableIndex(key), MOI.LessThan(value)) elseif sense == :greaterthan - MOI.add_constraint(blmo.o, MOI.VariableIndex(key), MOI.GreaterThan(value)) + MOI.add_constraint(lmo.o, MOI.VariableIndex(key), MOI.GreaterThan(value)) end end @@ -194,8 +195,8 @@ end Has variable a binary constraint? """ -function has_binary_constraint(blmo::MathOptBLMO, idx::Int) - consB_list = MOI.get(blmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.ZeroOne}()) +function has_binary_constraint(lmo::FrankWolfe.MathOptLMO, idx::Int) + consB_list = MOI.get(lmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.ZeroOne}()) for c_idx in consB_list if c_idx.value == idx return true, c_idx @@ -209,8 +210,8 @@ end Does the variable have an integer constraint? """ -function has_integer_constraint(blmo::MathOptBLMO, idx::Int) - consB_list = MOI.get(blmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.Integer}()) +function has_integer_constraint(lmo::FrankWolfe.MathOptLMO, idx::Int) + consB_list = MOI.get(lmo.o, MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.Integer}()) for c_idx in consB_list if c_idx.value == idx return true, c_idx @@ -224,8 +225,8 @@ end Is a given point v linear feasible for the model? """ -function is_linear_feasible(blmo::MathOptBLMO, v::AbstractVector) - return is_linear_feasible(blmo.o, v) +function is_linear_feasible(lmo::FrankWolfe.MathOptLMO, v::AbstractVector) + return is_linear_feasible(lmo.o, v) end function is_linear_feasible(o::MOI.ModelLike, v::AbstractVector) valvar(f) = v[f.value] @@ -263,9 +264,9 @@ end """ Is a given point v inface feasible for the model? """ -function is_inface_feasible(blmo::MathOptBLMO, a::AbstractVector, x::AbstractVector) - o2 = MOI.instantiate(typeof(blmo.o)) - MOI.copy_to(o2, blmo.o) +function is_inface_feasible(lmo::FrankWolfe.MathOptLMO, a::AbstractVector, x::AbstractVector) + o2 = MOI.instantiate(typeof(lmo.o)) + MOI.copy_to(o2, lmo.o) MOI.set(o2, MOI.Silent(), true) return is_inface_feasible(o2, a, x) end @@ -366,18 +367,18 @@ end Add explicit bounds for binary variables. """ -function explicit_bounds_binary_var(blmo::MathOptBLMO, global_bounds::IntegerBounds) +function explicit_bounds_binary_var(lmo::FrankWolfe.MathOptLMO, global_bounds::IntegerBounds) # adding binary bounds explicitly - binary_variables = get_binary_variables(blmo) + binary_variables = get_binary_variables(lmo) for idx in binary_variables cidx = MOI.ConstraintIndex{MOI.VariableIndex,MOI.LessThan{Float64}}(idx.value) - if !MOI.is_valid(blmo.o, cidx) - MOI.add_constraint(blmo.o, MOI.VariableIndex(idx.value), MOI.LessThan(1.0)) + if !MOI.is_valid(lmo.o, cidx) + MOI.add_constraint(lmo.o, MOI.VariableIndex(idx.value), MOI.LessThan(1.0)) end - @assert MOI.is_valid(blmo.o, cidx) + @assert MOI.is_valid(lmo.o, cidx) cidx = MOI.ConstraintIndex{MOI.VariableIndex,MOI.GreaterThan{Float64}}(idx.value) - if !MOI.is_valid(blmo.o, cidx) - MOI.add_constraint(blmo.o, MOI.VariableIndex(idx.value), MOI.GreaterThan(0.0)) + if !MOI.is_valid(lmo.o, cidx) + MOI.add_constraint(lmo.o, MOI.VariableIndex(idx.value), MOI.GreaterThan(0.0)) end global_bounds[idx.value, :greaterthan] = 0.0 global_bounds[idx.value, :lessthan] = 1.0 @@ -389,14 +390,14 @@ end Read global bounds from the problem """ -function build_global_bounds(blmo::MathOptBLMO, integer_variables) +function build_global_bounds(lmo::FrankWolfe.MathOptLMO, integer_variables) global_bounds = IntegerBounds() for idx in integer_variables for ST in (MOI.LessThan{Float64}, MOI.GreaterThan{Float64}) cidx = MOI.ConstraintIndex{MOI.VariableIndex,ST}(idx) # Variable constraints to not have to be explicitly given, see Buchheim example - if MOI.is_valid(blmo.o, cidx) - s = MOI.get(blmo.o, MOI.ConstraintSet(), cidx) + if MOI.is_valid(lmo.o, cidx) + s = MOI.get(lmo.o, MOI.ConstraintSet(), cidx) if ST == MOI.LessThan{Float64} push!(global_bounds, (idx, s.upper), :lessthan) else @@ -405,18 +406,18 @@ function build_global_bounds(blmo::MathOptBLMO, integer_variables) end end cidx = MOI.ConstraintIndex{MOI.VariableIndex,MOI.Interval{Float64}}(idx) - if MOI.is_valid(blmo.o, cidx) + if MOI.is_valid(lmo.o, cidx) x = MOI.VariableIndex(idx) - s = MOI.get(blmo.o, MOI.ConstraintSet(), cidx) - MOI.delete(blmo.o, cidx) - MOI.add_constraint(blmo.o, x, MOI.GreaterThan(s.lower)) - MOI.add_constraint(blmo.o, x, MOI.LessThan(s.upper)) + s = MOI.get(lmo.o, MOI.ConstraintSet(), cidx) + MOI.delete(lmo.o, cidx) + MOI.add_constraint(lmo.o, x, MOI.GreaterThan(s.lower)) + MOI.add_constraint(lmo.o, x, MOI.LessThan(s.upper)) push!(global_bounds, (idx, s.lower), :greaterthan) push!(global_bounds, (idx, s.upper), :lessthan) end - @assert !MOI.is_valid(blmo.o, cidx) + @assert !MOI.is_valid(lmo.o, cidx) end - explicit_bounds_binary_var(blmo, global_bounds) + explicit_bounds_binary_var(lmo, global_bounds) return global_bounds end @@ -429,15 +430,15 @@ end Check if the bounds were set correctly in build_LMO. Safety check only. """ -function build_LMO_correct(blmo, node_bounds) +function build_LMO_correct(lmo, node_bounds) for list in (node_bounds.lower_bounds, node_bounds.upper_bounds) for (idx, set) in list c_idx = MOI.ConstraintIndex{MOI.VariableIndex,typeof(set)}(idx) - @assert MOI.is_valid(blmo.o, c_idx) - set2 = MOI.get(blmo.o, MOI.ConstraintSet(), c_idx) + @assert MOI.is_valid(lmo.o, c_idx) + set2 = MOI.get(lmo.o, MOI.ConstraintSet(), c_idx) if !(set == set2) - MOI.set(blmo.o, MOI.ConstraintSet(), c_idx, set) - set3 = MOI.get(blmo.o, MOI.ConstraintSet(), c_idx) + MOI.set(lmo.o, MOI.ConstraintSet(), c_idx, set) + set3 = MOI.get(lmo.o, MOI.ConstraintSet(), c_idx) @assert (set3 == set) "$((idx, set3, set))" end end @@ -450,8 +451,8 @@ end Free model data from previous solve (if necessary). """ -function free_model(blmo::MathOptBLMO) - return free_model(blmo.o) +function free_model(lmo::FrankWolfe.MathOptLMO) + return free_model(lmo.o) end # no-op by default @@ -464,14 +465,14 @@ end Check if problem is bounded and feasible, i.e. no contradicting constraints. """ -function check_feasibility(blmo::MathOptBLMO) +function check_feasibility(lmo::FrankWolfe.MathOptLMO) MOI.set( - blmo.o, + lmo.o, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(), MOI.ScalarAffineFunction{Float64}([], 0.0), ) - MOI.optimize!(blmo.o) - status = MOI.get(blmo.o, MOI.TerminationStatus()) + MOI.optimize!(lmo.o) + status = MOI.get(lmo.o, MOI.TerminationStatus()) return status end @@ -480,16 +481,14 @@ end Check whether a split is valid, i.e. the upper and lower on variable vidx are not the same. """ -function is_valid_split(tree::Bonobo.BnBTree, blmo::MathOptBLMO, vidx::Int) - bin_var, _ = has_binary_constraint(blmo, vidx) - int_var, _ = has_integer_constraint(blmo, vidx) +function is_valid_split(tree::Bonobo.BnBTree, lmo::FrankWolfe.MathOptLMO, vidx::Int) + bin_var, _ = has_binary_constraint(lmo, vidx) + int_var, _ = has_integer_constraint(lmo, vidx) if int_var || bin_var l_idx = MOI.ConstraintIndex{MOI.VariableIndex,MOI.GreaterThan{Float64}}(vidx) u_idx = MOI.ConstraintIndex{MOI.VariableIndex,MOI.LessThan{Float64}}(vidx) - l_bound = - MOI.is_valid(blmo.o, l_idx) ? MOI.get(blmo.o, MOI.ConstraintSet(), l_idx) : nothing - u_bound = - MOI.is_valid(blmo.o, u_idx) ? MOI.get(blmo.o, MOI.ConstraintSet(), u_idx) : nothing + l_bound = MOI.is_valid(lmo.o, l_idx) ? MOI.get(lmo.o, MOI.ConstraintSet(), l_idx) : nothing + u_bound = MOI.is_valid(lmo.o, u_idx) ? MOI.get(lmo.o, MOI.ConstraintSet(), u_idx) : nothing if (l_bound !== nothing && u_bound !== nothing && l_bound.lower === u_bound.upper) @debug l_bound.lower, u_bound.upper return false @@ -503,31 +502,31 @@ function is_valid_split(tree::Bonobo.BnBTree, blmo::MathOptBLMO, vidx::Int) end """ - get_BLMO_solve_data(blmo::MathOptBLMO) + get_LMO_solve_data(blmo::MathOptBLMO) Get solve time, number of nodes and number of simplex iterations. """ -function get_BLMO_solve_data(blmo::MathOptBLMO) - if !isempty(blmo.inface_point_solve_data.MOI_attribute) - opt_times = blmo.inface_point_solve_data.MOI_attribute[MOI.SolveTimeSec()] - numberofnodes = blmo.inface_point_solve_data.MOI_attribute[MOI.NodeCount()] - simplex_iterations = blmo.inface_point_solve_data.MOI_attribute[MOI.SimplexIterations()] - empty!(blmo.inface_point_solve_data.MOI_attribute) - else - opt_times = MOI.get(blmo.o, MOI.SolveTimeSec()) - numberofnodes = MOI.get(blmo.o, MOI.NodeCount()) - simplex_iterations = MOI.get(blmo.o, MOI.SimplexIterations()) - end +function get_LMO_solve_data(lmo::FrankWolfe.MathOptLMO) + #if !isempty(lmo.inface_point_solve_data.MOI_attribute) + # opt_times = lmo.inface_point_solve_data.MOI_attribute[MOI.SolveTimeSec()] + # numberofnodes = lmo.inface_point_solve_data.MOI_attribute[MOI.NodeCount()] + # simplex_iterations = lmo.inface_point_solve_data.MOI_attribute[MOI.SimplexIterations()] + # empty!(blmo.inface_point_solve_data.MOI_attribute) + #else + opt_times = MOI.get(lmo.o, MOI.SolveTimeSec()) + numberofnodes = MOI.get(lmo.o, MOI.NodeCount()) + simplex_iterations = MOI.get(lmo.o, MOI.SimplexIterations()) + #end return opt_times, numberofnodes, simplex_iterations end """ - is_indicator_feasible(blmo::MathOptBLMO, v; atol=1e-6, rtol=1e-6) + is_indicator_feasible(lmo::FrankWolfe.MathOptLMO, v; atol=1e-6, rtol=1e-6) Is a given point v indicator feasible, i.e. meets the indicator constraints? If applicable. """ -function is_indicator_feasible(blmo::MathOptBLMO, v; atol=1e-6, rtol=1e-6) - return is_indicator_feasible(blmo.o, v; atol, rtol) +function is_indicator_feasible(lmo::FrankWolfe.MathOptLMO, v; atol=1e-6, rtol=1e-6) + return is_indicator_feasible(lmo.o, v; atol, rtol) end function is_indicator_feasible(o, x; atol=1e-6, rtol=1e-6) valvar(f) = x[f.value] @@ -556,8 +555,8 @@ end Are indicator constraints present? """ -function indicator_present(blmo::MathOptBLMO) - for (_, S) in MOI.get(blmo.o, MOI.ListOfConstraintTypesPresent()) +function indicator_present(lmo::FrankWolfe.MathOptLMO) + for (_, S) in MOI.get(lmo.o, MOI.ListOfConstraintTypesPresent()) if S <: MOI.Indicator return true end @@ -570,26 +569,26 @@ end Get solving tolerance for the BLMO. """ -function get_tol(blmo::MathOptBLMO) - return get_tol(blmo.o) +function get_tol(lmo::FrankWolfe.MathOptLMO) + return get_tol(lmo.o) end function get_tol(o::MOI.AbstractOptimizer) return 1e-06 end """ - find_best_solution(f::Function, blmo::MathOptBLMO, vars, domain_oracle) + find_best_solution(f::Function, lmo::FrankWolfe.MathOptLMO, vars, domain_oracle) Find best solution from the solving process. """ function find_best_solution( tree::Bonobo.BnBTree, f::Function, - blmo::MathOptBLMO, + lmo::FrankWolfe.MathOptLMO, vars, domain_oracle, ) - return find_best_solution(tree, f, blmo.o, vars, domain_oracle) + return find_best_solution(tree, f, lmo.o, vars, domain_oracle) end """ @@ -632,25 +631,25 @@ end List of all variable pointers. Depends on how you save your variables internally. Is used in `find_best_solution`. """ -function get_variables_pointers(blmo::MathOptBLMO, tree) +function get_variables_pointers(lmo::FrankWolfe.MathOptLMO, tree) return [MOI.VariableIndex(var) for var in 1:(tree.root.problem.nvars)] end """ - check_infeasible_vertex(blmo::MathOptBLMO, tree) + check_infeasible_vertex(lmo::FrankWolfe.MathOptLMO, tree) Deal with infeasible vertex if necessary, e.g. check what caused it etc. """ -function check_infeasible_vertex(blmo::MathOptBLMO, tree) +function check_infeasible_vertex(lmo::FrankWolfe.MathOptLMO, tree) node = tree.nodes[tree.root.current_node_id[]] node_bounds = node.local_bounds for list in (node_bounds.lower_bounds, node_bounds.upper_bounds) for (idx, set) in list c_idx = MOI.ConstraintIndex{MOI.VariableIndex,typeof(set)}(idx) - @assert MOI.is_valid(state.tlmo.blmo.o, c_idx) - set2 = MOI.get(state.tlmo.blmo.o, MOI.ConstraintSet(), c_idx) + @assert MOI.is_valid(lmo.o, c_idx) + set2 = MOI.get(state.tlmo.lmo.o, MOI.ConstraintSet(), c_idx) if !(set == set2) - MOI.set(tlmo.blmo.o, MOI.ConstraintSet(), c_idx, set) + MOI.set(lmo.o, MOI.ConstraintSet(), c_idx, set) set3 = MOI.get(tlmo.blmo.o, MOI.ConstraintSet(), c_idx) @assert (set3 == set) "$((idx, set3, set))" end @@ -666,14 +665,14 @@ Note that in constrast to the `ManagedBLMO` type, we filter out the integer and """ function Bonobo.get_branching_variable( tree::Bonobo.BnBTree, - branching::PartialStrongBranching{MathOptBLMO{OT}}, + branching::PartialStrongBranching{FrankWolfe.MathOptLMO{OT}}, node::Bonobo.AbstractNode, ) where {OT<:MOI.AbstractOptimizer} xrel = Bonobo.get_relaxed_values(tree, node) max_lowerbound = -Inf max_idx = -1 # copy problem and remove integer constraints - filtered_src = MOI.Utilities.ModelFilter(tree.root.problem.tlmo.blmo.o) do item + filtered_src = MOI.Utilities.ModelFilter(tree.root.problem.tlmo.lmo.o) do item if item isa Tuple (_, S) = item if S <: Union{MOI.Indicator,MOI.Integer,MOI.ZeroOne} @@ -682,14 +681,14 @@ function Bonobo.get_branching_variable( end return !(item isa MOI.ConstraintIndex{<:Any,<:Union{MOI.ZeroOne,MOI.Integer,MOI.Indicator}}) end - index_map = MOI.copy_to(branching.bounded_lmo.o, filtered_src) + index_map = MOI.copy_to(branching.lmo.o, filtered_src) # sanity check, otherwise the functions need permuted indices for (v1, v2) in index_map if v1 isa MOI.VariableIndex @assert v1 == v2 end end - relaxed_lmo = MathOptBLMO(branching.bounded_lmo.o) + relaxed_lmo = FrankWolfe.MathOptLMO(branching.lmo.o) @assert !isempty(node.active_set) active_set = copy(node.active_set) empty!(active_set) @@ -799,12 +798,8 @@ function Bonobo.get_branching_variable( return max_idx end - -function is_decomposition_invariant_oracle(blmo::MathOptBLMO) - return true -end - -function compute_inface_extreme_point(blmo::MathOptBLMO, direction, x; kwargs...) +#= +function compute_inface_extreme_point(lmo::FrankWolfe.MathOptLMO, direction, x; kwargs...) MOI_attribute = Dict() MOI_attribute[MOI.SolveTimeSec()] = 0.0 MOI_attribute[MOI.NodeCount()] = 0.0 @@ -826,37 +821,20 @@ function dicg_maximum_step(blmo::MathOptBLMO, direction, x; kwargs...) lmo = convert(FrankWolfe.MathOptLMO, blmo) return FrankWolfe.dicg_maximum_step(lmo, direction, x; kwargs...) end +=# +# Both of the following functions are for the continued support of the +# deprecated Boscia.MathOptBLMO type. """ -The `solve` function receiving a `FrankWolfe.MathOptLMO`. -Converts the lmo into an instance of `Boscia.MathOptBLMO` and calls the main `solve` function. +The `solve` function receiving a `Boscia.MathOptBLMO`. +Converts the lmo into an instance of `FrankWolfe.MathOptLMO` and calls the main `solve` function. """ -function solve( - f, - g, - lmo::FrankWolfe.MathOptLMO; - mode::Mode=DEFAULT_MODE, - settings_bnb=settings_bnb(mode=mode), - settings_frank_wolfe=settings_frank_wolfe(mode=mode), - settings_tolerances=settings_tolerances(mode=mode), - settings_postprocessing=settings_postprocessing(mode=mode), - settings_heuristic=settings_heuristic(mode=mode), - settings_tightening=settings_tightening(mode=mode), - settings_domain=settings_domain(mode=mode), - kwargs..., -) - blmo = convert(MathOptBLMO, lmo) - return solve( - f, - g, - blmo; - settings_bnb=settings_bnb, - settings_frank_wolfe=settings_frank_wolfe, - settings_tolerances=settings_tolerances, - settings_postprocessing=settings_postprocessing, - settings_heuristic=settings_heuristic, - settings_tightening=settings_tightening, - settings_domain=settings_domain, - kwargs..., - ) +function solve(f, g, blmo::MathOptBLMO; settings=create_default_settings(), kwargs...) + lmo = convert(FrankWolfe.MathOptLMO, blmo) + return solve(f, g, lmo; settings=settings, kwargs...) +end + +function PartialStrongBranching(max_iteration::Int, solving_epsilon::Float64, lmo::MathOptBLMO) + lmo = convert(FrankWolfe.MathOptLMO, lmo) + return PartialStrongBranching(max_iteration, solving_epsilon, lmo) end diff --git a/src/blmo_interface.jl b/src/blmo_interface.jl index 25f7890bf..080e472e2 100644 --- a/src/blmo_interface.jl +++ b/src/blmo_interface.jl @@ -1,15 +1,17 @@ """ BLMO -Supertype for the Bounded Linear Minimization Oracles +Supertype for the Bounded Linear Minimization Oracles, + +WILL BE DEPRECATED in favor of simply extending the `FrankWolfe.LinearMinimizationOracle` type. """ abstract type BoundedLinearMinimizationOracle <: FrankWolfe.LinearMinimizationOracle end """ -Enum encoding the status of the Bounded Linear Minimization Oracle. +Enum encoding the status of the Linear Minimization Oracle. Currently available: `OPTIMAL`, `INFEASIBLE` and `UNBOUNDED`. """ -@enum BLMOStatus begin +@enum LMOStatus begin OPTIMAL = 0 INFEASIBLE = 1 UNBOUNDED = 2 @@ -24,7 +26,7 @@ Given a direction d solves the problem `min_x d^T x` where x has to be an integer feasible point """ -function compute_extreme_point end +#compute_extreme_point(lmo,d; kwargs...) """ Read global bounds from the problem. @@ -113,87 +115,87 @@ function has_integer_constraint end ## Safety Functions """ - build_LMO_correct(blmo::BoundedLinearMinimizationOracle, node_bounds) + build_LMO_correct(lmo::LinearMinimizationOracle, node_bounds) Check if the bounds were set correctly in build_LMO. Safety check only. """ -function build_LMO_correct(blmo::BoundedLinearMinimizationOracle, node_bounds) +function build_LMO_correct(lmo::LinearMinimizationOracle, node_bounds) return true end """ - check_feasibility(blmo::BoundedLinearMinimizationOracle) + check_feasibility(lmo::LinearMinimizationOracle) Check if problem is bounded and feasible, i.e. no contradicting constraints. """ -function check_feasibility(blmo::BoundedLinearMinimizationOracle) +function check_feasibility(lmo::LinearMinimizationOracle) return OPTIMAL end """ - is_valid_split(tree::Bonobo.BnBTree, blmo::BoundedLinearMinimizationOracle, vidx::Int) + is_valid_split(tree::Bonobo.BnBTree, lmo::LinearMinimizationOracle, vidx::Int) Check whether a split is valid, i.e. the upper and lower on variable vidx are not the same. """ -function is_valid_split(tree::Bonobo.BnBTree, blmo::BoundedLinearMinimizationOracle, vidx::Int) +function is_valid_split(tree::Bonobo.BnBTree, lmo::LinearMinimizationOracle, vidx::Int) return true end """ - is_indicator_feasible(blmo::BoundedLinearMinimizationOracle, v; atol=1e-6, rtol=1e-6) + is_indicator_feasible(lmo::LinearMinimizationOracle, v; atol=1e-6, rtol=1e-6) Is a given point v indicator feasible, i.e. meets the indicator constraints? If applicable. """ -function is_indicator_feasible(blmo::BoundedLinearMinimizationOracle, v; atol=1e-6, rtol=1e-6) +function is_indicator_feasible(lmo::LinearMinimizationOracle, v; atol=1e-6, rtol=1e-6) return true end """ - indicator_present(blmo::BoundedLinearMinimizationOracle) + indicator_present(lmo::LinearMinimizationOracle) Are indicator constraints present? """ -function indicator_present(blmo::BoundedLinearMinimizationOracle) +function indicator_present(lmo::LinearMinimizationOracle) return false end """ - check_infeasible_vertex(blmo::BoundedLinearMinimizationOracle, tree) + check_infeasible_vertex(lmo::LinearMinimizationOracle, tree) Deal with infeasible vertex if necessary, e.g. check what caused it etc. """ -function check_infeasible_vertex(blmo::BoundedLinearMinimizationOracle, tree) end +function check_infeasible_vertex(lmo::LinearMinimizationOracle, tree) end ## Utility """ - free_model(blmo::BoundedLinearMinimizationOracle) + free_model(lmo::LinearMinimizationOracle) Free model data from previous solve (if necessary). """ -function free_model(blmo::BoundedLinearMinimizationOracle) +function free_model(lmo::LinearMinimizationOracle) return true end """ - get_tol(blmo::BoundedLinearMinimizationOracle) + get_tol(lmo::LinearMinimizationOracle) -Get solving tolerance for the BLMO. +Get solving tolerance for the LMO. """ -function get_tol(blmo::BoundedLinearMinimizationOracle) +function get_tol(lmo::LinearMinimizationOracle) return 1e-6 end """ - find_best_solution(f::Function, blmo::BoundedLinearMinimizationOracle, vars, domain_oracle) + find_best_solution(f::Function, lmo::LinearMinimizationOracle, vars, domain_oracle) Find best solution from the solving process. """ function find_best_solution( tree::Bonobo.BnBTree, f::Function, - blmo::BoundedLinearMinimizationOracle, + lmo::LinearMinimizationOracle, vars, domain_oracle, ) @@ -201,12 +203,12 @@ function find_best_solution( end """ - get_variables_pointers(blmo::BoundedLinearMinimizationOracle, tree) + get_variables_pointers(lmo::LinearMinimizationOracle, tree) List of all variable pointers. Depends on how you save your variables internally. In the easy case, this is simply `collect(1:N)`. Is used in `find_best_solution`. """ -function get_variables_pointers(blmo::BoundedLinearMinimizationOracle, tree) +function get_variables_pointers(lmo::LinearMinimizationOracle, tree) N = tree.root.problem.nvars return collect(1:N) end @@ -214,53 +216,63 @@ end ## Logs """ - get_BLMO_solve_data(blmo::BoundedLinearMinimizationOracle) + get_LMO_solve_data(lmo::LinearMinimizationOracle) Get solve time, number of nodes and number of iterations, if applicable. """ -function get_BLMO_solve_data(blmo::BoundedLinearMinimizationOracle) +function get_LMO_solve_data(lmo::LinearMinimizationOracle) return 0.0, 0.0, 0.0 end +#= ## These DICG-specific functions are essential for Boscia to run with DICG. """ + is_decomposition_invariant_oracle(lmo::LinearMinimizationOracle) + Implement `FrankWolfe.is_decomposition_invariant_oracle` Check if necessary DICG-specific orcales are implemented. """ -function is_decomposition_invariant_oracle(blmo::BoundedLinearMinimizationOracle) +function is_decomposition_invariant_oracle(lmo::LinearMinimizationOracle) return false end """ + is_inface_feasible(lmo::LinearMinimizationOracle, a, x) + Is a given point a on the minimal face containing the given x? """ -function is_inface_feasible(blmo::BoundedLinearMinimizationOracle, a, x) +function is_inface_feasible(lmo::LinearMinimizationOracle, a, x) return false end """ + compute_inface_extreme_point(lmo::LinearMinimizationOracle, d, x) + Implement `FrankWolfe.compute_inface_extreme_point` Given a direction d and feasible point x solves the problem min_a d^T a where a has to be an integer feasible point and on the minimal face containing x """ -function compute_inface_extreme_point(blmo::BoundedLinearMinimizationOracle, d, x) +function compute_inface_extreme_point(lmo::LinearMinimizationOracle, d, x) return error( - "To use DICG within Boscia, this function has to be implemented for $(typeof(blmo)).", + "To use DICG within Boscia, this function has to be implemented for $(typeof(lmo)).", ) end """ + dicg_maximum_step(lmo::LinearMinimizationOracle, d, x) + Implement `FrankWolfe.dicg_maximum_step` Given a direction d and feasible point x solves the problem argmax_γ (x - γ * d) ∈ P where P is feasible set """ -function dicg_maximum_step(blmo::BoundedLinearMinimizationOracle, d, x) +function dicg_maximum_step(lmo::LinearMinimizationOracle, d, x) return error( - "To use DICG within Boscia, this function has to be implemented for $(typeof(blmo)).", + "To use DICG within Boscia, this function has to be implemented for $(typeof(lmo)).", ) end +=# diff --git a/src/branching_strategies.jl b/src/branching_strategies.jl index eee55a3fd..b734baa9e 100644 --- a/src/branching_strategies.jl +++ b/src/branching_strategies.jl @@ -648,8 +648,12 @@ mutable struct Hierarchy <: Bonobo.AbstractBranchStrategy pseudos::SparseMatrixCSC{Float64,Int64} branch_tracker::SparseMatrixCSC{Int64,Int64} stages::Vector{Stage} - function Hierarchy(bounded_lmo; stages=[]) - int_vars = Boscia.get_integer_variables(bounded_lmo) + function Hierarchy(lmo; stages=[]) + # as long as MathOptBLMO is supported, we need to convert the lmo to a FrankWolfe.MathOptLMO + if lmo isa MathOptBLMO + lmo = convert(FrankWolfe.MathOptLMO, lmo) + end + int_vars = Boscia.get_integer_variables(lmo) int_var_number = length(int_vars) # create sparse array for pseudocosts pseudos = sparse( @@ -777,13 +781,17 @@ end """ function create_binary_stage( - bounded_lmo + lmo ) Creates a Stage for Hierarchy Branching where non binary variables are filtered out when binary variables exist. """ -function create_binary_stage(bounded_lmo) - binary_vars = Set{Int64}(getproperty.(get_binary_variables(bounded_lmo), :value)) +function create_binary_stage(lmo) + # As long as MathOptBLMO is supported, we need to convert the lmo to a FrankWolfe.MathOptLMO + if lmo isa MathOptBLMO + lmo = convert(FrankWolfe.MathOptLMO, lmo) + end + binary_vars = Set{Int64}(getproperty.(get_binary_variables(lmo), :value)) function select_binary_vars( tree::Bonobo.BnBTree, branching::Bonobo.AbstractBranchStrategy, @@ -932,12 +940,16 @@ mutable struct PseudocostBranching <: Bonobo.AbstractBranchStrategy alt_decision_number::Int64# Contains information on how often the alternative strategy decided stable_decision_number::Int64# How many pseudocost branching decisions were made function PseudocostBranching( - bounded_lmo; + lmo; alt_f=most_infeasible_decision, stable_f=PseudocostStableSelectionGenerator("product", 1e-6), iterations_until_stable=1, ) - int_vars = Boscia.get_integer_variables(bounded_lmo) + # As long as MathOptBLMO is supported, we need to convert the lmo to a FrankWolfe.MathOptLMO + if lmo isa MathOptBLMO + lmo = convert(FrankWolfe.MathOptLMO, lmo) + end + int_vars = Boscia.get_integer_variables(lmo) int_var_number = length(int_vars) # create sparse array for pseudocosts pseudos = sparse( diff --git a/src/build_lmo.jl b/src/build_lmo.jl index f1140bd46..c65b76c38 100644 --- a/src/build_lmo.jl +++ b/src/build_lmo.jl @@ -9,28 +9,28 @@ Four action can be taken: - ADD bound has to be added for this node because it does not exist in the global bounds (e.g. variable bound is a half open interval globally) """ function build_LMO( - blmo::BoundedLinearMinimizationOracle, + lmo::LinearMinimizationOracle, global_bounds::IntegerBounds, node_bounds::IntegerBounds, int_vars::Vector{Int}, ) - free_model(blmo) + free_model(lmo) - consLB_list = get_lower_bound_list(blmo) - consUB_list = get_upper_bound_list(blmo) + consLB_list = get_lower_bound_list(lmo) + consUB_list = get_upper_bound_list(lmo) cons_delete = [] # Lower bounds for c_idx in consLB_list - if is_constraint_on_int_var(blmo, c_idx, int_vars) - v_idx = get_int_var(blmo, c_idx) - if is_bound_in(blmo, c_idx, global_bounds.lower_bounds) + if is_constraint_on_int_var(lmo, c_idx, int_vars) + v_idx = get_int_var(lmo, c_idx) + if is_bound_in(lmo, c_idx, global_bounds.lower_bounds) # Change - if is_bound_in(blmo, c_idx, node_bounds.lower_bounds) - set_bound!(blmo, c_idx, node_bounds.lower_bounds[v_idx], :greaterthan) + if is_bound_in(lmo, c_idx, node_bounds.lower_bounds) + set_bound!(lmo, c_idx, node_bounds.lower_bounds[v_idx], :greaterthan) # Keep else - set_bound!(blmo, c_idx, global_bounds.lower_bounds[v_idx], :greaterthan) + set_bound!(lmo, c_idx, global_bounds.lower_bounds[v_idx], :greaterthan) end else # Delete @@ -41,15 +41,15 @@ function build_LMO( # Upper bounds for c_idx in consUB_list - if is_constraint_on_int_var(blmo, c_idx, int_vars) - v_idx = get_int_var(blmo, c_idx) - if is_bound_in(blmo, c_idx, global_bounds.upper_bounds) + if is_constraint_on_int_var(lmo, c_idx, int_vars) + v_idx = get_int_var(lmo, c_idx) + if is_bound_in(lmo, c_idx, global_bounds.upper_bounds) # Change - if is_bound_in(blmo, c_idx, node_bounds.upper_bounds) - set_bound!(blmo, c_idx, node_bounds.upper_bounds[v_idx], :lessthan) + if is_bound_in(lmo, c_idx, node_bounds.upper_bounds) + set_bound!(lmo, c_idx, node_bounds.upper_bounds[v_idx], :lessthan) # Keep else - set_bound!(blmo, c_idx, global_bounds.upper_bounds[v_idx], :lessthan) + set_bound!(lmo, c_idx, global_bounds.upper_bounds[v_idx], :lessthan) end else # Delete @@ -59,23 +59,24 @@ function build_LMO( end # delete constraints - delete_bounds!(blmo, cons_delete) + delete_bounds!(lmo, cons_delete) # add node specific constraints # These are bounds constraints where there is no corresponding global bound for key in keys(node_bounds.lower_bounds) if !haskey(global_bounds.lower_bounds, key) - add_bound_constraint!(blmo, key, node_bounds.lower_bounds[key], :greaterthan) + add_bound_constraint!(lmo, key, node_bounds.lower_bounds[key], :greaterthan) end end for key in keys(node_bounds.upper_bounds) if !haskey(global_bounds.upper_bounds, key) - add_bound_constraint!(blmo, key, node_bounds.upper_bounds[key], :lessthan) + add_bound_constraint!(lmo, key, node_bounds.upper_bounds[key], :lessthan) end end - return build_LMO_correct(blmo, node_bounds) + # Finally, check if the bounds were set correctly + return build_LMO_correct(lmo, node_bounds) end build_LMO(tlmo::TimeTrackingLMO, gb::IntegerBounds, nb::IntegerBounds, int_vars::Vector{Int64}) = - build_LMO(tlmo.blmo, gb, nb, int_vars) + build_LMO(tlmo.lmo, gb, nb, int_vars) diff --git a/src/callbacks.jl b/src/callbacks.jl index 63a859dcd..69535f90e 100644 --- a/src/callbacks.jl +++ b/src/callbacks.jl @@ -16,7 +16,7 @@ function build_FW_callback( time_limit; use_DICG=false, ) - vars = get_variables_pointers(tree.root.problem.tlmo.blmo, tree) + vars = get_variables_pointers(tree.root.problem.tlmo.lmo, tree) # variable to only fetch heuristics when the counter increases ncalls = -1 if !use_DICG @@ -72,16 +72,53 @@ function process_FW_callback_logic( kwargs..., ) + if tree.root.options[:fw_callback] !== nothing + if use_DICG + tree.root.options[:fw_callback](state, pre_computed_set, kwargs...) + else + tree.root.options[:fw_callback](state, active_set, kwargs...) + end + end if !use_DICG - @assert isapprox(sum(active_set.weights), 1.0) "sum(active_set.weights) = $(sum(active_set.weights))" + @assert isapprox(sum(active_set.weights), 1.0, atol=1e-10) "sum(active_set.weights) = $(sum(active_set.weights))" @assert sum(active_set.weights .< 0) == 0 end + #=if state.t > 1 + if !isfinite(state.primal) || !isfinite(state.dual_gap) + @show state.t, state.primal, state.dual_gap + @show state.x + @show state.v + @show state.d + @show state.gamma + @show state.gradient + end + @assert isfinite(state.primal) "state.primal = $(state.primal) is not finite" + @assert isfinite(state.dual_gap) "state.dual_gap = $(state.dual_gap) is not finite" + end =# + + if tree.root.options[:mode] == SMOOTHING_MODE && tree.root.options[:best_sol_by_original] + fx = tree.root.options[:original_objective](state.x) + if fx < tree.root.options[:local_opt_primal] || state.t in [0, 1] + tree.root.options[:local_opt_primal] = fx + tree.root.options[:local_opt_x] = state.x + tree.root.options[:local_active_set] = use_DICG ? pre_computed_set : active_set + end + # sub_grad= [] + # tree.root.options[:sub_grad!](sub_grad, state.x) + # local_dual_gap = Inf + # for i in eachindex(sub_grad) + # v = compute_extreme_point(tree.root.problem.tlmo, sub_grad[i]) + # local_dual_gap = min(local_dual_gap, dot(sub_grad[i], state.x - v)) + # end + # @show tree.root.problem.f(state.x), local_dual_gap + end + # TODO deal with vertices becoming infeasible with conflicts @debug begin if !is_linear_feasible(tree.root.problem.tlmo, state.v) @info "$(state.v)" - check_infeasible_vertex(tree.root.problem.tlmo.blmo, tree) + check_infeasible_vertex(tree.root.problem.tlmo.lmo, tree) @assert is_linear_feasible(tree.root.problem.tlmo, state.v) end if state.step_type != FrankWolfe.ST_SIMPLEXDESCENT && !is_integer_feasible(tree, state.v) @@ -97,7 +134,7 @@ function process_FW_callback_logic( (best_v, best_val) = find_best_solution( tree, tree.root.problem.f, - tree.root.problem.tlmo.blmo, + tree.root.problem.tlmo.lmo, vars, tree.root.options[:domain_oracle], ) @@ -188,6 +225,7 @@ function build_bnb_callback( list_time_cb, list_num_nodes_cb, list_lmo_calls_cb, + list_lmo_times_acc, verbose, fw_iterations, list_active_set_size_cb, @@ -248,14 +286,17 @@ function build_bnb_callback( if !node_infeasible #update lower bound if lb_update == true - tree.node_queue[node.id] = (node.lb, node.id) - _, prio = peek(tree.node_queue) - @assert tree.lb <= prio[1] - tree.lb = min(minimum([prio[2][1] for prio in tree.node_queue]), tree.incumbent) + node_queue_lb = Inf + if !isempty(tree.node_queue) + _, prio = first(tree.node_queue) + @assert tree.lb <= prio[1] + node_queue_lb = minimum([prio[2][1] for prio in tree.node_queue]) + end + tree.lb = min(node_queue_lb, tree.incumbent, node.lb) end push!(list_ub_cb, tree.incumbent) push!(list_num_nodes_cb, tree.num_nodes) - push!(node_level, node.level) + push!(node_level, node.std.depth) iteration += 1 if tree.lb == -Inf && isempty(tree.nodes) tree.lb = node.lb @@ -289,7 +330,7 @@ function build_bnb_callback( end LMO_calls_c = tree.root.problem.tlmo.ncalls push!(list_lmo_calls_cb, copy(LMO_calls_c)) - + push!(list_lmo_times_acc, float(LMO_time)) if !isempty(tree.node_queue) p_lb = tree.lb tree.lb = min(minimum([prio[2][1] for prio in tree.node_queue]), tree.incumbent) @@ -299,8 +340,8 @@ function build_bnb_callback( tree.lb = tree_lb(tree) dual_gap = tree.incumbent - tree_lb(tree) push!(list_lb_cb, tree_lb(tree)) - active_set_size = length(node.active_set) - discarded_set_size = length(node.discarded_vertices.storage) + active_set_size = node.active_set_size + discarded_set_size = node.discarded_set_size push!(list_active_set_size_cb, active_set_size) push!(list_discarded_set_size_cb, discarded_set_size) nodes_left = length(tree.nodes) @@ -347,14 +388,14 @@ function build_bnb_callback( else LMO_calls = list_lmo_calls_cb[end] end - if length(lmo_calls_per_layer) < node.level + if length(lmo_calls_per_layer) < node.std.depth push!(lmo_calls_per_layer, [LMO_calls]) push!(active_set_size_per_layer, [active_set_size]) push!(discarded_set_size_per_layer, [discarded_set_size]) else - push!(lmo_calls_per_layer[node.level], LMO_calls) - push!(active_set_size_per_layer[node.level], active_set_size) - push!(discarded_set_size_per_layer[node.level], discarded_set_size) + push!(lmo_calls_per_layer[node.std.depth], LMO_calls) + push!(active_set_size_per_layer[node.std.depth], active_set_size) + push!(discarded_set_size_per_layer[node.std.depth], discarded_set_size) end # add tightenings @@ -372,13 +413,19 @@ function build_bnb_callback( end if Bonobo.terminated(tree) - Bonobo.sort_solutions!(tree.solutions, tree.sense) + Bonobo.sort_solutions!(tree.solutions) x = Bonobo.get_solution(tree) # x can be nothing if the user supplied a custom domain oracle and the time limit is reached if x === nothing @assert tree.root.problem.solving_stage == TIME_LIMIT_REACHED end - primal_value = x !== nothing ? tree.root.problem.f(x) : Inf + primal_value = if x === nothing + Inf + elseif tree.root.options[:mode] == SMOOTHING_MODE + tree.root.options[:original_objective](x) + else + tree.root.problem.f(x) + end # deactivate postsolve if there is no solution tree.root.options[:use_postsolve] = x === nothing ? false : tree.root.options[:use_postsolve] @@ -387,6 +434,11 @@ function build_bnb_callback( # If the tree is empty, incumbent and solution should be the same! if !tree.root.options[:no_pruning] && isempty(tree.nodes) + if tree.root.problem.solving_stage == TIME_LIMIT_REACHED + @warn "Incumbent is not equal to primal value: $(tree.incumbent) != $(primal_value)" + else + @assert isapprox(tree.incumbent, primal_value) "tree.incumbent = $(tree.incumbent) and primal_value = $(primal_value)" + end @assert isapprox(tree.incumbent, primal_value) end @@ -395,6 +447,7 @@ function build_bnb_callback( result[:heu_lmo_calls] = tree.root.options[:heu_ncalls] result[:list_num_nodes] = list_num_nodes_cb result[:list_lmo_calls_acc] = list_lmo_calls_cb + result[:list_lmo_times_acc] = list_lmo_times_acc result[:list_active_set_size] = list_active_set_size_cb result[:list_discarded_set_size] = list_discarded_set_size_cb result[:list_lb] = list_lb_cb diff --git a/src/custom_bonobo.jl b/src/custom_bonobo.jl index b70a4be75..899a0251c 100644 --- a/src/custom_bonobo.jl +++ b/src/custom_bonobo.jl @@ -30,6 +30,7 @@ function Bonobo.optimize!( tree::Bonobo.BnBTree{<:FrankWolfeNode}; callback=(args...; kwargs...) -> (), ) + while !Bonobo.terminated(tree) node = Bonobo.get_next_node(tree, tree.options.traverse_strategy) lb, ub = Bonobo.evaluate_node!(tree, node) @@ -44,6 +45,25 @@ function Bonobo.optimize!( # if the evaluated lower bound is worse than the best incumbent -> close and continue if !tree.root.options[:no_pruning] && node.lb >= tree.incumbent + # In pseudocost branching we need to perform the update now for nodes which will never be seen by get_branching_variable + if isa(tree.options.branch_strategy, Boscia.Hierarchy) || + isa(tree.options.branch_strategy, Boscia.PseudocostBranching) + if !isinf(node.parent_lower_bound_base) + idx = node.branched_on + update = lb - node.parent_lower_bound_base + update = update / node.distance_to_int + if isinf(update) + @debug "update is $(Inf)" + end + r_idx = node.branched_right ? 1 : 2 + tree.options.branch_strategy.pseudos[idx, r_idx] = update_avg( + update, + tree.options.branch_strategy.pseudos[idx, r_idx], + tree.options.branch_strategy.branch_tracker[idx, r_idx], + ) + tree.options.branch_strategy.branch_tracker[idx, r_idx] += 1 + end + end Bonobo.close_node!(tree, node) callback( tree, @@ -54,35 +74,13 @@ function Bonobo.optimize!( continue end - if node.lb >= tree.incumbent - # In pseudocost branching we need to perform the update now for nodes which will never be seen by get_branching_variable - if isa(tree.options.branch_strategy, Boscia.Hierarchy) || - isa(tree.options.branch_strategy, Boscia.PseudocostBranching) - if !isinf(node.parent_lower_bound_base) - idx = node.branched_on - update = lb - node.parent_lower_bound_base - update = update / node.distance_to_int - if isinf(update) - @debug "update is $(Inf)" - end - r_idx = node.branched_right ? 1 : 2 - tree.options.branch_strategy.pseudos[idx, r_idx] = update_avg( - update, - tree.options.branch_strategy.pseudos[idx, r_idx], - tree.options.branch_strategy.branch_tracker[idx, r_idx], - ) - tree.options.branch_strategy.branch_tracker[idx, r_idx] += 1 - end - end - end - tree.node_queue[node.id] = (node.lb, node.id) #_ , prio = peek(tree.node_queue) #@assert tree.lb <= prio[1] #tree.lb = prio[1] p_lb = tree.lb tree.lb = minimum([prio[2][1] for prio in tree.node_queue]) - @assert p_lb <= tree.lb + @assert p_lb <= tree.lb "p_lb: $(p_lb) <= tree.lb: $(tree.lb)" updated = Bonobo.update_best_solution!(tree, node) if updated @@ -101,11 +99,10 @@ function Bonobo.optimize!( y = Bonobo.get_solution(tree) vertex_storage = FrankWolfe.DeletedVertexStorage(typeof(y)[], 1) dummy_node = FrankWolfeNode( - NodeInfo(-1, Inf, Inf), + NodeInfo(-1, Inf, Inf, 0), FrankWolfe.ActiveSet([(1.0, y)]), vertex_storage, IntegerBounds(), - 1, 1e-3, Millisecond(0), 0, @@ -116,7 +113,7 @@ function Bonobo.optimize!( ) callback(tree, dummy_node, node_infeasible=true) end - return Bonobo.sort_solutions!(tree.solutions, tree.sense) + return Bonobo.sort_solutions!(tree.solutions) end function Bonobo.update_best_solution!( @@ -153,6 +150,10 @@ function add_new_solution!( tree.root.options[:post_heuristics_callback](tree, node, solution) end + if tree.root.options[:mode] == SMOOTHING_MODE + objective = tree.root.options[:original_objective](solution) + end + sol = FrankWolfeSolution(objective, solution, node, origin, time) sol.solution = solution sol.objective = objective @@ -178,3 +179,87 @@ function Bonobo.get_solution( end return tree.solutions[result].solution end + +# ============== New branching strategy ==============# +struct BRANCH_ALL <: Bonobo.AbstractBranchStrategy end + +function Bonobo.get_branching_variable(tree::Bonobo.BnBTree, ::BRANCH_ALL, node::Bonobo.AbstractNode) + gb = tree.root.problem.integer_variable_bounds + most_infeas_idx = Bonobo.get_branching_variable(tree, Bonobo.MOST_INFEASIBLE(), node) + + # Helper: is variable idx fixed in this node? (effective lb == effective ub) + function is_fixed(idx) + lb = get(gb.lower_bounds, idx, -Inf) + ub = get(gb.upper_bounds, idx, Inf) + if haskey(node.local_bounds.lower_bounds, idx) || haskey(node.local_bounds.upper_bounds, idx) + lb = get(node.local_bounds.lower_bounds, idx, lb) + ub = get(node.local_bounds.upper_bounds, idx, ub) + end + return lb == ub + end + + # If most-infeasible returned a variable, use it only if it is not fixed in this node + if most_infeas_idx != -1 && is_fixed(most_infeas_idx) + most_infeas_idx = -1 + end + + if most_infeas_idx != -1 + return most_infeas_idx + end + + # Fallback: first integer variable that is not fixed in this node + for idx in tree.root.problem.integer_variables + is_fixed(idx) && continue + return idx + end + + # All variables are fixed; no branching variable + return -1 +end + +# ============== New traverse strategy ==============# +struct BiasedDepthFirstSearch <: Bonobo.AbstractTraverseStrategy + favor_right::Bool +end + +BiasedDepthFirstSearch() = BiasedDepthFirstSearch(true) + +function Bonobo.get_next_node(tree::Bonobo.BnBTree, strategy::BiasedDepthFirstSearch) + node_queue = tree.node_queue + nodes = tree.nodes + + # For favored branch side (e.g. right if strategy.favor_right == true) + favored_id = -1 + favored_depth = -1 + favored_lb = Inf # we maximize depth, then minimize lb + + # For unfavored side + unfavored_id = -1 + unfavored_lb = Inf # we minimize lb + + for id in keys(node_queue) + + node = nodes[id] + + if node.branched_right == strategy.favor_right + # Favored: maximize depth, tie-break by smaller lb + if node.depth > favored_depth || (node.depth == favored_depth && node.lb < favored_lb) + favored_depth = node.depth + favored_lb = node.lb + favored_id = id + end + else + # Unfavored: choose smallest lb + if node.lb < unfavored_lb + unfavored_lb = node.lb + unfavored_id = id + end + end + end + + if favored_id !== -1 + return nodes[favored_id] + end + + return nodes[unfavored_id] +end diff --git a/src/frank_wolfe_variants.jl b/src/frank_wolfe_variants.jl index 0ebe8ef3e..5bc8bc1a2 100644 --- a/src/frank_wolfe_variants.jl +++ b/src/frank_wolfe_variants.jl @@ -10,6 +10,7 @@ It may also implement `build_frank_wolfe_workspace(x)` which creates a workspace structure that is passed as last argument to `solve_frank_wolfe`. """ abstract type FrankWolfeVariant end +abstract type DecompositionInvariant <: FrankWolfeVariant end # default printing for FrankWolfeVariant is just showing the type Base.print(io::IO, fw::FrankWolfeVariant) = print(io, split(string(typeof(fw)), ".")[end]) @@ -57,9 +58,10 @@ function solve_frank_wolfe( timeout=Inf, verbose=false, workspace=nothing, + print_fw_iter=1000, kwargs..., ) - x, _, primal, dual_gap, _, active_set = FrankWolfe.away_frank_wolfe( + x, _, primal, dual_gap, status, _, active_set = FrankWolfe.away_frank_wolfe( f, grad!, lmo, @@ -75,9 +77,10 @@ function solve_frank_wolfe( use_extra_vertex_storage=use_extra_vertex_storage, extra_vertex_storage=extra_vertex_storage, verbose=verbose, + print_iter=print_fw_iter, ) - return x, primal, dual_gap, active_set + return x, primal, dual_gap, status, active_set end Base.print(io::IO, ::AwayFrankWolfe) = print(io, "Away-Frank-Wolfe") @@ -106,9 +109,10 @@ function solve_frank_wolfe( timeout=Inf, verbose=false, workspace=nothing, + print_fw_iter=1000, kwargs..., ) - x, _, primal, dual_gap, _, active_set = blended_conditional_gradient( + x, _, primal, dual_gap, status, _, active_set = blended_conditional_gradient( f, grad!, lmo, @@ -123,13 +127,65 @@ function solve_frank_wolfe( timeout=timeout, verbose=verbose, sparsity_control=lazy_tolerance, + print_iter=print_fw_iter, ) - return x, primal, dual_gap, active_set + return x, primal, dual_gap, status, active_set end Base.print(io::IO, ::BlendedConditionalGradient) = print(io, "Blended Conditional Gradient") +""" + Pairwise FrankWolfe +""" + +struct PairwiseFrankWolfe <: FrankWolfeVariant end + +function solve_frank_wolfe( + frank_wolfe_variant::PairwiseFrankWolfe, + f, + grad!, + lmo, + active_set; + line_search::FrankWolfe.LineSearchMethod=FrankWolfe.Secant(), + epsilon=1e-7, + max_iteration=10000, + add_dropped_vertices=false, + use_extra_vertex_storage=false, + extra_vertex_storage=nothing, + callback=nothing, + lazy=false, + lazy_tolerance=2.0, + timeout=Inf, + verbose=false, + workspace=nothing, + print_fw_iter=1000, + kwargs..., +) + x, _, primal, dual_gap, status, _, active_set = + FrankWolfe.blended_pairwise_conditional_gradient( + f, + grad!, + lmo, + active_set, + line_search=line_search, + epsilon=epsilon, + max_iteration=max_iteration, + add_dropped_vertices=add_dropped_vertices, + use_extra_vertex_storage=use_extra_vertex_storage, + extra_vertex_storage=extra_vertex_storage, + callback=callback, + lazy=lazy, + sparsity_control=lazy_tolerance, + timeout=timeout, + verbose=verbose, + print_iter=print_fw_iter, + ) + return x, primal, dual_gap, status, active_set +end + +Base.print(io::IO, ::PairwiseFrankWolfe) = print(io, "Pairwise Frank-Wolfe") + """ Blended Pairwise Conditional Gradient """ @@ -153,26 +209,29 @@ function solve_frank_wolfe( timeout=Inf, verbose=false, workspace=nothing, + print_fw_iter=1000, kwargs..., ) - x, _, primal, dual_gap, _, active_set = FrankWolfe.blended_pairwise_conditional_gradient( - f, - grad!, - lmo, - active_set, - line_search=line_search, - epsilon=epsilon, - max_iteration=max_iteration, - add_dropped_vertices=add_dropped_vertices, - use_extra_vertex_storage=use_extra_vertex_storage, - extra_vertex_storage=extra_vertex_storage, - callback=callback, - lazy=lazy, - sparsity_control=lazy_tolerance, - timeout=timeout, - verbose=verbose, - ) - return x, primal, dual_gap, active_set + x, _, primal, dual_gap, status, _, active_set = + FrankWolfe.blended_pairwise_conditional_gradient( + f, + grad!, + lmo, + active_set, + line_search=line_search, + epsilon=epsilon, + max_iteration=max_iteration, + add_dropped_vertices=add_dropped_vertices, + use_extra_vertex_storage=use_extra_vertex_storage, + extra_vertex_storage=extra_vertex_storage, + callback=callback, + lazy=lazy, + sparsity_control=lazy_tolerance, + timeout=timeout, + verbose=verbose, + print_iter=print_fw_iter, + ) + return x, primal, dual_gap, status, active_set end Base.print(io::IO, ::BlendedPairwiseConditionalGradient) = @@ -184,22 +243,22 @@ Base.print(io::IO, ::BlendedPairwiseConditionalGradient) = The Decomposition-invariant Frank-Wolfe. """ -struct DecompositionInvariantConditionalGradient <: FrankWolfeVariant +struct DecompositionInvariantConditionalGradient <: DecompositionInvariant use_strong_lazy::Bool - use_DICG_warm_start::Bool + use_warm_start::Bool use_strong_warm_start::Bool build_dicg_start_point::Function end function DecompositionInvariantConditionalGradient(; use_strong_lazy=false, - use_DICG_warm_start=false, + use_warm_start=false, use_strong_warm_start=false, - build_dicg_start_point=trivial_build_dicg_start_point, + build_dicg_start_point=trivial_build_decomposition_invariant_start_iterate, ) return DecompositionInvariantConditionalGradient( use_strong_lazy, - use_DICG_warm_start, + use_warm_start, use_strong_warm_start, build_dicg_start_point, ) @@ -214,9 +273,6 @@ function solve_frank_wolfe( line_search::FrankWolfe.LineSearchMethod=FrankWolfe.Secant(), epsilon=1e-7, max_iteration=10000, - add_dropped_vertices=false, - use_extra_vertex_storage=false, - extra_vertex_storage=nothing, callback=nothing, lazy=false, lazy_tolerance=2.0, @@ -225,35 +281,27 @@ function solve_frank_wolfe( workspace=nothing, pre_computed_set=nothing, domain_oracle=_trivial_domain, + print_fw_iter=1000, + decomposition_invariant_starting_point=nothing, kwargs..., ) - # We keep track of computed extreme points by creating logging callback. - function make_callback(pre_computed_set) - return function DICG_callback(state, kwargs...) - if !callback(state, pre_computed_set) - return false - end - return true - end - end - - x0 = dicg_start_point_initialize( + x0 = initialize_decomposition_invariant_starting_point( lmo, active_set, pre_computed_set, frank_wolfe_variant.build_dicg_start_point; + starting_point=decomposition_invariant_starting_point, domain_oracle=domain_oracle, ) - - if x0 === nothing || !domain_oracle(x0) + if x0 === nothing return NaN, Inf, Inf, pre_computed_set - else - @assert is_linear_feasible(lmo, x0) end - DICG_callback = make_callback(pre_computed_set) + # In case of the postprocessing, no callback is provided. + DICG_callback = + callback !== nothing ? make_precomputed_set_callback(callback, pre_computed_set) : nothing - x, _, primal, dual_gap, _ = FrankWolfe.decomposition_invariant_conditional_gradient( + x, _, primal, dual_gap, status, _ = FrankWolfe.decomposition_invariant_conditional_gradient( f, grad!, lmo, @@ -269,26 +317,115 @@ function solve_frank_wolfe( sparsity_control=lazy_tolerance, callback=DICG_callback, extra_vertex_storage=pre_computed_set, + print_iter=print_fw_iter, + ) + cleanup_precomputed_set_after_solve!( + pre_computed_set, + lmo, + x, + frank_wolfe_variant.use_strong_warm_start, ) - if pre_computed_set !== nothing - if frank_wolfe_variant.use_strong_warm_start - indices_to_delete = [] - for idx in eachindex(pre_computed_set) - atom = pre_computed_set[idx] - if !is_inface_feasible(lmo, atom, x) - push!(indices_to_delete, idx) - end - end - deleteat!(pre_computed_set, indices_to_delete) - end - end - return x, primal, dual_gap, pre_computed_set + return x, primal, dual_gap, status, pre_computed_set end Base.print(io::IO, ::DecompositionInvariantConditionalGradient) = print(io, "Decompostion-Invariant-Frank-Wolfe") +""" + BDICG-Frank-Wolfe + +The Decomposition-invariant Frank-Wolfe. + +""" +struct BlendedDecompositionInvariantConditionalGradient <: DecompositionInvariant + use_strong_lazy::Bool + use_warm_start::Bool + use_strong_warm_start::Bool + build_bdicg_start_point::Function +end + +function BlendedDecompositionInvariantConditionalGradient(; + use_strong_lazy=false, + use_warm_start=false, + use_strong_warm_start=false, + build_bdicg_start_point=trivial_build_decomposition_invariant_start_iterate, +) + return BlendedDecompositionInvariantConditionalGradient( + use_strong_lazy, + use_warm_start, + use_strong_warm_start, + build_bdicg_start_point, + ) +end + +function solve_frank_wolfe( + frank_wolfe_variant::BlendedDecompositionInvariantConditionalGradient, + f, + grad!, + lmo, + active_set; + line_search::FrankWolfe.LineSearchMethod=FrankWolfe.Secant(), + epsilon=1e-7, + max_iteration=10000, + callback=nothing, + lazy=false, + lazy_tolerance=2.0, + timeout=Inf, + verbose=false, + workspace=nothing, + pre_computed_set=nothing, + domain_oracle=_trivial_domain, + decomposition_invariant_starting_point=nothing, + kwargs..., +) + x0 = initialize_decomposition_invariant_starting_point( + lmo, + active_set, + pre_computed_set, + frank_wolfe_variant.build_bdicg_start_point; + starting_point=decomposition_invariant_starting_point, + domain_oracle=domain_oracle, + ) + if x0 === nothing + return NaN, Inf, Inf, pre_computed_set + end + + # In case of the postprocessing, no callback is provided. + BDICG_callback = + callback !== nothing ? make_precomputed_set_callback(callback, pre_computed_set) : nothing + + x, _, primal, dual_gap, status, _ = + FrankWolfe.blended_decomposition_invariant_conditional_gradient( + f, + grad!, + lmo, + x0; + line_search=line_search, + epsilon=epsilon, + max_iteration=max_iteration, + verbose=verbose, + timeout=timeout, + lazy=lazy, + use_strong_lazy=frank_wolfe_variant.use_strong_lazy, + linesearch_workspace=workspace, + sparsity_control=lazy_tolerance, + callback=BDICG_callback, + extra_vertex_storage=pre_computed_set, + ) + cleanup_precomputed_set_after_solve!( + pre_computed_set, + lmo, + x, + frank_wolfe_variant.use_strong_warm_start, + ) + + return x, primal, dual_gap, status, pre_computed_set +end + +Base.print(io::IO, ::BlendedDecompositionInvariantConditionalGradient) = + print(io, "Blended-Decompostion-Invariant-Frank-Wolfe") + """ Vanilla-Frank-Wolfe @@ -316,11 +453,12 @@ function solve_frank_wolfe( timeout=Inf, verbose=false, workspace=nothing, + print_fw_iter=1000, kwargs..., ) # If the flag away_steps is set to false, away_frank_wolfe performs Vanilla. # Observe that the lazy flag is only observed if away_steps is set to true, so it can neglected. - x, _, primal, dual_gap, _, active_set = FrankWolfe.away_frank_wolfe( + x, _, primal, dual_gap, status, _, active_set = FrankWolfe.away_frank_wolfe( f, grad!, lmo, @@ -335,8 +473,9 @@ function solve_frank_wolfe( use_extra_vertex_storage=use_extra_vertex_storage, extra_vertex_storage=extra_vertex_storage, verbose=verbose, + print_iter=print_fw_iter, ) - return x, primal, dual_gap, active_set + return x, primal, dual_gap, status, active_set end Base.print(io::IO, ::StandardFrankWolfe) = print(io, "StandardFrank-Wolfe") diff --git a/src/heuristics.jl b/src/heuristics.jl index c0116d4c0..11d8dae08 100644 --- a/src/heuristics.jl +++ b/src/heuristics.jl @@ -43,7 +43,7 @@ end Choose which heuristics to run by rolling a dice. """ function run_heuristics(tree, x, heuristic_list; rng=Random.GLOBAL_RNG) - inner_lmo = tree.root.problem.tlmo.blmo + inner_lmo = tree.root.problem.tlmo.lmo heuristic_lmo = TimeTrackingLMO( inner_lmo, tree.root.problem.integer_variables, @@ -100,7 +100,7 @@ end """ Simple rounding heuristic. """ -function rounding_heuristic(tree::Bonobo.BnBTree, blmo::Boscia.TimeTrackingLMO, x) +function rounding_heuristic(tree::Bonobo.BnBTree, lmo::Boscia.TimeTrackingLMO, x) x_rounded = copy(x) for idx in tree.branching_indices x_rounded[idx] = round(x[idx]) @@ -181,7 +181,7 @@ function probability_rounding( # check for feasibility and boundedness status = check_feasibility(tlmo) - if status == MOI.INFEASIBLE || status == MOI.DUAL_INFEASIBLE + if status == INFEASIBLE || status == UNBOUNDED @debug "LMO state in the probability rounding heuristic: $(status)" # reset LMO to node state build_LMO( @@ -208,7 +208,7 @@ function probability_rounding( line_search=tree.root.options[:line_search], lazy=tree.root.options[:lazy], lazy_tolerance=tree.root.options[:lazy_tolerance], - callback=tree.root.options[:callback], + callback=tree.root.options[:boscia_fw_callback], verbose=tree.root.options[:fw_verbose], ) diff --git a/src/integer_bounds.jl b/src/integer_bounds.jl index 9e2addcb5..7df00dc05 100644 --- a/src/integer_bounds.jl +++ b/src/integer_bounds.jl @@ -14,6 +14,33 @@ end IntegerBounds() = IntegerBounds(Dict{Int,Float64}(), Dict{Int,Float64}()) +function IntegerBounds( + lbs::AbstractVector{<:Real}, + ubs::AbstractVector{<:Real}, + int_vars::AbstractVector{<:Integer}, +) + lower_bounds = Dict{Int,Float64}() + upper_bounds = Dict{Int,Float64}() + for idx in int_vars + lower_bounds[idx] = lbs[idx] + upper_bounds[idx] = ubs[idx] + #push!(lower_bounds, (idx, lbs[idx])) + # push!(upper_bounds, (idx, ubs[idx])) + end + return IntegerBounds(lower_bounds, upper_bounds) +end + +function IntegerBounds(lb::Real, ub::Real, int_vars::AbstractVector{<:Integer}) + lbs = fill(lb, length(int_vars)) + ubs = fill(ub, length(int_vars)) + return IntegerBounds(lbs, ubs, int_vars) +end + +function IntegerBounds(lb::Real, ub::Real, m::Integer) + int_vars = collect(1:m) + return IntegerBounds(lb, ub, int_vars) +end + function Base.push!(ib::IntegerBounds, (idx, bound), sense::Symbol) if sense == :greaterthan ib.lower_bounds[idx] = bound diff --git a/src/interface.jl b/src/interface.jl index bd924e25b..7ca764320 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -1,22 +1,25 @@ # Interface.jl """ - solve(f, g, blmo::BoundedLinearMinimizationOracle; ...) + solve(f, g, lmo::LinearMinimizationOracle; ...) Requires - `f` oracle of the objective function. - `g` oracle of the gradient of the objective -- `blmo` encodes the feasible region and can handle additional bound constraints. This can either be a MIP solver instance (e.g., SCIP) or be a custom type (see `polytope_blmos.jl`). Has to be of type `BoundedLinearMinimizationOracle` (see `blmo_interface.jl`). +- `lmo` encodes the feasible region and can handle additional bound constraints. This can either be a MIP solver instance (e.g., SCIP) or be a custom type (see `polytope_blmos.jl`). Has to be of type `FrankWolfe.LinearMinimizationOracle` (see `blmo_interface.jl`). Returns - `x` the best solution found. -- `tlmo` the BLMO wrapped in a TimeTrackingLMO instance. +- `tlmo` the LMO wrapped in a TimeTrackingLMO instance. - `result` a dictionary containg the statistics like number of nodes, total solving etc. It also contains information for plotting progress plots like the lower and upper bound progress. Optional settings +- `mode` the mode of the algorithm. See the `Boscia.Mode` enum for the available modes. If no mode is provided, the default mode is used. +If a different mode is supplied, default settings will be set according to the chosen mode. +If you want to change the default parameter settings, please provide the mode to the settings constructor! - `settings_bnb` dictionary of settings for the branch-and-bound algorithm. Created via `settings_bnb()`. - `settings_frank_wolfe` dictionary of settings for the Frank-Wolfe algorithm. Created via `settings_frank_wolfe()`. - `settings_tolerances` dictionary of settings for the tolerances. Created via `settings_tolerances()`. @@ -28,30 +31,48 @@ Optional settings function solve( f, grad!, - blmo::BoundedLinearMinimizationOracle; - mode::Mode=DEFAULT_MODE, - settings_bnb=settings_bnb(mode=mode), - settings_frank_wolfe=settings_frank_wolfe(mode=mode), - settings_tolerances=settings_tolerances(mode=mode), - settings_postprocessing=settings_postprocessing(mode=mode), - settings_heuristic=settings_heuristic(mode=mode), - settings_tightening=settings_tightening(mode=mode), - settings_domain=settings_domain(mode=mode), + lmo::FrankWolfe.LinearMinimizationOracle; + settings=create_default_settings(), kwargs..., ) + # For as long as MathOptBLMO is not yet deleted. + if lmo isa MathOptBLMO + println("Convert MathOptBLMO to MathOptLMO") + lmo = convert(MathOptLMO, lmo) + end + if settings.mode[:mode] == SMOOTHING_MODE && settings.smoothing[:generate_smoothing_objective] === nothing + error("generate_smoothing_objective function is required in SMOOTHING_MODE!") + end + if settings.smoothing[:generate_smoothing_objective] !== nothing && settings.mode[:mode] != SMOOTHING_MODE + @warn "generate_smoothing_objective function will only be used in SMOOTHING_MODE!" + end + + build_heuristics(settings.heuristic) options = merge( - settings_bnb, - settings_frank_wolfe, - settings_tolerances, - settings_postprocessing, - settings_heuristic, - settings_tightening, - settings_domain, + settings.mode, + settings.branch_and_bound, + settings.frank_wolfe, + settings.tolerances, + settings.postprocessing, + settings.heuristic, + settings.tightening, + settings.domain, + settings.smoothing, ) merge!(options, Dict(:heu_ncalls => 0)) - if typeof(options[:variant]) == DecompositionInvariantConditionalGradient - if !is_decomposition_invariant_oracle(blmo) - error("DICG within Boscia is not implemented for $(typeof(blmo)).") + if options[:mode] == SMOOTHING_MODE + merge!(options, Dict(:original_objective => f)) + merge!(options, Dict(:sub_grad! => grad!)) + f, grad! = options[:generate_smoothing_objective](options[:smoothing_start]; epsilon=options[:fw_epsilon], node_level=0) + local_opt_x = Float64[] # placeholder: empty until set + local_active_set = FrankWolfe.ActiveSet{Vector{Float64}, Float64, Vector{Float64}}([], [], Float64[]) + merge!(options, Dict(:local_opt_x => local_opt_x)) + merge!(options, Dict(:local_active_set => local_active_set)) + end + + if typeof(options[:variant]) <: DecompositionInvariant + if !is_decomposition_invariant_oracle(lmo) + error("DICG/BDICG within Boscia is not implemented for $(typeof(lmo)).") end end if options[:verbose] @@ -71,15 +92,22 @@ function solve( @printf("\t Relative dual gap tolerance: %e\n", options[:rel_dual_gap]) @printf("\t Frank-Wolfe subproblem tolerance: %e\n", options[:fw_epsilon]) @printf("\t Frank-Wolfe dual gap decay factor: %e\n", options[:dual_gap_decay_factor]) + if options[:mode] == SMOOTHING_MODE + println("\t Smoothing Mode") + println("\t\t Start smoothing parameter: $(options[:smoothing_start])") + println("\t\t Minimum smoothing parameter: $(options[:smoothing_min])") + println("\t\t Smoothing parameter decay factor: $(options[:smoothing_decay])") + println("\t\t Minimum smoothing parameter valid: $(options[:smoothing_min_valid])") + end println("\t Additional kwargs: ", join(keys(kwargs), ",")) end - n, _ = get_list_of_variables(blmo) + n, _ = get_list_of_variables(lmo) integer_variables = Vector{Int}() num_int = 0 num_bin = 0 - for c_idx in get_integer_variables(blmo) + for c_idx in get_integer_variables(lmo) push!(integer_variables, c_idx) num_int += 1 end @@ -93,7 +121,7 @@ function solve( println("\t Number of integer variables: $(num_int)\n") end - global_bounds = build_global_bounds(blmo, integer_variables) + global_bounds = build_global_bounds(lmo, integer_variables) if typeof(options[:domain_oracle]) != typeof(_trivial_domain) && typeof(options[:find_domain_point]) == typeof(_trivial_domain_point) @@ -101,12 +129,12 @@ function solve( end time_ref = Dates.now() - time_lmo = TimeTrackingLMO(blmo, integer_variables, time_ref, Float64(options[:time_limit])) + time_lmo = TimeTrackingLMO(lmo, integer_variables, time_ref, Float64(options[:time_limit])) v = [] if options[:active_set] === nothing direction = collect(1.0:n) - v = compute_extreme_point(blmo, direction) + v = compute_extreme_point(lmo, direction) v[integer_variables] = round.(v[integer_variables]) @assert isfinite(f(v)) options[:active_set] = FrankWolfe.ActiveSet([(1.0, v)]) @@ -114,7 +142,7 @@ function solve( else @assert FrankWolfe.active_set_validate(options[:active_set]) for a in options[:active_set].atoms - @assert is_linear_feasible(blmo, a) + @assert is_linear_feasible(lmo, a) end x = FrankWolfe.compute_active_set_iterate!(options[:active_set]) v = x @@ -122,9 +150,14 @@ function solve( end vertex_storage = FrankWolfe.DeletedVertexStorage(typeof(v)[], 1) + if options[:mode] == SMOOTHING_MODE + options[:local_active_set] = options[:active_set] + options[:local_opt_x] = options[:active_set].x + options[:local_opt_primal] = f(options[:local_opt_x]) + end + pre_computed_set = - if typeof(options[:variant]) == DecompositionInvariantConditionalGradient && - options[:variant].use_DICG_warm_start + if typeof(options[:variant]) <: DecompositionInvariant && options[:variant].use_warm_start [v] else nothing @@ -132,11 +165,10 @@ function solve( m = SimpleOptimizationProblem(f, grad!, n, integer_variables, time_lmo, global_bounds) nodeEx = FrankWolfeNode( - NodeInfo(1, f(v), f(v)), + NodeInfo(1, f(v), f(v), 1), options[:active_set], vertex_storage, IntegerBounds(), - 1, 1e-3, Millisecond(0), 0, @@ -182,7 +214,6 @@ function solve( active_set=options[:active_set], discarded_vertices=vertex_storage, local_bounds=IntegerBounds(), - level=1, fw_dual_gap_limit=options[:fw_epsilon], fw_time=Millisecond(0), global_tightenings=0, @@ -194,6 +225,8 @@ function solve( branched_on=-1, branched_right=false, distance_to_int=0.0, + active_set_size=0, + discarded_set_size=0, ), ) @@ -204,7 +237,7 @@ function solve( ) end # Sanity check that the provided solution is in fact feasible. - @assert is_linear_feasible(blmo, options[:start_solution]) && + @assert is_linear_feasible(lmo, options[:start_solution]) && is_integer_feasible(tree, options[:start_solution]) node = tree.nodes[1] add_new_solution!(tree, node, f(options[:start_solution]), options[:start_solution], :start) @@ -216,6 +249,7 @@ function solve( list_time_cb = Float64[] list_num_nodes_cb = Int[] list_lmo_calls_cb = Int[] + list_lmo_times_acc = Float64[] list_active_set_size_cb = Int[] list_discarded_set_size_cb = Int[] fw_iterations = Int[] @@ -235,6 +269,7 @@ function solve( list_time_cb, list_num_nodes_cb, list_lmo_calls_cb, + list_lmo_times_acc, options[:verbose], fw_iterations, list_active_set_size_cb, @@ -260,10 +295,10 @@ function solve( options[:min_fw_iterations], time_ref, options[:time_limit], - use_DICG=typeof(options[:variant]) == DecompositionInvariantConditionalGradient, + use_DICG=typeof(options[:variant]) <: DecompositionInvariant, ) - tree.root.options[:callback] = fw_callback + tree.root.options[:boscia_fw_callback] = fw_callback tree.root.current_node_id[] = Bonobo.get_next_node(tree, tree.options.traverse_strategy).id Bonobo.optimize!(tree; callback=bnb_callback) @@ -308,15 +343,15 @@ function postsolve(tree, result, time_ref, verbose, max_iteration_post) primal = x !== nothing ? tree.incumbent_solution.objective : Inf status_string = "FIX ME" # should report "feasible", "optimal", "infeasible", "gap tolerance met" - if isempty(tree.nodes) - status_string = "Optimal (tree empty)" - tree.root.problem.solving_stage = OPT_TREE_EMPTY - elseif tree.root.problem.solving_stage == TIME_LIMIT_REACHED + if tree.root.problem.solving_stage == TIME_LIMIT_REACHED status_string = "Time limit reached" elseif tree.root.problem.solving_stage == NODE_LIMIT_REACHED status_string = "Node limit reached" elseif tree.root.problem.solving_stage == USER_STOP status_string = "User defined stop" + elseif isempty(tree.nodes) + status_string = "Optimal (tree empty)" + tree.root.problem.solving_stage = OPT_TREE_EMPTY else status_string = "Optimal (tolerance reached)" tree.root.problem.solving_stage = OPT_GAP_REACHED @@ -331,7 +366,7 @@ function postsolve(tree, result, time_ref, verbose, max_iteration_post) push!(fix_bounds, (i => round(x[i])), :greaterthan) end - free_model(tree.root.problem.tlmo.blmo) + free_model(tree.root.problem.tlmo.lmo) build_LMO( tree.root.problem.tlmo, tree.root.problem.integer_variable_bounds, @@ -350,16 +385,22 @@ function postsolve(tree, result, time_ref, verbose, max_iteration_post) v = compute_extreme_point(tree.root.problem.tlmo, direction) active_set = FrankWolfe.ActiveSet([(1.0, v)]) verbose && println("Postprocessing") - x, _, primal, dual_gap, _, _ = FrankWolfe.blended_pairwise_conditional_gradient( + x, primal, dual_gap, fw_status, _ = solve_frank_wolfe( + tree.root.options[:variant], tree.root.problem.f, tree.root.problem.g, tree.root.problem.tlmo, active_set, - line_search=FrankWolfe.Adaptive(verbose=false), - lazy=true, - verbose=verbose, - max_iteration=max_iteration_post, + line_search=tree.root.options[:line_search], + lazy=tree.root.options[:lazy_post], + verbose=tree.root.options[:verbose_post] && verbose, + max_iteration=tree.root.options[:max_iteration_post], + epsilon=tree.root.options[:epsilon_post], + timeout=tree.root.options[:timeout_post], ) + if verbose + @show fw_status + end # update tree if primal < tree.incumbent @@ -390,9 +431,11 @@ function postsolve(tree, result, time_ref, verbose, max_iteration_post) result[:rel_dual_gap] = relative_gap(primal, tree_lb(tree)) result[:dual_gap] = tree.incumbent - tree_lb(tree) result[:raw_solution] = x + result[:solution_source] = tree.incumbent_solution.source total_time_in_sec = (Dates.value(Dates.now() - time_ref)) / 1000.0 result[:total_time_in_sec] = total_time_in_sec - result[:status] = status_string + result[:status] = tree.root.problem.solving_stage + result[:status_string] = status_string result[:solving_stage] = tree.root.problem.solving_stage if verbose @@ -401,8 +444,10 @@ function postsolve(tree, result, time_ref, verbose, max_iteration_post) println("Solution Statistics.") println("\t Solution Status: ", status_string) + println("\t Solution Source: ", tree.incumbent_solution.source) println("\t Primal Objective: ", primal) println("\t Dual Bound: ", tree_lb(tree)) + println("\t Absolute Dual Gap: $(primal - tree_lb(tree))") println("\t Dual Gap (relative): $(relative_gap(primal,tree_lb(tree)))\n") println("Search Statistics.") println("\t Total number of nodes processed: ", tree.num_nodes) diff --git a/src/managed_blmo.jl b/src/managed_blmo.jl index 5796120d6..c24cf7a2d 100644 --- a/src/managed_blmo.jl +++ b/src/managed_blmo.jl @@ -1,20 +1,22 @@ """ SimpleBoundableLinearMinimizationOracle -A "simple" BLMO that computes the extreme point given a linear objective and the node specific bounds on the integer variables. -Can be stateless since all of the bound management is done by the `ManagedBoundedLMO`. +A "simple" LMO that computes the extreme point given a linear objective and the node specific bounds on the integer variables. +Can be stateless since all of the bound management is done by the `ManagedBoundedLMO`. + +WILL BE DEPRECATED! """ abstract type SimpleBoundableLMO <: FrankWolfe.LinearMinimizationOracle end """ - bounded_compute_extreme_point + bounded_compute_extreme_point(lmo::LinearMinimizationOracle, d, lb, ub, int_vars; kwargs...) Computes the extreme point given an direction `d`, the current lower and upper bounds on the integer variables, and the set of indices of integer variables. """ function bounded_compute_extreme_point end """ - is_simple_linear_feasible + is_simple_linear_feasible(lmo::LinearMinimizationOracle, v::AbstractVector) Checks whether a given point `v` is satisfying the constraints on the problem. Note that the bounds on the integer variables are being checked by the ManagedBoundedLMO and do not have to be check here. @@ -23,19 +25,20 @@ function is_simple_linear_feasible end """ - ManagedBoundedLMO{SBLMO<:SimpleBoundableLMO} <: BoundedLinearMinimizationOracle + ManagedLMO{LMO<:FrankWolfe.LinearMinimizationOracle} <: FrankWolfe.LinearMinimizationOracle -A Bounded Linear Minimization Oracle that manages the bounds. +A Linear Minimization Oracle wrapper that manages the bounds. -- `simple_lmo` an LMO of type Simple Boundable LMO. +- `lmo` a FrankWolfe.LinearMinimizationOracle. - `lower_bounds` list of lower bounds for the integer variables recorded in `int_vars`. If there is no specific lower bound, set corresponding entry to `-Inf`. - `upper_bounds` list of upper bounds for the integer variables recorded in `int_vars`. If there is no specific upper bound, set corresponding entry to `Inf`. - `n` total number of variables. - `int_vars` list of indices of the integer variables. - `solving_time` the time to evaluate `compute_extreme_point`. """ -mutable struct ManagedBoundedLMO{SBLMO<:SimpleBoundableLMO} <: BoundedLinearMinimizationOracle - simple_lmo::SBLMO +mutable struct ManagedLMO{LMO<:FrankWolfe.LinearMinimizationOracle} <: + FrankWolfe.LinearMinimizationOracle + lmo::LMO lower_bounds::Vector{Float64} upper_bounds::Vector{Float64} int_vars::Vector{Int} @@ -43,86 +46,87 @@ mutable struct ManagedBoundedLMO{SBLMO<:SimpleBoundableLMO} <: BoundedLinearMini solving_time::Float64 end -function ManagedBoundedLMO(simple_lmo, lb, ub, int_vars::Vector{Int}, n::Int) +# Alias for backwards compatibility +const ManagedBoundedLMO = ManagedLMO + +function ManagedLMO(lmo, lb, ub, int_vars::Vector{Int}, n::Int) if length(lb) != length(ub) || length(ub) != length(int_vars) || length(lb) != length(int_vars) error( "Supply lower and upper bounds for all integer variables. If there are no explicit bounds, set entry to Inf and -Inf, respectively. The entries have to match the entries of int_vars!", ) end # Check that we have integer bounds - for (i, _) in enumerate(int_vars) + for i in eachindex(int_vars) @assert isapprox(lb[i], round(lb[i]), atol=1e-6, rtol=1e-2) @assert isapprox(ub[i], round(ub[i]), atol=1e-6, rtol=1e-2) end - return ManagedBoundedLMO(simple_lmo, lb, ub, int_vars, n, 0.0) + return ManagedLMO(lmo, lb, ub, int_vars, n, 0.0) end -#ManagedBoundedLMO(simple_lmo, lb, ub, n, int_vars) = ManagedBoundedLMO(simple_lmo, lb, ub, n, int_vars, 0.0) - # Overload FrankWolfe.compute_extreme_point -function compute_extreme_point(blmo::ManagedBoundedLMO, d; kwargs...) - time_ref = Dates.now() +function compute_extreme_point(managed_lmo::ManagedLMO, d; kwargs...) + t0_ns = time_ns() v = bounded_compute_extreme_point( - blmo.simple_lmo, + managed_lmo.lmo, d, - blmo.lower_bounds, - blmo.upper_bounds, - blmo.int_vars, + managed_lmo.lower_bounds, + managed_lmo.upper_bounds, + managed_lmo.int_vars, ) - blmo.solving_time = float(Dates.value(Dates.now() - time_ref)) + managed_lmo.solving_time = (time_ns() - t0_ns) / 1.0e9 return v end -function is_decomposition_invariant_oracle(blmo::ManagedBoundedLMO) - return is_decomposition_invariant_oracle_simple(blmo.simple_lmo) +function is_decomposition_invariant_oracle(managed_lmo::ManagedLMO) + return is_decomposition_invariant_oracle_simple(managed_lmo.lmo) end # Provide FrankWolfe.compute_inface_extreme_point -function compute_inface_extreme_point(blmo::ManagedBoundedLMO, direction, x; kwargs...) - time_ref = Dates.now() +function compute_inface_extreme_point(managed_lmo::ManagedLMO, direction, x; kwargs...) + t0_ns = time_ns() a = bounded_compute_inface_extreme_point( - blmo.simple_lmo, + managed_lmo.lmo, direction, x, - blmo.lower_bounds, - blmo.upper_bounds, - blmo.int_vars, + managed_lmo.lower_bounds, + managed_lmo.upper_bounds, + managed_lmo.int_vars, ) - blmo.solving_time = float(Dates.value(Dates.now() - time_ref)) + managed_lmo.solving_time = (time_ns() - t0_ns) / 1.0e9 return a end # Check if the given point a is on the minimal face of x -function is_inface_feasible(blmo::ManagedBoundedLMO, a, x) +function is_inface_feasible(managed_lmo::ManagedLMO, a, x) return is_simple_inface_feasible( - blmo.simple_lmo, + managed_lmo.lmo, a, x, - blmo.lower_bounds, - blmo.upper_bounds, - blmo.int_vars, + managed_lmo.lower_bounds, + managed_lmo.upper_bounds, + managed_lmo.int_vars, ) end #Provide FrankWolfe.dicg_maximum_step -function dicg_maximum_step(blmo::ManagedBoundedLMO, x, direction; kwargs...) +function dicg_maximum_step(managed_lmo::ManagedLMO, direction, x; kwargs...) return bounded_dicg_maximum_step( - blmo.simple_lmo, - x, + managed_lmo.lmo, direction, - blmo.lower_bounds, - blmo.upper_bounds, - blmo.int_vars, + x, + managed_lmo.lower_bounds, + managed_lmo.upper_bounds, + managed_lmo.int_vars, ) end # Read global bounds from the problem. -function build_global_bounds(blmo::ManagedBoundedLMO, integer_variables) +function build_global_bounds(managed_lmo::ManagedLMO, integer_variables) global_bounds = IntegerBounds() - for (idx, int_var) in enumerate(blmo.int_vars) - push!(global_bounds, (int_var, blmo.lower_bounds[idx]), :greaterthan) - push!(global_bounds, (int_var, blmo.upper_bounds[idx]), :lessthan) + for (idx, int_var) in enumerate(managed_lmo.int_vars) + push!(global_bounds, (int_var, managed_lmo.lower_bounds[idx]), :greaterthan) + push!(global_bounds, (int_var, managed_lmo.upper_bounds[idx]), :lessthan) end return global_bounds end @@ -131,36 +135,36 @@ end # Get list of variables indices. # If the problem has n variables, they are expected to contiguous and ordered from 1 to n. -function get_list_of_variables(blmo::ManagedBoundedLMO) - return blmo.n, collect(1:blmo.n) +function get_list_of_variables(managed_lmo::ManagedLMO) + return managed_lmo.n, collect(1:managed_lmo.n) end # Get list of integer variables -function get_integer_variables(blmo::ManagedBoundedLMO) - return blmo.int_vars +function get_integer_variables(managed_lmo::ManagedLMO) + return managed_lmo.int_vars end # Get the index of the integer variable the bound is working on. -function get_int_var(blmo::ManagedBoundedLMO, cidx) - return blmo.int_vars[cidx] +function get_int_var(managed_lmo::ManagedLMO, cidx) + return managed_lmo.int_vars[cidx] end # Get the list of lower bounds. -function get_lower_bound_list(blmo::ManagedBoundedLMO) - return collect(1:length(blmo.lower_bounds)) +function get_lower_bound_list(managed_lmo::ManagedLMO) + return collect(1:length(managed_lmo.lower_bounds)) end # Get the list of upper bounds. -function get_upper_bound_list(blmo::ManagedBoundedLMO) - return collect(1:length(blmo.upper_bounds)) +function get_upper_bound_list(managed_lmo::ManagedLMO) + return collect(1:length(managed_lmo.upper_bounds)) end # Read bound value for c_idx. -function get_bound(blmo::ManagedBoundedLMO, c_idx, sense::Symbol) +function get_bound(managed_lmo::ManagedLMO, c_idx, sense::Symbol) if sense == :lessthan - return blmo.upper_bounds[c_idx] + return managed_lmo.upper_bounds[c_idx] elseif sense == :greaterthan - return blmo.lower_bounds[c_idx] + return managed_lmo.lower_bounds[c_idx] else error("Allowed value for sense are :lessthan and :greaterthan!") end @@ -169,34 +173,34 @@ end ## Changing the bounds constraints. # Change the value of the bound c_idx. -function set_bound!(blmo::ManagedBoundedLMO, c_idx, value, sense::Symbol) +function set_bound!(managed_lmo::ManagedLMO, c_idx, value, sense::Symbol) if sense == :greaterthan - blmo.lower_bounds[c_idx] = value + managed_lmo.lower_bounds[c_idx] = value elseif sense == :lessthan - blmo.upper_bounds[c_idx] = value + managed_lmo.upper_bounds[c_idx] = value else error("Allowed values for sense are :lessthan and :greaterthan.") end end # Delete bounds. -function delete_bounds!(blmo::ManagedBoundedLMO, cons_delete) +function delete_bounds!(managed_lmo::ManagedLMO, cons_delete) for (d_idx, sense) in cons_delete if sense == :greaterthan - blmo.lower_bounds[d_idx] = -Inf + managed_lmo.lower_bounds[d_idx] = -Inf else - blmo.upper_bounds[d_idx] = Inf + managed_lmo.upper_bounds[d_idx] = Inf end end end # Add bound constraint. -function add_bound_constraint!(blmo::ManagedBoundedLMO, key, value, sense::Symbol) - idx = findfirst(x -> x == key, blmo.int_vars) +function add_bound_constraint!(managed_lmo::ManagedLMO, key, value, sense::Symbol) + idx = findfirst(x -> x == key, managed_lmo.int_vars) if sense == :greaterthan - blmo.lower_bounds[idx] = value + managed_lmo.lower_bounds[idx] = value elseif sense == :lessthan - blmo.upper_bounds[idx] = value + managed_lmo.upper_bounds[idx] = value else error("Allowed value of sense are :lessthan and :greaterthan!") end @@ -205,34 +209,35 @@ end ## Checks # Check if the subject of the bound c_idx is an integer variable (recorded in int_vars). -function is_constraint_on_int_var(blmo::ManagedBoundedLMO, c_idx, int_vars) - return blmo.int_vars[c_idx] in int_vars +function is_constraint_on_int_var(managed_lmo::ManagedLMO, c_idx, int_vars) + return managed_lmo.int_vars[c_idx] in int_vars end # To check if there is bound for the variable in the global or node bounds. -function is_bound_in(blmo::ManagedBoundedLMO, c_idx, bounds) - return haskey(bounds, blmo.int_vars[c_idx]) +function is_bound_in(managed_lmo::ManagedLMO, c_idx, bounds) + return haskey(bounds, managed_lmo.int_vars[c_idx]) end # Is a given point v linear feasible for the model? # That means does v satisfy all bounds and other linear constraints? -function is_linear_feasible(blmo::ManagedBoundedLMO, v::AbstractVector) - for (i, int_var) in enumerate(blmo.int_vars) +function is_linear_feasible(managed_lmo::ManagedLMO, v::AbstractVector) + for (i, int_var) in enumerate(managed_lmo.int_vars) if !( - blmo.lower_bounds[i] ≤ v[int_var] + 1e-6 || !(v[int_var] - 1e-6 ≤ blmo.upper_bounds[i]) + managed_lmo.lower_bounds[i] ≤ v[int_var] + 1e-6 || + !(v[int_var] - 1e-6 ≤ managed_lmo.upper_bounds[i]) ) @debug( - "Variable: $(int_var) Vertex entry: $(v[int_var]) Lower bound: $(blmo.lower_bounds[i]) Upper bound: $(blmo.upper_bounds[i]))" + "Variable: $(int_var) Vertex entry: $(v[int_var]) Lower bound: $(managed_lmo.lower_bounds[i]) Upper bound: $(managed_lmo.upper_bounds[i]))" ) return false end end - return is_simple_linear_feasible(blmo.simple_lmo, v) + return is_simple_linear_feasible(managed_lmo.lmo, v) end # Has variable an integer constraint? -function has_integer_constraint(blmo::ManagedBoundedLMO, idx) - return idx in blmo.int_vars +function has_integer_constraint(managed_lmo::ManagedLMO, idx) + return idx in managed_lmo.int_vars end @@ -242,51 +247,51 @@ end # Check if the bounds were set correctly in build_LMO. # Safety check only. -function build_LMO_correct(blmo::ManagedBoundedLMO, node_bounds) +function build_LMO_correct(managed_lmo::ManagedLMO, node_bounds) for key in keys(node_bounds.lower_bounds) - idx = findfirst(x -> x == key, blmo.int_vars) - if idx === nothing || blmo.lower_bounds[idx] != node_bounds[key, :greaterthan] + idx = findfirst(x -> x == key, managed_lmo.int_vars) + if idx === nothing || managed_lmo.lower_bounds[idx] != node_bounds[key, :greaterthan] return false end end for key in keys(node_bounds.upper_bounds) - idx = findfirst(x -> x == key, blmo.int_vars) - if idx === nothing || blmo.upper_bounds[idx] != node_bounds[key, :lessthan] + idx = findfirst(x -> x == key, managed_lmo.int_vars) + if idx === nothing || managed_lmo.upper_bounds[idx] != node_bounds[key, :lessthan] return false end end return true end -function check_feasibility(blmo::ManagedBoundedLMO) - for (lb, ub) in zip(blmo.lower_bounds, blmo.upper_bounds) +function check_feasibility(managed_lmo::ManagedLMO) + for (lb, ub) in zip(managed_lmo.lower_bounds, managed_lmo.upper_bounds) if ub < lb return INFEASIBLE end end return check_feasibility( - blmo.simple_lmo, - blmo.lower_bounds, - blmo.upper_bounds, - blmo.int_vars, - blmo.n, + managed_lmo.lmo, + managed_lmo.lower_bounds, + managed_lmo.upper_bounds, + managed_lmo.int_vars, + managed_lmo.n, ) end -function check_feasibility(simple_lmo::SimpleBoundableLMO, lb, ub, int_vars, n) +function check_feasibility(lmo::FrankWolfe.LinearMinimizationOracle, lb, ub, int_vars, n) return true end # Check whether a split is valid, i.e. the upper and lower on variable vidx are not the same. -function is_valid_split(tree::Bonobo.BnBTree, blmo::ManagedBoundedLMO, vidx::Int) - idx = findfirst(x -> x == vidx, blmo.int_vars) - return blmo.lower_bounds[idx] != blmo.upper_bounds[idx] +function is_valid_split(tree::Bonobo.BnBTree, managed_lmo::ManagedLMO, vidx::Int) + idx = findfirst(x -> x == vidx, managed_lmo.int_vars) + return managed_lmo.lower_bounds[idx] != managed_lmo.upper_bounds[idx] end ## Logs # Get solve time, number of nodes and number of iterations, if applicable. -function get_BLMO_solve_data(blmo::ManagedBoundedLMO) - return blmo.solving_time, 0.0, 0.0 +function get_LMO_solve_data(managed_lmo::ManagedLMO) + return managed_lmo.solving_time, 0.0, 0.0 end # Solve function that just get a SimpleBoundableLMO and builds the corresponding @@ -294,33 +299,14 @@ end function solve( f, grad!, - sblmo::SimpleBoundableLMO, + lmo::FrankWolfe.LinearMinimizationOracle, lower_bounds::Vector{Float64}, upper_bounds::Vector{Float64}, int_vars::Vector{Int}, n::Int; - mode::Mode=DEFAULT_MODE, - settings_bnb=settings_bnb(mode=mode), - settings_frank_wolfe=settings_frank_wolfe(mode=mode), - settings_tolerances=settings_tolerances(mode=mode), - settings_postprocessing=settings_postprocessing(mode=mode), - settings_heuristic=settings_heuristic(mode=mode), - settings_tightening=settings_tightening(mode=mode), - settings_domain=settings_domain(mode=mode), + settings=create_default_settings(), kwargs..., ) - blmo = ManagedBoundedLMO(sblmo, lower_bounds, upper_bounds, int_vars, n) - return solve( - f, - grad!, - blmo, - settings_bnb=settings_bnb, - settings_frank_wolfe=settings_frank_wolfe, - settings_tolerances=settings_tolerances, - settings_postprocessing=settings_postprocessing, - settings_heuristic=settings_heuristic, - settings_tightening=settings_tightening, - settings_domain=settings_domain, - kwargs..., - ) + lmo = ManagedLMO(lmo, lower_bounds, upper_bounds, int_vars, n) + return solve(f, grad!, lmo, settings=settings, kwargs...) end diff --git a/src/node.jl b/src/node.jl index a51d71b6d..ae96498a1 100644 --- a/src/node.jl +++ b/src/node.jl @@ -13,16 +13,17 @@ end Holds the necessary information of every node. This needs to be added by every `AbstractNode` as `std::NodeInfo` -This variant is more flexibel than Bonobo.BnBNodeInfo. +This variant is more flexible than Bonobo.BnBNodeInfo. """ mutable struct NodeInfo{T<:Real} id::Int lb::T ub::T + depth::Int end function Base.convert(::Type{NodeInfo{T}}, std::Bonobo.BnBNodeInfo) where {T<:Real} - return NodeInfo(std.id, T(std.lb), T(std.ub)) + return NodeInfo(std.id, T(std.lb), T(std.ub), std.depth) end """ @@ -35,7 +36,7 @@ abstract type AbstractFrankWolfeNode <: Bonobo.AbstractNode end A node in the branch-and-bound tree storing information for a Frank-Wolfe subproblem. -`std` stores the id, lower and upper bound of the node. +`std` stores the id, lower, upper bound and Depth of the node. `active_set` store the active set structure. `local_bounds` instead of storing the complete LMO, it just stores the bounds specific to THIS node. All other integer bounds are stored in the root. @@ -61,7 +62,6 @@ mutable struct FrankWolfeNode{ active_set::AT discarded_vertices::DVS local_bounds::IB - level::Int fw_dual_gap_limit::Float64 fw_time::Millisecond global_tightenings::Int @@ -73,6 +73,8 @@ mutable struct FrankWolfeNode{ branched_on::Int branched_right::Bool distance_to_int::Float64 + active_set_size::Int + discarded_set_size::Int end @@ -83,7 +85,6 @@ FrankWolfeNode( active_set, discarded_vertices, local_bounds, - level, fw_dual_gap_limit, fw_time, global_tightenings, @@ -96,7 +97,6 @@ FrankWolfeNode( active_set, discarded_vertices, local_bounds, - level, fw_dual_gap_limit, fw_time, global_tightenings, @@ -108,6 +108,8 @@ FrankWolfeNode( -1, false, 0.0, + 0, + 0, ) @@ -119,6 +121,10 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN if !is_valid_split(tree, vidx) error("Splitting on the same index as parent! Abort!") end + + node.active_set_size = length(node.active_set) + node.discarded_set_size = length(node.discarded_vertices.storage) + # get iterate, primal and lower bound x = Bonobo.get_relaxed_values(tree, node) primal = tree.root.problem.f(x) @@ -127,6 +133,11 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN left_distance = x[vidx] - floor(x[vidx]) right_distance = ceil(x[vidx]) - x[vidx] + user_prune_left, user_prune_right = if tree.root.options[:branch_callback] !== nothing + tree.root.options[:branch_callback](tree, node, vidx) + else + false, false + end # In case of strong convexity, check if a child can be pruned prune_left, prune_right = if !tree.root.options[:no_pruning] @@ -136,7 +147,10 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN end #different ways to split active set - if typeof(tree.root.options[:variant]) != DecompositionInvariantConditionalGradient + if isapprox(floor(x[vidx]), ceil(x[vidx])) && tree.root.options[:branching_strategy] == BRANCH_ALL() + active_set_left, active_set_right = node.active_set, node.active_set + pre_computed_set_left, pre_computed_set_right = node.pre_computed_set, node.pre_computed_set + elseif !(typeof(tree.root.options[:variant]) <: DecompositionInvariant) # Keep the same pre_computed_set pre_computed_set_left, pre_computed_set_right = node.pre_computed_set, node.pre_computed_set @@ -159,7 +173,7 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN discarded_set_left, discarded_set_right = split_vertices_set!(node.discarded_vertices, tree, vidx, x, node.local_bounds) - if typeof(tree.root.options[:variant]) != DecompositionInvariantConditionalGradient + if !(typeof(tree.root.options[:variant]) <: DecompositionInvariant) # Sanity check @assert isapprox(sum(active_set_left.weights), 1.0) "sum weights left: $(sum(active_set_left.weights))" @assert sum(active_set_left.weights .< 0) == 0 @@ -198,14 +212,25 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN if haskey(varbounds_right.lower_bounds, vidx) delete!(varbounds_right.lower_bounds, vidx) end - push!(varbounds_left.upper_bounds, (vidx => floor(x[vidx]))) - push!(varbounds_right.lower_bounds, (vidx => ceil(x[vidx]))) + #@show x[vidx] + # @show tree.root.problem.integer_variable_bounds.upper_bounds[vidx] + #new_bound_left, new_bound_right = if isapprox(tree.root.problem.integer_variable_bounds.lower_bounds[vidx], x[vidx]) + # floor(x[vidx]), floor(x[vidx]) + 1 + #elseif isapprox(tree.root.problem.integer_variable_bounds.upper_bounds[vidx], x[vidx]) + # ceil(x[vidx]) - 1, ceil(x[vidx]) + #else + # floor(x[vidx]), ceil(x[vidx]) + #end + new_bound_left = floor(x[vidx]) + new_bound_right = ceil(x[vidx]) + push!(varbounds_left.upper_bounds, (vidx => new_bound_left)) + push!(varbounds_right.lower_bounds, (vidx => new_bound_right)) # compute new dual gap limit fw_dual_gap_limit = tree.root.options[:dual_gap_decay_factor] * node.fw_dual_gap_limit fw_dual_gap_limit = max(fw_dual_gap_limit, tree.root.options[:min_node_fw_epsilon]) - if typeof(tree.root.options[:variant]) != DecompositionInvariantConditionalGradient + if !(typeof(tree.root.options[:variant]) <: DecompositionInvariant) # in case of non trivial domain oracle: Only split if the iterate is still domain feasible x_left = FrankWolfe.compute_active_set_iterate!(active_set_left) x_right = FrankWolfe.compute_active_set_iterate!(active_set_right) @@ -225,7 +250,6 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN active_set=active_set_left, discarded_vertices=discarded_set_left, local_bounds=varbounds_left, - level=(node.level + 1), fw_dual_gap_limit=fw_dual_gap_limit, fw_time=Millisecond(0), global_tightenings=0, @@ -237,12 +261,13 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN branched_on=vidx, branched_right=false, distance_to_int=left_distance, + active_set_size=0, + discarded_set_size=0, ) node_info_right = ( active_set=active_set_right, discarded_vertices=discarded_set_right, local_bounds=varbounds_right, - level=(node.level + 1), fw_dual_gap_limit=fw_dual_gap_limit, fw_time=Millisecond(0), global_tightenings=0, @@ -254,25 +279,33 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN branched_on=vidx, branched_right=true, distance_to_int=right_distance, + active_set_size=0, + discarded_set_size=0, ) domain_right = !isempty(active_set_right) domain_left = !isempty(active_set_left) - nodes = if !prune_left && !prune_right && domain_right && domain_left - [node_info_left, node_info_right] - elseif prune_left - [node_info_right] - elseif prune_right - [node_info_left] - elseif domain_right # x_right in domain - [node_info_right] - elseif domain_left # x_left in domain - [node_info_left] - else - @warn "No childern nodes can be created." - Vector{typeof(node_info_left)}() - end + nodes = + if !prune_left && + !prune_right && + domain_right && + domain_left && + !user_prune_left && + !user_prune_right + [node_info_left, node_info_right] + elseif prune_left || user_prune_left + [node_info_right] + elseif prune_right || user_prune_right + [node_info_left] + elseif domain_right # x_right in domain + [node_info_right] + elseif domain_left # x_left in domain + [node_info_left] + else + @warn "No childern nodes can be created." + Vector{typeof(node_info_left)}() + end return nodes end @@ -321,14 +354,44 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) return NaN, NaN end - if typeof(tree.root.options[:variant]) != DecompositionInvariantConditionalGradient + decomposition_invariant_starting_point = nothing + if !(typeof(tree.root.options[:variant]) <: DecompositionInvariant) # Check feasibility of the iterate - active_set = node.active_set x = FrankWolfe.compute_active_set_iterate!(node.active_set) - @assert is_linear_feasible(tree.root.problem.tlmo, x) - for (_, v) in node.active_set + @debug "initial point x linear feasible: $(is_linear_feasible(tree.root.problem.tlmo, x)) x: $(x)" + if is_linear_feasible(tree.root.problem.tlmo, x) + @assert is_linear_feasible(tree.root.problem.tlmo, x) + for (_, v) in node.active_set + @assert is_linear_feasible(tree.root.problem.tlmo, v) + end + else + @assert tree.root.options[:branching_strategy] == BRANCH_ALL() + grad = similar(x) + v = compute_extreme_point(tree.root.problem.tlmo, grad) + @debug "initial point v linear feasible: $(is_linear_feasible(tree.root.problem.tlmo, v)) v: $(v)" + @debug "local bounds: $(node.local_bounds)" + node.active_set = FrankWolfe.ActiveSet([(1.0, v)]) @assert is_linear_feasible(tree.root.problem.tlmo, v) end + else + if node.id == 1 && tree.root.options[:start_solution] !== nothing + decomposition_invariant_starting_point = tree.root.options[:start_solution] + elseif tree.root.options[:find_domain_point] !== _trivial_domain_point + decomposition_invariant_starting_point = + tree.root.options[:find_domain_point](node.local_bounds) + if decomposition_invariant_starting_point === nothing + @debug "Node $(node.id) is infeasible: no domain-feasible starting point found." + return NaN, NaN + end + end + end + + if tree.root.options[:mode] == SMOOTHING_MODE + μ = max(tree.root.options[:smoothing_start] * (tree.root.options[:smoothing_decay] ^ (node.std.depth - 1)), tree.root.options[:smoothing_min]) + @debug "Smoothing parameter: $(μ)" + f_μ, g_μ = tree.root.options[:generate_smoothing_objective](μ; epsilon=tree.root.options[:fw_epsilon], node_level=node.std.depth) + tree.root.problem.f = f_μ + tree.root.problem.g = g_μ end if tree.root.options[:propagate_bounds] !== nothing @@ -339,7 +402,9 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) time_ref = Dates.now() domain_oracle = tree.root.options[:domain_oracle] - x, primal, dual_gap, atoms_set = solve_frank_wolfe( + @debug "active set: $(node.active_set)" + + x, primal, dual_gap, fw_status, atoms_set = solve_frank_wolfe( tree.root.options[:variant], tree.root.problem.f, tree.root.problem.g, @@ -353,13 +418,60 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) add_dropped_vertices=tree.root.options[:use_shadow_set], use_extra_vertex_storage=tree.root.options[:use_shadow_set], extra_vertex_storage=node.discarded_vertices, - callback=tree.root.options[:callback], + callback=tree.root.options[:boscia_fw_callback], verbose=tree.root.options[:fw_verbose], timeout=tree.root.options[:fw_timeout], pre_computed_set=node.pre_computed_set, domain_oracle=domain_oracle, + print_fw_iter=tree.root.options[:print_fw_iter], + decomposition_invariant_starting_point=decomposition_invariant_starting_point, ) + if tree.root.options[:fw_verbose] + @show fw_status + end + + if tree.root.options[:mode] == SMOOTHING_MODE && is_integer_feasible(tree, x) && tree.root.options[:resolve_integer_solution] + @debug "Smoothed problem has integer solution. Tightening smoothing parameter to verify." + @debug "x: $(x)\n primal: $(primal) dual_gap: $(dual_gap) smoothing parameter: $(tree.root.options[:smoothing_start] * (tree.root.options[:smoothing_decay] ^ (node.std.depth - 1)))" + #μ = max(tree.root.options[:smoothing_start] * (tree.root.options[:smoothing_decay] ^ (node.std.depth + 10)), tree.root.options[:smoothing_min]) + μ = tree.root.options[:smoothing_start] * (tree.root.options[:smoothing_decay] ^ (node.std.depth + 10)) + if tree.root.options[:clip_mu_resolution] + μ = max(μ, tree.root.options[:smoothing_min]) + end + @debug "New smoothing parameter: $(μ)" + f_μ, g_μ = tree.root.options[:generate_smoothing_objective](μ; epsilon=tree.root.options[:fw_epsilon], node_level=node.std.depth) + tree.root.problem.f = f_μ + tree.root.problem.g = g_μ + + #v = compute_extreme_point(tree.root.problem.tlmo, x) + #active_set = FrankWolfe.ActiveSet([(1.0, v)]) + #@debug "v: $(v)" + + x, primal, dual_gap, fw_status, atoms_set = solve_frank_wolfe( + tree.root.options[:variant], + tree.root.problem.f, + tree.root.problem.g, + tree.root.problem.tlmo, + node.active_set; + epsilon=node.fw_dual_gap_limit, + max_iteration=tree.root.options[:max_restart_fw_iter], + line_search=tree.root.options[:line_search], + lazy=tree.root.options[:lazy], + lazy_tolerance=tree.root.options[:lazy_tolerance], + add_dropped_vertices=tree.root.options[:use_shadow_set], + use_extra_vertex_storage=tree.root.options[:use_shadow_set], + extra_vertex_storage=node.discarded_vertices, + callback=tree.root.options[:boscia_fw_callback], + verbose=tree.root.options[:fw_verbose], + timeout=tree.root.options[:fw_timeout], + pre_computed_set=node.pre_computed_set, + domain_oracle=domain_oracle, + print_fw_iter=tree.root.options[:print_fw_iter], + ) + @debug "x: $(x)" + end + if typeof(atoms_set).name.wrapper == FrankWolfe.ActiveSet # update active set of the node node.active_set = atoms_set @@ -376,6 +488,39 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) end end + if tree.root.options[:mode] == SMOOTHING_MODE && tree.root.options[:use_sub_grad_info] + if tree.root.options[:node_callback] !== nothing + tree.root.options[:node_callback](tree, node, μ, x, primal, dual_gap, fw_status, atoms_set) + end + original_primal = tree.root.options[:original_objective](x) + @assert primal <= original_primal + 1e-10 "primal = $(primal) > original_primal + 1e-10 = $(original_primal + 1e-10)" + if original_primal > tree.root.options[:local_opt_primal] && tree.root.options[:best_sol_by_original] + x = tree.root.options[:local_opt_x] + if tree.root.options[:variant] isa DecompositionInvariantConditionalGradient + node.pre_computed_set = tree.root.options[:local_active_set] + else + node.active_set = tree.root.options[:local_active_set] + end + original_primal = tree.root.options[:local_opt_primal] + end + sub_grad = [] + tree.root.options[:sub_grad!](sub_grad, x) + min_dual_gap = Inf + for i in eachindex(sub_grad) + v_sub = compute_extreme_point(tree.root.problem.tlmo, sub_grad[i]) + dual_gap_sub = dot(sub_grad[i], x - v_sub) + min_dual_gap = min(min_dual_gap, dual_gap_sub) + end + #v_sub = compute_extreme_point(tree.root.problem.tlmo, sub_grad) + # dual_gap = dot(sub_grad, x - v_sub) + @debug "original_primal: $(original_primal) min_dual_gap: $(min_dual_gap) primal: $(primal) dual_gap: $(dual_gap)" + if original_primal - min_dual_gap > primal - dual_gap || !isfinite(dual_gap) + dual_gap = min_dual_gap + primal = original_primal + end + @assert isfinite(dual_gap) "dual_gap is not finite: $(dual_gap)" + end + node.fw_time = Dates.now() - time_ref node.dual_gap = dual_gap @@ -387,9 +532,13 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) global_tightening(tree, node) lower_bound = primal - dual_gap + # tighten the lower bound if the objective is always integral + lower_bound = tree.root.options[:integral_objective] ? ceil(lower_bound) : lower_bound # improvement of the lower bound using strong convexity lower_bound = tightening_lowerbound(tree, node, x, lower_bound) + @assert isfinite(lower_bound) "lower_bound is not finite: $(lower_bound)" + # Call heuristic run_heuristics(tree, x, tree.root.options[:heuristics]) diff --git a/src/polytope_blmos.jl b/src/polytope_blmos.jl index 7660b0a55..c44a624b5 100644 --- a/src/polytope_blmos.jl +++ b/src/polytope_blmos.jl @@ -1,37 +1,129 @@ +#### CubeLMO #### """ - CubeSimpleBLMO{T}(lower_bounds, upper_bounds) + CubeSimpleLMO{T}(lower_bounds, upper_bounds, int_vars) Hypercube with lower and upper bounds implementing the `SimpleBoundableLMO` interface. """ -struct CubeSimpleBLMO <: SimpleBoundableLMO +struct CubeLMO <: FrankWolfe.LinearMinimizationOracle lower_bounds::Vector{Float64} upper_bounds::Vector{Float64} int_vars::Vector{Int} end +const CubeSimpleBLMO = CubeLMO +Base.@deprecate_binding CubeSimpleBLMO CubeLMO + +function bounded_compute_extreme_point(lmo::CubeLMO, d, lb, ub, int_vars; kwargs...) + v = zeros(length(d)) + for i in eachindex(d) + if i in int_vars + idx = findfirst(x -> x == i, int_vars) + v[i] = d[i] > 0 ? lb[idx] : ub[idx] + else + v[i] = d[i] > 0 ? lmo.lower_bounds[i] : lmo.upper_bounds[i] + end + end + return v +end + +function is_simple_linear_feasible(lmo::CubeLMO, v) + for i in eachindex(v) + if !(lmo.lower_bounds[i] ≤ v[i] + 1e-6 || !(v[i] - 1e-6 ≤ lmo.upper_bounds[i])) + return false + end + end + return true +end + +function is_decomposition_invariant_oracle_simple(lmo::CubeLMO) + return true +end + +function is_simple_inface_feasible(lmo::CubeLMO, a, x, lb, ub, int_vars; kwargs...) + return is_simple_inface_feasible_subroutine(lmo, a, x, lb, ub, int_vars; kwargs) +end + +function bounded_compute_inface_extreme_point(lmo::CubeLMO, d, x, lb, ub, int_vars; kwargs...) + a = zeros(length(d)) + for i in eachindex(d) + if i in int_vars + idx = findfirst(x -> x == i, int_vars) + if isapprox(x[i], ub[idx]; atol=atol, rtol=rtol) + a[i] = ub[idx] + elseif isapprox(x[i], lb[idx]; atol=atol, rtol=rtol) + a[i] = lb[idx] + else + a[i] = d[i] > 0 ? lb[idx] : ub[idx] + end + else + if isapprox(x[i], lmo.upper_bounds[i]; atol=atol, rtol=rtol) + a[i] = lmo.upper_bounds[i] + elseif isapprox(x[i], lmo.lower_bounds[i]; atol=atol, rtol=rtol) + a[i] = lmo.lower_bounds[i] + else + a[i] = d[i] > 0 ? lmo.lower_bounds[i] : lmo.upper_bounds[i] + end + end + end + return a +end + +function bounded_dicg_maximum_step(lmo::CubeLMO, direction, x, lb, ub, int_vars; kwargs...) + gamma_max = one(eltype(direction)) + for idx in eachindex(x) + di = direction[idx] + if idx in int_vars + i = findfirst(x -> x == idx, int_vars) + if di < 0 + gamma_max = min(gamma_max, (ub[i] - x[idx]) / -di) + elseif di > 0 + gamma_max = min(gamma_max, (x[idx] - lb[i]) / di) + end + else + if di < 0 + gamma_max = min(gamma_max, (lmo.upper_bounds[idx] - x[idx]) / -di) + elseif di > 0 + gamma_max = min(gamma_max, (x[idx] - lmo.lower_bounds[idx]) / di) + end + end + + end + return gamma_max +end + + +#### BoxLMO #### + +Base.@deprecate CubeLMO BoxLMO + """ - bounded_compute_extreme_point(sblmo::CubeSimpleBLMO, d, lb, ub, int_vars; kwargs...) + bounded_compute_extreme_point(lmo::FrankWolfe.BoxLMO, d, lb, ub, int_vars; kwargs...) -If the entry is positve, choose the lower bound. Else, choose the upper bound. +If the entry is positive, choose the lower bound. Else, choose the upper bound. """ -function bounded_compute_extreme_point(sblmo::CubeSimpleBLMO, d, lb, ub, int_vars; kwargs...) +function bounded_compute_extreme_point(lmo::BoxLMO, d, lb, ub, int_vars; kwargs...) v = zeros(length(d)) for i in eachindex(d) if i in int_vars idx = findfirst(x -> x == i, int_vars) v[i] = d[i] > 0 ? lb[idx] : ub[idx] else - v[i] = d[i] > 0 ? sblmo.lower_bounds[i] : sblmo.upper_bounds[i] + v[i] = d[i] > 0 ? lmo.lower_bounds[i] : lmo.upper_bounds[i] end end return v end -function is_simple_linear_feasible(sblmo::CubeSimpleBLMO, v) - for i in setdiff(eachindex(v), sblmo.int_vars) - if !(sblmo.lower_bounds[i] ≤ v[i] + 1e-6 || !(v[i] - 1e-6 ≤ blmo.upper_bounds[i])) +""" + is_simple_linear_feasible(lmo::FrankWolfe.BoxLMO, v) + +Checks if a given point `v` is satisfying the box constraints on the problem. +""" +function is_simple_linear_feasible(lmo::BoxLMO, v) + for i in eachindex(v) # This causes a minor breaking change. + if !(lmo.lower_bounds[i] ≤ v[i] + 1e-6 || !(v[i] - 1e-6 ≤ lmo.upper_bounds[i])) @debug( - "Vertex entry: $(v[i]) Lower bound: $(blmo.bounds[i, :greaterthan]) Upper bound: $(blmo.bounds[i, :lessthan]))" + "Vertex entry: $(v[i]) Lower bound: $(lmo.bounds[i, :greaterthan]) Upper bound: $(lmo.bounds[i, :lessthan]))" ) return false end @@ -39,20 +131,32 @@ function is_simple_linear_feasible(sblmo::CubeSimpleBLMO, v) return true end -function is_simple_inface_feasible(sblmo::CubeSimpleBLMO, a, x, lb, ub, int_vars; kwargs...) - return is_simple_inface_feasible_subroutine(sblmo, a, x, lb, ub, int_vars; kwargs) +""" + is_simple_inface_feasible(lmo::FrankWolfe.BoxLMO, a, x, lb, ub, int_vars; kwargs...) + +Checks if a given point `a` is on the same face of the subproblem constraints as `x`. +""" +function is_simple_inface_feasible(lmo::BoxLMO, a, x, lb, ub, int_vars; kwargs...) + return is_simple_inface_feasible_subroutine(lmo, a, x, lb, ub, int_vars; kwargs) end -function is_decomposition_invariant_oracle_simple(sblmo::CubeSimpleBLMO) +""" + is_decomposition_invariant_oracle_simple(lmo::FrankWolfe.BoxLMO) + +The FrankWolfe.BoxLMO/Boscia.CubeLMO is decomposition invariant. +""" +function is_decomposition_invariant_oracle_simple(lmo::BoxLMO) return true end """ + bounded_compute_inface_extreme_point(lmo::FrankWolfe.BoxLMO, d, x, lb, ub, int_vars; kwargs...) + If the entry in x is at the boundary, choose the corresponding bound. -Otherwise, if the entry in direction is positve, choose the lower bound. Else, choose the upper bound. +Otherwise, if the entry in direction is positive, choose the lower bound. Else, choose the upper bound. """ function bounded_compute_inface_extreme_point( - sblmo::CubeSimpleBLMO, + lmo::BoxLMO, d, x, lb, @@ -74,12 +178,12 @@ function bounded_compute_inface_extreme_point( a[i] = d[i] > 0 ? lb[idx] : ub[idx] end else - if isapprox(x[i], sblmo.upper_bounds[i]; atol=atol, rtol=rtol) - a[i] = sblmo.upper_bounds[i] - elseif isapprox(x[i], sblmo.lower_bounds[i]; atol=atol, rtol=rtol) - a[i] = sblmo.lower_bounds[i] + if isapprox(x[i], lmo.upper_bounds[i]; atol=atol, rtol=rtol) + a[i] = lmo.upper_bounds[i] + elseif isapprox(x[i], lmo.lower_bounds[i]; atol=atol, rtol=rtol) + a[i] = lmo.lower_bounds[i] else - a[i] = d[i] > 0 ? sblmo.lower_bounds[i] : sblmo.upper_bounds[i] + a[i] = d[i] > 0 ? lmo.lower_bounds[i] : lmo.upper_bounds[i] end end end @@ -87,9 +191,11 @@ function bounded_compute_inface_extreme_point( end """ -Compute the maximum step size for each entry and return the minium of all the possible step sizes. + bounded_dicg_maximum_step(lmo::FrankWolfe.BoxLMO, direction, x, lb, ub, int_vars; kwargs...) + +Compute the maximum step size for each entry and return the minimum of all the possible step sizes. """ -function bounded_dicg_maximum_step(sblmo::CubeSimpleBLMO, direction, x, lb, ub, int_vars; kwargs...) +function bounded_dicg_maximum_step(lmo::BoxLMO, direction, x, lb, ub, int_vars; kwargs...) gamma_max = one(eltype(direction)) for idx in eachindex(x) di = direction[idx] @@ -102,9 +208,9 @@ function bounded_dicg_maximum_step(sblmo::CubeSimpleBLMO, direction, x, lb, ub, end else if di < 0 - gamma_max = min(gamma_max, (sblmo.upper_bounds[idx] - x[idx]) / -di) + gamma_max = min(gamma_max, (lmo.upper_bounds[idx] - x[idx]) / -di) elseif di > 0 - gamma_max = min(gamma_max, (x[idx] - sblmo.lower_bounds[idx]) / di) + gamma_max = min(gamma_max, (x[idx] - lmo.lower_bounds[idx]) / di) end end @@ -112,68 +218,199 @@ function bounded_dicg_maximum_step(sblmo::CubeSimpleBLMO, direction, x, lb, ub, return gamma_max end +#### ZeroOneHypercubeLMO #### + """ - ProbablitySimplexSimpleBLMO(N) + bounded_compute_extreme_point(lmo::FrankWolfe.ZeroOneHypercubeLMO, d, lb, ub, int_vars; kwargs...) -The scaled probability simplex with `∑ x = N`. +If the entry is positve, choose the lower bound. Else, choose the upper bound. """ -struct ProbabilitySimplexSimpleBLMO <: SimpleBoundableLMO - N::Float64 +function bounded_compute_extreme_point(lmo::ZeroOneHypercubeLMO, d, lb, ub, int_vars; kwargs...) + v = zeros(length(d)) + for i in eachindex(d) + if i in int_vars + idx = findfirst(x -> x == i, int_vars) + v[i] = d[i] > 0 ? lb[idx] : ub[idx] + else + v[i] = d[i] > 0 ? 0 : 1 + end + end + return v end -function is_decomposition_invariant_oracle_simple(sblmo::ProbabilitySimplexSimpleBLMO) +""" + is_simple_linear_feasible(lmo::FrankWolfe.ZeroOneHypercubeLMO, v) + +Checks if a given point `v` is satisfying the box constraints on the problem. +""" +function is_simple_linear_feasible(lmo::ZeroOneHypercubeLMO, v) + for i in eachindex(v) # This causes a minor breaking change. + if !(0 ≤ v[i] + 1e-6 || !(v[i] - 1e-6 ≤ 1)) + @debug("Vertex entry: $(v[i]) Lower bound: 0 Upper bound: 1") + return false + end + end return true end -function is_simple_inface_feasible( - sblmo::ProbabilitySimplexSimpleBLMO, - a, +""" + is_simple_inface_feasible(lmo::FrankWolfe.ZeroOneHypercubeLMO, a, x, lb, ub, int_vars; kwargs...) + +Checks if a given point `a` is on the same face of the subproblem constraints as `x`. +""" +function is_simple_inface_feasible(lmo::ZeroOneHypercubeLMO, a, x, lb, ub, int_vars; kwargs...) + for i in eachindex(x) + if i in int_vars + idx = findfirst(x -> x == i, int_vars) + if isapprox(x[idx], lb[idx]; atol=atol, rtol=rtol) && + !isapprox(a[i], lb[idx]; atol=atol, rtol=rtol) + return false + elseif isapprox(x[idx], ub[idx]; atol=atol, rtol=rtol) && + !isapprox(a[i], ub[idx]; atol=atol, rtol=rtol) + return false + end + else + if isapprox(x[i], 0; atol=atol, rtol=rtol) && !isapprox(a[i], 0; atol=atol, rtol=rtol) + return false + elseif isapprox(x[i], 1; atol=atol, rtol=rtol) && + !isapprox(a[i], 1; atol=atol, rtol=rtol) + return false + end + end + end + return true +end + +""" + bounded_compute_inface_extreme_point(lmo::FrankWolfe.ZeroOneHypercubeLMO, d, x, lb, ub, int_vars; kwargs...) + +If the entry in x is at the boundary, choose the corresponding bound. +Otherwise, if the entry in direction is positive, choose the lower bound. Else, choose the upper bound. +""" +function bounded_compute_inface_extreme_point( + lmo::ZeroOneHypercubeLMO, + d, x, lb, ub, int_vars; + atol=1e-6, + rtol=1e-4, kwargs..., ) - return is_simple_inface_feasible_subroutine(sblmo, a, x, lb, ub, int_vars; kwargs) + a = zeros(length(d)) + for i in eachindex(d) + if i in int_vars + idx = findfirst(x -> x == i, int_vars) + if isapprox(x[i], ub[idx]; atol=atol, rtol=rtol) + a[i] = ub[idx] + elseif isapprox(x[i], lb[idx]; atol=atol, rtol=rtol) + a[i] = lb[idx] + else + a[i] = d[i] > 0 ? lb[idx] : ub[idx] + end + else + if isapprox(x[i], 1; atol=atol, rtol=rtol) + a[i] = 1 + elseif isapprox(x[i], 0; atol=atol, rtol=rtol) + a[i] = 0 + else + a[i] = d[i] > 0 ? 0 : 1 + end + end + end + return a end """ - bounded_compute_extreme_point(sblmo::ProbabilitySimplexSimpleBLMO, d, lb, ub, int_vars; kwargs...) + bounded_dicg_maximum_step(lmo::FrankWolfe.ZeroOneHypercubeLMO, direction, x, lb, ub, int_vars; kwargs...) -Assign the largest possible values to the entries corresponding to the smallest entries of d. +Compute the maximum step size for each entry and return the minimum of all the possible step sizes. """ -function bounded_compute_extreme_point( - sblmo::ProbabilitySimplexSimpleBLMO, - d, +function bounded_dicg_maximum_step( + lmo::ZeroOneHypercubeLMO, + direction, + x, lb, ub, int_vars; kwargs..., ) + gamma_max = one(eltype(direction)) + for idx in eachindex(x) + di = direction[idx] + if idx in int_vars + i = findfirst(x -> x == idx, int_vars) + if di < 0 + gamma_max = min(gamma_max, (ub[i] - x[idx]) / -di) + elseif di > 0 + gamma_max = min(gamma_max, (x[idx] - lb[i]) / di) + end + else + if di < 0 + gamma_max = min(gamma_max, (1 - x[idx]) / -di) + elseif di > 0 + gamma_max = min(gamma_max, (x[idx] - 0 / di)) + end + end + + end + return gamma_max +end + +#### ProbabilitySimplexLMO #### + +const ProbabilitySimplexSimpleBLMO = ProbabilitySimplexLMO# + +Base.@deprecate_binding ProbabilitySimplexSimpleBLMO ProbabilitySimplexLMO + +function is_decomposition_invariant_oracle_simple(lmo::ProbabilitySimplexLMO) + return true +end + +function is_simple_inface_feasible(lmo::ProbabilitySimplexLMO, a, x, lb, ub, int_vars; kwargs...) + return is_simple_inface_feasible_subroutine(lmo, a, x, lb, ub, int_vars; kwargs) +end + +""" + bounded_compute_extreme_point(lmo::ProbabilitySimplexLMO, d, lb, ub, int_vars; kwargs...) + +Assign the largest possible values to the entries corresponding to the smallest entries of d. +""" +function bounded_compute_extreme_point(lmo::ProbabilitySimplexLMO, d, lb, ub, int_vars; kwargs...) v = zeros(length(d)) indices = collect(1:length(d)) perm = sortperm(d) - # The lower bounds always have to be met. + # Step 1: satisfy integer lower bounds v[int_vars] = lb + # Step 2: distribute remaining N for i in indices[perm] + rem = lmo.right_side - sum(v) + if rem ≤ 1e-10 + break + end + if i in int_vars idx = findfirst(x -> x == i, int_vars) - v[i] += min(ub[idx] - lb[idx], sblmo.N - sum(v)) + add_int = min(ub[idx] - v[i], floor(rem)) # make sure it is int + v[i] += add_int else - v[i] += sblmo.N - sum(v) + v[i] += rem end end + return v end + """ Fix the corresponding entries to the boudary based on the given x. Assign the largest possible values to the unfixed entries corresponding to the smallest entries of d. """ function bounded_compute_inface_extreme_point( - sblmo::ProbabilitySimplexSimpleBLMO, + lmo::ProbabilitySimplexLMO, d, x, lb, @@ -190,9 +427,10 @@ function bounded_compute_inface_extreme_point( for i in indices if i in int_vars - idx = findfirst(x -> x == i, int_vars) + idx = findfirst(==(i), int_vars) if isapprox(x[i], lb[idx]; atol=atol, rtol=rtol) push!(fixed_vars, i) + a[i] = lb[idx] elseif isapprox(x[i], ub[idx]; atol=atol, rtol=rtol) push!(fixed_vars, i) a[i] = ub[idx] @@ -200,15 +438,12 @@ function bounded_compute_inface_extreme_point( else if isapprox(x[i], 0.0; atol=atol, rtol=rtol) push!(fixed_vars, i) - end - if isapprox(x[i], 0.0; atol=atol, rtol=rtol) - push!(fixed_vars, i) - a[i] = sblmo.N + a[i] = 0.0 end end end - if sum(a) == sblmo.N + if isapprox(sum(a), lmo.right_side; atol=atol, rtol=rtol) return a end @@ -216,15 +451,19 @@ function bounded_compute_inface_extreme_point( d_updated = d[non_fixed_idx] perm = sortperm(d_updated) sorted = non_fixed_idx[perm] + rem = lmo.right_side - sum(a) + for i in sorted if i in int_vars idx = findfirst(x -> x == i, int_vars) - a[i] += min(ub[idx] - lb[idx], sblmo.N - sum(a)) + add_int = min(ub[idx] - a[i], floor(rem)) + a[i] += add_int else - a[i] += sblmo.N - sum(a) + a[i] += rem end - if sum(a) == sblmo.N + rem = lmo.right_side - sum(a) + if isapprox(sum(a), lmo.right_side; atol=atol, rtol=rtol) return a end end @@ -236,7 +475,7 @@ end Compute the maximum step size for each entry and return the minium of all the possible step sizes. """ function bounded_dicg_maximum_step( - sblmo::ProbabilitySimplexSimpleBLMO, + lmo::ProbabilitySimplexLMO, direction, x, lb, @@ -250,43 +489,56 @@ function bounded_dicg_maximum_step( for idx in eachindex(x) di = direction[idx] if di > tol - gamma_max = min(gamma_max, (x[idx] - lb[idx]) / di) + if idx in int_vars + int_idx = findfirst(==(idx), int_vars) + gamma_max = min(gamma_max, (x[idx] - lb[int_idx]) / di) + else + gamma_max = min(gamma_max, (x[idx] - 0.0) / di) + end elseif di < -tol - gamma_max = min(gamma_max, (ub[idx] - x[idx]) / -di) + if idx in int_vars + int_idx = findfirst(==(idx), int_vars) + gamma_max = min(gamma_max, (ub[int_idx] - x[idx]) / -di) + else + gamma_max = min(gamma_max, (lmo.right_side - x[idx]) / -di) + end end - if gamma_max == 0.0 + if isapprox(gamma_max, 0.0; atol=tol) return 0.0 end end return gamma_max end -function is_simple_linear_feasible(sblmo::ProbabilitySimplexSimpleBLMO, v) - if sum(v .≥ 0) < length(v) +function is_simple_linear_feasible(lmo::ProbabilitySimplexLMO, v) + if any(v .< -1e-8) @debug "v has negative entries: $(v)" return false end - return isapprox(sum(v), sblmo.N, atol=1e-4, rtol=1e-2) + return isapprox(sum(v), lmo.right_side, atol=1e-4, rtol=1e-2) end -function check_feasibility(sblmo::ProbabilitySimplexSimpleBLMO, lb, ub, int_vars, n) +function check_feasibility(lmo::ProbabilitySimplexLMO, lb, ub, int_vars, n) m = n - length(int_vars) - if sum(lb) ≤ sblmo.N ≤ sum(ub) + m * sblmo.N + if length(int_vars) == n && !isinteger(lmo.right_side) + error("Invalid problem: all variables are integer but the right hand side is non-integer.") + end + if sum(lb) ≤ lmo.right_side ≤ sum(ub) + m * lmo.right_side return OPTIMAL else - INFEASIBLE + return INFEASIBLE end end """ - rounding_hyperplane_heuristic(tree::Bonobo.BnBTree, tlmo::TimeTrackingLMO{ManagedBoundedLMO{ProbabilitySimplexSimpleBLMO}}, x) + rounding_hyperplane_heuristic(tree::Bonobo.BnBTree, tlmo::TimeTrackingLMO{ManagedBoundedLMO{<:ProbabilitySimplexLMO}}, x) Hyperplane-aware rounding for the probability simplex. """ function rounding_hyperplane_heuristic( tree::Bonobo.BnBTree, - tlmo::TimeTrackingLMO{ManagedBoundedLMO{ProbabilitySimplexSimpleBLMO}}, + tlmo::TimeTrackingLMO{<:ManagedBoundedLMO{<:ProbabilitySimplexLMO}}, x, ) z = copy(x) @@ -298,25 +550,25 @@ function rounding_hyperplane_heuristic( return [z], false end - N = tlmo.blmo.simple_lmo.N + N = tlmo.lmo.lmo.right_side non_zero_int = intersect(findall(!iszero, z), tree.branching_indices) cont_z = isempty(setdiff(collect(1:tree.root.problem.nvars), tree.branching_indices)) ? 0 : sum(z[setdiff(collect(1:tree.root.problem.nvars), tree.branching_indices)]) - if cont_z + sum(tlmo.blmo.upper_bounds[non_zero_int]) < N || - cont_z + sum(tlmo.blmo.lower_bounds[non_zero_int]) > N - @debug "No heuristics improvement possible, bounds already reached, N=$(N), maximal possible sum $(cont_z + sum(tlmo.blmo.upperbounds[non_zero_int])), minimal possible sum $(cont_z + sum(tlmo.blmo.lower_bounds[non_zero_int]))" + if cont_z + sum(tlmo.lmo.upper_bounds[non_zero_int]) < N || + cont_z + sum(tlmo.lmo.lower_bounds[non_zero_int]) > N + @debug "No heuristics improvement possible, bounds already reached, N=$(N), maximal possible sum $(cont_z + sum(tlmo.lmo.upper_bounds[non_zero_int])), minimal possible sum $(cont_z + sum(tlmo.lmo.lower_bounds[non_zero_int]))" return [z], true end if sum(z) < N while sum(z) < N - z = add_to_min(z, tlmo.blmo.upper_bounds, tree.branching_indices) + z = add_to_min(z, tlmo.lmo.upper_bounds, tree.branching_indices) end elseif sum(z) > N while sum(z) > N - z = remove_from_max(z, tlmo.blmo.lower_bounds, tree.branching_indices) + z = remove_from_max(z, tlmo.lmo.lower_bounds, tree.branching_indices) end end return [z], false @@ -350,33 +602,31 @@ function remove_from_max(x, lb, int_vars) return x end -""" - UnitSimplexSimpleBLMO(N) +#### UnitSimplexLMO #### -The scaled unit simplex with `∑ x ≤ N`. -""" -struct UnitSimplexSimpleBLMO <: SimpleBoundableLMO - N::Float64 -end +const UnitSimplexSimpleBLMO = UnitSimplexLMO + +Base.@deprecate_binding UnitSimplexSimpleBLMO UnitSimplexLMO -function is_decomposition_invariant_oracle_simple(sblmo::UnitSimplexSimpleBLMO) +function is_decomposition_invariant_oracle_simple(lmo::UnitSimplexLMO) return true end -function is_simple_inface_feasible(sblmo::UnitSimplexSimpleBLMO, a, x, lb, ub, int_vars; kwargs...) - if isapprox(sum(x), N; atol=atol, rtol=rtol) && !isapprox(sum(a), N; atol=atol, rtol=rtol) +function is_simple_inface_feasible(lmo::UnitSimplexLMO, a, x, lb, ub, int_vars; kwargs...) + if isapprox(sum(x), lmo.right_side; atol=atol, rtol=rtol) && + !isapprox(sum(a), lmo.right_side; atol=atol, rtol=rtol) return false end - return is_simple_inface_feasible_subroutine(sblmo, a, x, lb, ub, int_vars; kwargs) + return is_simple_inface_feasible_subroutine(lmo, a, x, lb, ub, int_vars; kwargs) end """ - bounded_compute_extreme_point(sblmo::UnitSimplexSimpleBLMO, d, lb, ub, int_vars; kwargs...) + bounded_compute_extreme_point(lmo::UnitSimplexSimpleLMO, d, lb, ub, int_vars; kwargs...) For all positive entries of d, assign the corresponding lower bound. For non-positive entries, assign largest possible value in increasing order. """ -function bounded_compute_extreme_point(sblmo::UnitSimplexSimpleBLMO, d, lb, ub, int_vars; kwargs...) +function bounded_compute_extreme_point(lmo::UnitSimplexLMO, d, lb, ub, int_vars; kwargs...) v = zeros(length(d)) # The wloer bounds always have to be met. v[int_vars] = lb @@ -390,21 +640,23 @@ function bounded_compute_extreme_point(sblmo::UnitSimplexSimpleBLMO, d, lb, ub, for i in idx_neg[perm] if i in int_vars idx = findfirst(x -> x == i, int_vars) - v[i] += min(ub[idx] - lb[idx], sblmo.N - sum(v)) + v[i] += min(ub[idx] - lb[idx], lmo.right_side - sum(v)) else - v[i] += N - sum(v) + v[i] += lmo.right_side - sum(v) end end return v end + + """ For boundary entries of x, assign the corresponding boudary. For all positive entries of d, assign the corresponding lower bound. For non-positive entries, assign largest possible value in increasing order. """ function bounded_compute_inface_extreme_point( - sblmo::UnitSimplexSimpleBLMO, + lmo::UnitSimplexLMO, d, x, lb, @@ -423,9 +675,10 @@ function bounded_compute_inface_extreme_point( for i in indices if i in int_vars - idx = findfirst(x -> x == i, int_vars) + idx = findfirst(==(i), int_vars) if isapprox(x[i], lb[idx]; atol=atol, rtol=rtol) push!(fixed_vars, i) + a[i] = lb[idx] elseif isapprox(x[i], ub[idx]; atol=atol, rtol=rtol) push!(fixed_vars, i) a[i] = ub[idx] @@ -433,15 +686,12 @@ function bounded_compute_inface_extreme_point( else if isapprox(x[i], 0.0; atol=atol, rtol=rtol) push!(fixed_vars, i) - end - if isapprox(x[i], 0.0; atol=atol, rtol=rtol) - push!(fixed_vars, i) - a[i] = sblmo.N + a[i] = 0.0 end end end - if sum(a) == sblmo.N + if isapprox(sum(a), lmo.right_side; atol=atol, rtol=rtol) return a end @@ -451,15 +701,19 @@ function bounded_compute_inface_extreme_point( perm = sortperm(d_updated[idx_neg]) sorted_neg = idx_neg[perm] sorted = non_fixed_idx[sorted_neg] + rem = lmo.right_side - sum(a) + for i in sorted if i in int_vars idx = findfirst(x -> x == i, int_vars) - a[i] += min(ub[idx] - lb[idx], sblmo.N - sum(a)) + add_int = min(ub[idx] - a[i], floor(rem)) + a[i] += add_int else - a[i] += sblmo.N - sum(a) + a[i] += rem end - if sum(a) == sblmo.N + rem = lmo.right_side - sum(a) + if isapprox(sum(a), lmo.right_side; atol=atol, rtol=rtol) return a end end @@ -472,7 +726,7 @@ Compute the maximum step size for each entry and the sum of entries should satis Return the minium of all the possible step sizes. """ function bounded_dicg_maximum_step( - sblmo::UnitSimplexSimpleBLMO, + lmo::UnitSimplexLMO, direction, x, lb, @@ -486,34 +740,44 @@ function bounded_dicg_maximum_step( for idx in eachindex(x) di = direction[idx] if di > tol - gamma_max = min(gamma_max, (x[idx] - lb[idx]) / di) + if idx in int_vars + int_idx = findfirst(==(idx), int_vars) + gamma_max = min(gamma_max, (x[idx] - lb[int_idx]) / di) + else + gamma_max = min(gamma_max, (x[idx] - 0.0) / di) + end elseif di < -tol - gamma_max = min(gamma_max, (ub[idx] - x[idx]) / -di) + if idx in int_vars + int_idx = findfirst(==(idx), int_vars) + gamma_max = min(gamma_max, (ub[int_idx] - x[idx]) / -di) + else + gamma_max = min(gamma_max, (lmo.right_side - x[idx]) / -di) + end end - if gamma_max == 0.0 + if isapprox(gamma_max, 0.0; atol=tol) return 0.0 end end # the sum of entries should be smaller than N. if sum(direction) < 0.0 - gamma_max = min(gamma_max, (sum(x) - sblmo.N) / sum(direction)) + gamma_max = min(gamma_max, (sum(x) - lmo.right_side) / sum(direction)) end return gamma_max end -function is_simple_linear_feasible(sblmo::UnitSimplexSimpleBLMO, v) +function is_simple_linear_feasible(lmo::UnitSimplexLMO, v) if sum(v .≥ 0) < length(v) @debug "v has negative entries: $(v)" return false end - return sum(v) ≤ sblmo.N + 1e-3 + return sum(v) ≤ lmo.right_side + 1e-3 end -function check_feasibility(sblmo::UnitSimplexSimpleBLMO, lb, ub, int_vars, n) - if sum(lb) ≤ sblmo.N +function check_feasibility(lmo::UnitSimplexLMO, lb, ub, int_vars, n) + if sum(lb) ≤ lmo.right_side return OPTIMAL else INFEASIBLE @@ -521,13 +785,13 @@ function check_feasibility(sblmo::UnitSimplexSimpleBLMO, lb, ub, int_vars, n) end """ - rounding_hyperplane_heuristic(tree::Bonobo.BnBTree, tlmo::TimeTrackingLMO{ManagedBoundedLMO{UnitSimplexSimpleBLMO}}, x) + rounding_hyperplane_heuristic(tree::Bonobo.BnBTree, tlmo::TimeTrackingLMO{ManagedBoundedLMO{FrankWolfe.UnitSimplexLMO}}, x) Hyperplane-aware rounding for the unit simplex. """ function rounding_hyperplane_heuristic( tree::Bonobo.BnBTree, - tlmo::TimeTrackingLMO{ManagedBoundedLMO{UnitSimplexSimpleBLMO}}, + tlmo::TimeTrackingLMO{<:ManagedBoundedLMO{<:UnitSimplexLMO}}, x, ) z = copy(x) @@ -535,28 +799,33 @@ function rounding_hyperplane_heuristic( z[idx] = round(x[idx]) end - N = tlmo.blmo.simple_lmo.N + T = tlmo.lmo.lmo.right_side non_zero_int = intersect(findall(!iszero, z), tree.branching_indices) cont_z = isempty(setdiff(collect(1:tree.root.problem.nvars), tree.branching_indices)) ? 0 : sum(z[setdiff(collect(1:tree.root.problem.nvars), tree.branching_indices)]) - if cont_z + sum(tlmo.blmo.lower_bounds[non_zero_int]) > N - @debug "No heuristics improvement possible, bounds already reached, N=$(N), minimal possible sum $(cont_z + sum(tlmo.blmo.lower_bounds[non_zero_int]))" + if cont_z + sum(tlmo.lmo.lower_bounds[non_zero_int]) > T + @debug "No heuristics improvement possible, bounds already reached, T=$(T), minimal possible sum $(cont_z + sum(tlmo.lmo.lower_bounds[non_zero_int]))" return [z], true end - if sum(z) > N - while sum(z) > N - z = remove_from_max(z, tlmo.blmo.lower_bounds, tree.branching_indices) + if sum(z) > T + while sum(z) > T + z = remove_from_max(z, tlmo.lmo.lower_bounds, tree.branching_indices) end end return [z], false end +""" + is_simple_inface_feasible_subroutine(lmo::FrankWolfe.LinearMinimizationOracle, a, x, lb, ub, int_vars; atol=1e-6, rtol=1e-5, kwargs...) + +Checks if a given point a lies on the same face of a box as the point x. +""" function is_simple_inface_feasible_subroutine( - sblmo::SimpleBoundableLMO, + lmo::FrankWolfe.LinearMinimizationOracle, a, x, lb, @@ -577,11 +846,11 @@ function is_simple_inface_feasible_subroutine( return false end else - if isapprox(x[i], sblmo.lower_bounds[i]; atol=atol, rtol=rtol) && - !isapprox(a[i], sblmo.lower_bounds[i]; atol=atol, rtol=rtol) + if isapprox(x[i], lmo.lower_bounds[i]; atol=atol, rtol=rtol) && + !isapprox(a[i], lmo.lower_bounds[i]; atol=atol, rtol=rtol) return false - elseif isapprox(x[i], sblmo.upper_bounds[i]; atol=atol, rtol=rtol) && - !isapprox(a[i], sblmo.upper_bounds[i]; atol=atol, rtol=rtol) + elseif isapprox(x[i], lmo.upper_bounds[i]; atol=atol, rtol=rtol) && + !isapprox(a[i], lmo.upper_bounds[i]; atol=atol, rtol=rtol) return false end end @@ -590,27 +859,29 @@ function is_simple_inface_feasible_subroutine( end """ - ReverseKnapsackBLMO(N, upper_bounds) + ReverseKnapsackLMO(N, upper_bounds) BLMO denotes the reverse Knapsack constraint: ∑ x ≥ N. We assume x ≥ 0. Explicit upper bounds are needed, otherwise the feasible region is unbounded. """ -struct ReverseKnapsackBLMO <: SimpleBoundableLMO +struct ReverseKnapsackLMO <: FrankWolfe.LinearMinimizationOracle N::Float64 upper_bounds::Vector{Float64} end +const ReverseKnapsackBLMO = ReverseKnapsackLMO + # Have the same upper bounds for all variables -function ReverseKnapsackBLMO(size; N=1.0, upper=1.0) - return ReverseKnapsackBLMO(N, fill(upper, size)) +function ReverseKnapsackLMO(size; N=1.0, upper=1.0) + return ReverseKnapsackLMO(N, fill(upper, size)) end """ Entries corresponding to non positive entries in d, are assigned their upper bound. """ -function bounded_compute_extreme_point(sblmo::ReverseKnapsackBLMO, d, lb, ub, int_vars; kwargs...) - v = copy(sblmo.upper_bounds) +function bounded_compute_extreme_point(lmo::ReverseKnapsackLMO, d, lb, ub, int_vars; kwargs...) + v = copy(lmo.upper_bounds) v[int_vars] = min.(v[int_vars], ub) idx_pos = findall(x -> x > 0, d) @@ -620,33 +891,33 @@ function bounded_compute_extreme_point(sblmo::ReverseKnapsackBLMO, d, lb, ub, in perm = sortperm(d[idx_pos], rev=true) for i in idx_pos[perm] if i in int_vars - v[i] += max(sblmo.N - sum(v), lb[i] - ub[i], -v[i]) + v[i] += max(lmo.N - sum(v), lb[i] - ub[i], -v[i]) else - v[i] += max(N - sum(v), -v[i]) + v[i] += max(lmo.N - sum(v), -v[i]) end end return v end -function is_simple_linear_feasible(sblmo::ReverseKnapsackBLMO, v) +function is_simple_linear_feasible(lmo::ReverseKnapsackLMO, v) if sum(v .≥ 0) < length(v) @debug "v has negative entries: $(v)" return false end - if sum(v .<= sblmo.upper_bounds) < length(v) + if sum(v .<= lmo.upper_bounds) < length(v) @debug begin - idxs = findall(x -> x == 0, v .<= sblmo.upper_bounds) - @info "vertex violates the upper bounds at indices $(idxs), upper bounds: $(sblmo.upper_bounds[idxs]), v: $(v[idxs])" + idxs = findall(x -> x == 0, v .<= lmo.upper_bounds) + @info "vertex violates the upper bounds at indices $(idxs), upper bounds: $(lmo.upper_bounds[idxs]), v: $(v[idxs])" end end - return sum(v) ≥ sblmo.N - 1e-4 + return sum(v) ≥ lmo.N - 1e-4 end -function check_feasibility(sblmo::ReverseKnapsackBLMO, lb, ub, int_vars, n) - u = copy(sblmo.upper_bounds) +function check_feasibility(lmo::ReverseKnapsackLMO, lb, ub, int_vars, n) + u = copy(lmo.upper_bounds) u[int_vars] = min.(u[int_vars], ub) - if sum(u) ≥ sblmo.N + if sum(u) ≥ lmo.N return OPTIMAL else return INFEASIBLE @@ -658,7 +929,7 @@ Hyperplane-aware rounding for the reverse knapsack constraint. """ function rounding_hyperplane_heuristic( tree::Bonobo.BnBTree, - tlmo::TimeTrackingLMO{ManagedBoundedLMO{ReverseKnapsackBLMO}}, + tlmo::TimeTrackingLMO{<:ManagedBoundedLMO{<:ReverseKnapsackLMO}}, x, ) z = copy(x) @@ -666,379 +937,91 @@ function rounding_hyperplane_heuristic( z[idx] = round(x[idx]) end - N = tlmo.blmo.simple_lmo.N + N = tlmo.lmo.lmo.N non_zero_int = intersect(findall(!iszero, z), tree.branching_indices) cont_z = isempty(setdiff(collect(1:tree.root.problem.nvars), tree.branching_indices)) ? 0 : sum(z[setdiff(collect(1:tree.root.problem.nvars), tree.branching_indices)]) - if cont_z + sum(tlmo.blmo.upper_bounds[non_zero_int]) < N - @debug "No heuristics improvement possible, bounds already reached, N=$(N), maximal possible sum $(cont_z + sum(tlmo.blmo.lower_bounds[non_zero_int]))" + if cont_z + sum(tlmo.lmo.upper_bounds[non_zero_int]) < N + @debug "No heuristics improvement possible, bounds already reached, N=$(N), maximal possible sum $(cont_z + sum(tlmo.lmo.upper_bounds[non_zero_int]))" return [z], true end if sum(z) < N while sum(z) < N - z = add_to_min(z, tlmo.blmo.upper_bounds, tree.branching_indices) + z = add_to_min(z, tlmo.lmo.upper_bounds, tree.branching_indices) end end return [z], false end -""" - BirkhoffBLMO - -A simple LMO that computes the extreme point given the node specific bounds on the integer variables. -Can be stateless since all of the bound management is done by the ManagedBoundedLMO. -""" -struct BirkhoffBLMO <: SimpleBoundableLMO - append_by_column::Bool - dim::Int - int_vars::Vector{Int} - atol::Float64 - rtol::Float64 -end - -BirkhoffBLMO(dim, int_vars; append_by_column=true) = - BirkhoffBLMO(append_by_column, dim, int_vars, 1e-6, 1e-3) - -""" -Computes the extreme point given an direction d, the current lower and upper bounds on the integer variables, and the set of integer variables. -""" -function Boscia.bounded_compute_extreme_point(sblmo::BirkhoffBLMO, d, lb, ub, int_vars; kwargs...) - n = sblmo.dim - - if size(d, 2) == 1 - d = sblmo.append_by_column ? reshape(d, (n, n)) : transpose(reshape(d, (n, n))) - end - - fixed_to_one_rows = Int[] - fixed_to_one_cols = Int[] - delete_ub = Int[] - for j in 1:n - for i in 1:n - if lb[(j-1)*n+i] >= 1 - eps() - if sblmo.append_by_column - push!(fixed_to_one_rows, i) - push!(fixed_to_one_cols, j) - append!(delete_ub, union(collect(((j-1)*n+1):(j*n)), collect(i:n:(n^2)))) - else - push!(fixed_to_one_rows, j) - push!(fixed_to_one_cols, i) - append!(delete_ub, union(collect(((i-1)*n+1):(i*n)), collect(j:n:(n^2)))) - end - end - end - end - - sort!(delete_ub) - unique!(delete_ub) - nfixed = length(fixed_to_one_cols) - nreduced = n - nfixed - reducedub = copy(ub) - deleteat!(reducedub, delete_ub) - - # stores the indices of the original matrix that are still in the reduced matrix - index_map_rows = fill(1, nreduced) - index_map_cols = fill(1, nreduced) - idx_in_map_row = 1 - idx_in_map_col = 1 - for orig_idx in 1:n - if orig_idx ∉ fixed_to_one_rows - index_map_rows[idx_in_map_row] = orig_idx - idx_in_map_row += 1 - end - if orig_idx ∉ fixed_to_one_cols - index_map_cols[idx_in_map_col] = orig_idx - idx_in_map_col += 1 - end - end - type = typeof(d[1, 1]) - d2 = ones(Union{type,Missing}, nreduced, nreduced) - for j in 1:nreduced - for i in 1:nreduced - # interdict arc when fixed to zero - if reducedub[(j-1)*nreduced+i] <= eps() - if sblmo.append_by_column - d2[i, j] = missing - else - d2[j, i] = missing - end - else - if sblmo.append_by_column - d2[i, j] = d[index_map_rows[i], index_map_cols[j]] - else - d2[j, i] = d[index_map_rows[j], index_map_cols[i]] - end - end - end - end - m = SparseArrays.spzeros(n, n) - for (i, j) in zip(fixed_to_one_rows, fixed_to_one_cols) - m[i, j] = 1 - end - res_mat = Hungarian.munkres(d2) - (rows, cols, vals) = SparseArrays.findnz(res_mat) - @inbounds for i in eachindex(cols) - m[index_map_rows[rows[i]], index_map_cols[cols[i]]] = (vals[i] == 2) - end +#### UnitHyperSimplex, HyperSimplexLMO #### - m = if sblmo.append_by_column - # Convert sparse matrix to sparse vector by columns - I, J, V = SparseArrays.findnz(m) - linear_indices = (J .- 1) .* n .+ I - SparseArrays.sparsevec(linear_indices, V, n^2) - else - # Convert sparse matrix to sparse vector by rows (transpose first) - mt = SparseArrays.sparse(LinearAlgebra.transpose(m)) - I, J, V = SparseArrays.findnz(mt) - linear_indices = (J .- 1) .* n .+ I - SparseArrays.sparsevec(linear_indices, V, n^2) - end - return m -end - -""" -Computes the inface extreme point given an direction d, x, the current lower and upper bounds on the integer variables, and the set of integer variables. -""" -function Boscia.bounded_compute_inface_extreme_point( - sblmo::BirkhoffBLMO, +function bounded_compute_extreme_point( + lmo::Union{FrankWolfe.UnitHyperSimplexLMO,FrankWolfe.HyperSimplexLMO}, direction, - x, lb, ub, int_vars; kwargs..., ) - n = sblmo.dim - - if size(direction, 2) == 1 - direction = - sblmo.append_by_column ? reshape(direction, (n, n)) : - transpose(reshape(direction, (n, n))) - end - - if size(x, 2) == 1 - x = sblmo.append_by_column ? reshape(x, (n, n)) : transpose(reshape(x, (n, n))) - end - fixed_to_one_rows = Int[] - fixed_to_one_cols = Int[] - delete_ub = Int[] - - for idx in eachindex(int_vars) - if lb[idx] >= 1 - eps() - var_idx = int_vars[idx] - if sblmo.append_by_column - j = ceil(Int, var_idx / n) - i = Int(var_idx - n * (j - 1)) - push!(fixed_to_one_rows, i) - push!(fixed_to_one_cols, j) - append!(delete_ub, union(collect(((j-1)*n+1):(j*n)), collect(i:n:(n^2)))) - else - i = ceil(int, var_idx / n) - j = Int(var_idx - n * (j - 1)) - push!(fixed_to_one_rows, j) - push!(fixed_to_one_cols, i) - append!(delete_ub, union(collect(((i-1)*n+1):(i*n)), collect(j:n:(n^2)))) - end + K = _compute_k_hypersimplex(lmo, direction) + @assert isinteger(lmo.radius) + v = spzeros(length(direction)) + if maximum(lb) > 0 + @assert sum(lb) <= lmo.K + for (idx, i) in enumerate(int_vars) + v[i] = lb[idx] end - end - - for j in 1:n - if j ∉ fixed_to_one_cols - for i in 1:n - if i ∉ fixed_to_one_rows - if x[i, j] >= 1 - eps() - push!(fixed_to_one_rows, i) - push!(fixed_to_one_cols, j) - if sblmo.append_by_column - append!( - delete_ub, - union(collect(((j-1)*n+1):(j*n)), collect(i:n:(n^2))), - ) - else - append!( - delete_ub, - union(collect(((i-1)*n+1):(i*n)), collect(j:n:(n^2))), - ) - end - end - end - end + if sum(lb) >= K + return v end end - - sort!(delete_ub) - unique!(delete_ub) - fixed_to_one_cols = unique!(fixed_to_one_cols) - fixed_to_one_rows = unique!(fixed_to_one_rows) - nfixed = length(fixed_to_one_cols) - nreduced = n - nfixed - reducedub = copy(ub) - reducedintvars = copy(int_vars) - delete_ub_idx = findall(x -> x in delete_ub, int_vars) - deleteat!(reducedub, delete_ub_idx) - deleteat!(reducedintvars, delete_ub_idx) - # stores the indices of the original matrix that are still in the reduced matrix - index_map_rows = fill(1, nreduced) - index_map_cols = fill(1, nreduced) - idx_in_map_row = 1 - idx_in_map_col = 1 - for orig_idx in 1:n - if orig_idx ∉ fixed_to_one_rows - index_map_rows[idx_in_map_row] = orig_idx - idx_in_map_row += 1 + K_remain = Int(K - sum(lb)) + K_indices = sortperm(direction) + for i in K_indices + if K_remain == 0 + break end - if orig_idx ∉ fixed_to_one_cols - index_map_cols[idx_in_map_col] = orig_idx - idx_in_map_col += 1 + if lmo isa FrankWolfe.UnitHyperSimplexLMO && direction[i] >= 0 + break end - end - type = typeof(direction[1, 1]) - d2 = ones(Union{type,Missing}, nreduced, nreduced) - - for j in 1:nreduced - for i in 1:nreduced - idx = (index_map_cols[j] - 1) * n + index_map_rows[i] - if sblmo.append_by_column - if x[index_map_rows[i], index_map_cols[j]] <= eps() - d2[i, j] = missing - else - d2[i, j] = direction[index_map_rows[i], index_map_cols[j]] - end - else - if x[index_map_rows[i], index_map_cols[j]] <= eps() - d2[j, i] = missing - else - d2[j, i] = direction[index_map_rows[j], index_map_cols[i]] - end - end - # interdict arc when fixed to zero - if idx in reducedintvars - reducedub_idx = findfirst(x -> x == idx, reducedintvars) - if reducedub[reducedub_idx] <= eps() - if sblmo.append_by_column - d2[i, j] = missing - else - d2[j, i] = missing - end - end - end + idx = findfirst(==(i), int_vars) + v[i] = if idx === nothing + min(lmo.radius, K_remain) + else + min(lmo.radius, K_remain, ub[idx]) end + K_remain = max(K_remain - v[i], 0) end - - m = SparseArrays.spzeros(n, n) - for (i, j) in zip(fixed_to_one_rows, fixed_to_one_cols) - m[i, j] = 1 - end - res_mat = Hungarian.munkres(d2) - (rows, cols, vals) = SparseArrays.findnz(res_mat) - @inbounds for i in eachindex(cols) - m[index_map_rows[rows[i]], index_map_cols[cols[i]]] = (vals[i] == 2) - end - - m = if sblmo.append_by_column - # Convert sparse matrix to sparse vector by columns - I, J, V = SparseArrays.findnz(m) - linear_indices = (J .- 1) .* n .+ I - SparseArrays.sparsevec(linear_indices, V, n^2) - else - # Convert sparse matrix to sparse vector by rows (transpose first) - mt = SparseArrays.sparse(LinearAlgebra.transpose(m)) - I, J, V = SparseArrays.findnz(mt) - linear_indices = (J .- 1) .* n .+ I - SparseArrays.sparsevec(linear_indices, V, n^2) - end - - return m -end - -""" -LMO-like operation which computes a vertex minimizing in `direction` on the face defined by the current fixings. -Fixings are maintained by the oracle (or deduced from `x` itself). -""" -function bounded_dicg_maximum_step(sblmo::BirkhoffBLMO, direction, x, lb, ub, int_vars; kwargs...) - n = sblmo.dim - - direction = - sblmo.append_by_column ? reshape(direction, (n, n)) : transpose(reshape(direction, (n, n))) - x = sblmo.append_by_column ? reshape(x, (n, n)) : transpose(reshape(x, (n, n))) - return FrankWolfe.dicg_maximum_step(FrankWolfe.BirkhoffPolytopeLMO(), direction, x) -end - -function is_decomposition_invariant_oracle_simple(sblmo::BirkhoffBLMO) - return true -end - -function dicg_split_vertices_set_simple(sblmo::BirkhoffBLMO, x, vidx) - x0_left = copy(x) - x0_right = copy(x) - return x0_left, x0_right + return v end -""" -The sum of each row and column has to be equal to 1. -""" -function is_simple_linear_feasible(sblmo::BirkhoffBLMO, v::AbstractVector) - n = sblmo.dim - for i in 1:n - # append by column ? column sum : row sum - if !isapprox(sum(v[((i-1)*n+1):(i*n)]), 1.0, atol=1e-6, rtol=1e-3) - @debug "Column sum not 1: $(sum(v[((i-1)*n+1):(i*n)]))" - return false - end - # append by column ? row sum : column sum - if !isapprox(sum(v[i:n:(n^2)]), 1.0, atol=1e-6, rtol=1e-3) - @debug "Row sum not 1: $(sum(v[i:n:n^2]))" +function is_simple_linear_feasible(lmo::FrankWolfe.UnitHyperSimplexLMO, v) + if sum(v) > lmo.K + 100eps() + return false + end + for i in eachindex(v) + if v[i] < -100eps() || v[i] > lmo.radius + 100eps() return false end end return true end -function check_feasibility(sblmo::BirkhoffBLMO, lb, ub, int_vars, n) - # For double stochastic matrices, each row and column must sum to 1 - # We check if the bounds allow for feasible assignments - - n0 = Int(sqrt(n)) - # Initialize row and column bound tracking - row_min_sum = zeros(n) # minimum possible sum for each row - row_max_sum = zeros(n) # maximum possible sum for each row - col_min_sum = zeros(n) # minimum possible sum for each column - col_max_sum = zeros(n) # maximum possible sum for each column - - # Process each integer variable - for idx in eachindex(int_vars) - var_idx = int_vars[idx] - - # Convert linear index to (row, col) based on storage format - if sblmo.append_by_column - j = ceil(Int, var_idx / n0) # column index - i = Int(var_idx - n0 * (j - 1)) # row index - else - i = ceil(Int, var_idx / n0) # row index - j = Int(var_idx - n0 * (i - 1)) # column index - end - - # Add bounds to row and column sums - row_min_sum[i] += lb[idx] - row_max_sum[i] += ub[idx] - col_min_sum[j] += lb[idx] - col_max_sum[j] += ub[idx] +function is_simple_linear_feasible(lmo::FrankWolfe.HyperSimplexLMO, v) + if sum(v) ≉ lmo.K + return false end - - # Check feasibility: each row and column must be able to sum to exactly 1 - for i in 1:n0 - # Check row sum constraints - if row_min_sum[i] > 1 + eps() || row_max_sum[i] < 1 - eps() - return INFEASIBLE - end - - # Check column sum constraints - if col_min_sum[i] > 1 + eps() || col_max_sum[i] < 1 - eps() - return INFEASIBLE + for i in eachindex(v) + if v[i] < -100eps() || v[i] > lmo.radius + 100eps() + return false end end - - return OPTIMAL + return true end + +_compute_k_hypersimplex(lmo::FrankWolfe.HyperSimplexLMO, direction) = lmo.K +_compute_k_hypersimplex(lmo::FrankWolfe.UnitHyperSimplexLMO, direction) = + min(lmo.K, length(direction), sum(<(0), direction)) diff --git a/src/problem.jl b/src/problem.jl index 254797b51..717914f6a 100644 --- a/src/problem.jl +++ b/src/problem.jl @@ -79,7 +79,7 @@ end function is_integer_feasible(tree::Bonobo.BnBTree, x::AbstractVector) indicator_feasible = - indicator_present(tree) ? is_indicator_feasible(tree.root.problem.tlmo.blmo.o, x) : true + indicator_present(tree) ? is_indicator_feasible(tree.root.problem.tlmo.lmo.o, x) : true return is_integer_feasible( tree.root.problem.integer_variables, x; @@ -91,10 +91,10 @@ end """ Checks if x is valid for all linear and variable bound constraints """ -is_linear_feasible(lmo::TimeTrackingLMO, v::AbstractVector) = is_linear_feasible(lmo.blmo, v) +is_linear_feasible(lmo::TimeTrackingLMO, v::AbstractVector) = is_linear_feasible(lmo.lmo, v) """ Are indicator constraints present """ -indicator_present(time_lmo::TimeTrackingLMO) = indicator_present(time_lmo.blmo) -indicator_present(tree::Bonobo.BnBTree) = indicator_present(tree.root.problem.tlmo.blmo) +indicator_present(time_lmo::TimeTrackingLMO) = indicator_present(time_lmo.lmo) +indicator_present(tree::Bonobo.BnBTree) = indicator_present(tree.root.problem.tlmo.lmo) diff --git a/src/settings.jl b/src/settings.jl index ab17b5c0d..3e16a01a0 100644 --- a/src/settings.jl +++ b/src/settings.jl @@ -1,3 +1,23 @@ +""" +Create default settings depending on the mode. + +Only requires the mode, if no mode is provided, the default mode is used. +Returns a NamedTuple of dictionaries for the different group of settings. +""" +function create_default_settings(; mode::Mode=Boscia.DEFAULT_MODE) + return ( + branch_and_bound=settings_bnb(mode=mode), + frank_wolfe=settings_frank_wolfe(mode=mode), + tolerances=settings_tolerances(mode=mode), + postprocessing=settings_postprocessing(mode=mode), + heuristic=settings_heuristic(mode=mode), + tightening=settings_tightening(mode=mode), + domain=settings_domain(mode=mode), + smoothing=settings_smoothing(mode=mode), + mode=Dict(:mode => mode), + ) +end + """ settings_bnb(mode::Mode;...) @@ -15,38 +35,59 @@ Available settings: - `traverse_strategy` encodes how to choose the next node for evaluation. By default the node with the best lower bound is picked. - `branching_strategy` fixes the branching strategy. By default, weuse `MOST_INFEASIBLE`, i.e. we branch on the entry which is the farthest away from being an integer. +- `integral_objective` if `true`, allows the lower bound at each node to be rounded up. Per default, this is `false`. - `verbose` if `true`, logs and solution statistics are printed. Per default, this is `false`. -- `node_limit` maximum number of nodes to be evaluated. Per default, there is no limit. -- `time_limit` algorithm will stop if the time limit is reached. Depending on the problem it is possible that no feasible solution has been found yet. On default, there is no time limit. +- `node_limit` maximum number of nodes to be evaluated. In DEFAULT mode, there is no limit. In HEURISTIC mode, the default is set to 1000. +- `time_limit` algorithm will stop if the time limit is reached. Depending on the problem it is possible that no feasible solution has been found yet. In DEFAULT mode, there is no time limit. In HEURISTIC mode, the default is set to 300 seconds (5 minutes). - `print_iter` encodes after how many processed nodes the current node and solution status is printed. The logs are always printed if a new integral solution has been found. Per default, `print_iter` is set to `100``. - `bnb_callback` optional callback function that is called after every node evaluation. It will be called before the Boscia internal callback handling the printing of the logs. It receives the tree, the node and the following keyword arguments: `worse_than_incumbent=false`, `node_infeasible=false`, `lb_update=false`. +- `branch_callback` an optional callback called before branching. Receives the tree, the node and the branching variable index as input. Expected output is a pair of boolean values indicating whether the left and right child should be pruned. `false`indicates prune the child, `true` indicates keep the child. - `no_pruning` if `true`, no pruning of nodes is performed. Per default, nodes are pruned if they have a lower bound which is worse than the best known solution. Per default, this is `true` for the `HEURISTIC` mode and `false` for the `OPTIMAL` mode. - `ignore_lower_bound` if `true`, the lower bound obtain by Frank-Wolfe is ignored and in the logs, only Inf will be printed. Per default, this is `true` for the `HEURISTIC` mode and `false` for the `OPTIMAL` mode. - `start_solution` an initial solution can be provided if known. It will be used as the initial incumbent. -- `use_shadow_set` the shadow set is the set of discarded vertices which is inherited by the children nodes. It is used to avoid recomputing of vertices in case the BLMO is expensive. In case of a cheap BLMO, performance might improve by disabling this option. Per default, this is `true`. +- `use_shadow_set` the shadow set is the set of discarded vertices which is inherited by the children nodes. It is used to avoid recomputing of vertices in case the LMO is expensive. In case of a cheap LMO, performance might improve by disabling this option. Per default, this is `true`. """ -function settings_bnb(; - mode::Mode=Boscia.DEFAULT_MODE, - traverse_strategy=Bonobo.BestFirstSearch(), - branching_strategy=Bonobo.MOST_INFEASIBLE(), - verbose=false, - node_limit=Inf, - time_limit=Inf, - print_iter=100, - bnb_callback=nothing, - no_pruning=mode == HEURISTIC_MODE ? true : false, - ignore_lower_bound=mode == HEURISTIC_MODE ? true : false, - start_solution=nothing, - use_shadow_set=true, -) +function settings_bnb(; mode::Mode=Boscia.DEFAULT_MODE) + traverse_strategy = Bonobo.BestFirstSearch() + branching_strategy = Bonobo.MOST_INFEASIBLE() + integral_objective = false + verbose = false + print_iter = 100 + bnb_callback = nothing + branch_callback = nothing + start_solution = nothing + use_shadow_set = true + node_limit = if mode == HEURISTIC_MODE + 1000 + else + Inf + end + time_limit = if mode == HEURISTIC_MODE + 300 + else + Inf + end + no_pruning = if mode == HEURISTIC_MODE + true + else + false + end + ignore_lower_bound = if mode == HEURISTIC_MODE + true + else + false + end + return Dict( :traverse_strategy => traverse_strategy, :branching_strategy => branching_strategy, + :integral_objective => integral_objective, :verbose => verbose, :node_limit => node_limit, :time_limit => time_limit, :print_iter => print_iter, :bnb_callback => bnb_callback, + :branch_callback => branch_callback, :no_pruning => no_pruning, :ignore_lower_bound => ignore_lower_bound, :start_solution => start_solution, @@ -72,23 +113,24 @@ Available settings: - `variant` the Frank-Wolfe variant to be used to solve the node problem. Options currently available are `AwayFrankWolfe`, `BlendedConditionalGradient`, `BlendedPairwiseConditionalGradient`, `DecompositionInvariantConditionalGradient` and `StandardFrankWolfe`. Per default, this is set to `BlendedPairwiseConditionalGradient`. - `line_search` specifies the line search method used in the FrankWolfe variant. Default is the `FrankWolfe.Secant` line search. For other available types, check the FrankWolfe.jl package. - `max_fw_iter` maximum number of iterations in a Frank-Wolfe run. Per default, this is set to `10000`. -- `fw_timeout` time limit for the Frank-Wolfe runs. Per default, there is no time limit. It is preferred to set the iteration limit but this can be used as a fallback and/or if the BLMO call is time consuming. +- `fw_timeout` time limit for the Frank-Wolfe runs. Per default, there is no time limit. It is preferred to set the iteration limit but this can be used as a fallback and/or if the LMO call is time consuming. - `min_fw_iterations` the minimum number of Frank-Wolfe iterations performed in the node evaluation. Per default, this is set to `5`. - `fw_verbose` if `true`, the Frank-Wolfe logs are printed at each node. Mostly meant for debugging. Per default, this is `false`. - `lazy` flag specifies whether the lazification of the Frank-Wolfe variant should be used. Per default `true`. Note that it has no effect on standard Frank-Wolfe. - `lazy_tolerance` decides how much progress is deemed enough to not have to call the LMO. Only used if the `lazy` flag is activated. Per default, this is set to `2`. """ -function settings_frank_wolfe(; - mode::Mode=Boscia.DEFAULT_MODE, - variant=BlendedPairwiseConditionalGradient(), - line_search=FrankWolfe.Secant(), - max_fw_iter=10000, - fw_timeout=Inf, - min_fw_iterations=5, - fw_verbose=false, - lazy=true, - lazy_tolerance=2, -) +function settings_frank_wolfe(; mode::Mode=Boscia.DEFAULT_MODE) + variant = BlendedPairwiseConditionalGradient() + line_search = FrankWolfe.Secant() + max_fw_iter = 10000 + fw_timeout = Inf + min_fw_iterations = 5 + fw_verbose = false + lazy = true + lazy_tolerance = 2 + print_fw_iter=1000 + fw_callback = nothing + return Dict( :variant => variant, :line_search => line_search, @@ -98,6 +140,8 @@ function settings_frank_wolfe(; :fw_verbose => fw_verbose, :lazy => lazy, :lazy_tolerance => lazy_tolerance, + :print_fw_iter => print_fw_iter, + :fw_callback => fw_callback, ) end @@ -123,15 +167,14 @@ Available settings: - `min_number_lower` if not `Inf`, evaluation of a node is stopped if at least `min_number_lower` open nodes have a better lower bound. Per default, this is set to `Inf`. - `min_node_fw_epsilon` smallest fw epsilon tolerance, see also `dual_gap_decay_factor`. Per default, this is set to `1e-6`. """ -function settings_tolerances(; - mode::Mode=Boscia.DEFAULT_MODE, - fw_epsilon=1e-2, - dual_gap=1e-6, - rel_dual_gap=1.0e-2, - dual_gap_decay_factor=0.8, - min_number_lower=Inf, - min_node_fw_epsilon=1e-6, -) +function settings_tolerances(; mode::Mode=Boscia.DEFAULT_MODE) + fw_epsilon = 1e-2 + dual_gap = 1e-6 + rel_dual_gap = 1.0e-2 + dual_gap_decay_factor = 0.8 + min_number_lower = Inf + min_node_fw_epsilon = 1e-6 + return Dict( :fw_epsilon => fw_epsilon, :dual_gap => dual_gap, @@ -160,12 +203,22 @@ Available settings: - `use_postsolve` if `true`, runs the specified Frank-Wolfe variant on the problem with the integral variables fixed to the solution, i.e. it only optimizes over the continuous variables. This might improve the solution if one has many continuous variables. Per default, this is `true`. - `max_iteration_post` maximum number of iterations in the Frank-Wolfe run during postsolve. Per default, this is set to `10000`. """ -function settings_postprocessing(; - mode::Mode=Boscia.DEFAULT_MODE, - use_postsolve=true, - max_iteration_post=10000, -) - return Dict(:use_postsolve => use_postsolve, :max_iteration_post => max_iteration_post) +function settings_postprocessing(; mode::Mode=Boscia.DEFAULT_MODE) + use_postsolve = true + max_iteration_post = 10000 + epsilon_post = 1e-6 + timeout_post = Inf + lazy_post = true + verbose_post = true + + return Dict( + :use_postsolve => use_postsolve, + :max_iteration_post => max_iteration_post, + :epsilon_post => epsilon_post, + :timeout_post => timeout_post, + :lazy_post => lazy_post, + :verbose_post => verbose_post, + ) end """ @@ -191,20 +244,24 @@ Available settings: - `rounding_lmo_01_prob` the probability for calling the rounding-LMO-01 heuristic. Per default, this is `0.0`. - `probability_rounding_prob` the probability for calling the probability-rounding heuristic. Per default, this is `0.0`. - `hyperplane_aware_rounding_prob` the probability for calling the hyperplane-aware-rounding heuristic. Per default, this is `0.0`. -- `add_all_solutions` if `true`, all solutions found by the heuristics, Frank-Wolfe or the BLMO are added to the tree. Per default, this is `true` for the `HEURISTIC` mode and `false` for the `OPTIMAL` mode. +- `add_all_solutions` if `true`, all solutions found by the heuristics, Frank-Wolfe or the LMO are added to the tree. Per default, this is `true` for the `HEURISTIC` mode and `false` for the `OPTIMAL` mode. """ -function settings_heuristic(; - mode::Mode=Boscia.DEFAULT_MODE, - custom_heuristics=[Heuristic()], - post_heuristics_callback=nothing, - rounding_prob=1.0, - follow_gradient_prob=0.0, - follow_gradient_steps=10, - rounding_lmo_01_prob=0.0, - probability_rounding_prob=0.0, - hyperplane_aware_rounding_prob=0.0, - add_all_solutions=mode == HEURISTIC_MODE ? true : false, -) +function settings_heuristic(; mode::Mode=Boscia.DEFAULT_MODE) + custom_heuristics = [Heuristic()] + post_heuristics_callback = nothing + rounding_prob = 1.0 + follow_gradient_prob = 0.0 + follow_gradient_steps = 10 + rounding_lmo_01_prob = 0.0 + probability_rounding_prob = 0.0 + hyperplane_aware_rounding_prob = 0.0 + add_all_solutions = if mode == HEURISTIC_MODE + true + else + false + end + + round_heu = Heuristic(rounding_heuristic, rounding_prob, :rounding) follow_grad_heu = Heuristic( (tree, tlmo, x) -> follow_gradient_heuristic(tree, tlmo, x, follow_gradient_steps), @@ -233,12 +290,60 @@ function settings_heuristic(; ) return Dict( - :heuristics => heuristics, + :custom_heuristics => custom_heuristics, + :rounding_prob => rounding_prob, + :follow_gradient_prob => follow_gradient_prob, + :follow_gradient_steps => follow_gradient_steps, + :rounding_lmo_01_prob => rounding_lmo_01_prob, + :probability_rounding_prob => probability_rounding_prob, + :hyperplane_aware_rounding_prob => hyperplane_aware_rounding_prob, :post_heuristics_callback => post_heuristics_callback, :add_all_solutions => add_all_solutions, + :heu_ncalls => 0, ) end +function build_heuristics(heuristics_dictionary) + custom_heuristics = heuristics_dictionary[:custom_heuristics] + rounding_prob = heuristics_dictionary[:rounding_prob] + follow_gradient_prob = heuristics_dictionary[:follow_gradient_prob] + follow_gradient_steps = heuristics_dictionary[:follow_gradient_steps] + rounding_lmo_01_prob = heuristics_dictionary[:rounding_lmo_01_prob] + probability_rounding_prob = heuristics_dictionary[:probability_rounding_prob] + hyperplane_aware_rounding_prob = heuristics_dictionary[:hyperplane_aware_rounding_prob] + + round_heu = Heuristic(rounding_heuristic, rounding_prob, :rounding) + follow_grad_heu = Heuristic( + (tree, tlmo, x) -> follow_gradient_heuristic(tree, tlmo, x, follow_gradient_steps), + follow_gradient_prob, + :follow_gradient, + ) + rounding_lmo_01_heu = + Heuristic(rounding_lmo_01_heuristic, rounding_lmo_01_prob, :rounding_lmo_01) + probability_rounding_heu = + Heuristic(probability_rounding, probability_rounding_prob, :probability_rounding) + hyperplane_aware_rounding_heu = Heuristic( + rounding_hyperplane_heuristic, + hyperplane_aware_rounding_prob, + :hyperplane_aware_rounding, + ) + + heuristics = vcat( + [ + round_heu, + follow_grad_heu, + rounding_lmo_01_heu, + probability_rounding_heu, + hyperplane_aware_rounding_heu, + ], + custom_heuristics, + ) + + heuristics_dictionary[:heuristics] = heuristics + + return heuristics +end + """ settings_tightening(mode::Mode;...) @@ -261,16 +366,15 @@ Available settings: - `sharpness_exponent` - the exponent `θ ∈ [0, 1/2]` for `(θ, M)`-sharpness. Per default, this is set to `Inf`. - `propagate_bounds` optional function that allows the user to propagate and tighten bounds depending on the node. Receives the tree and the node as input. """ -function settings_tightening(; - mode::Mode=Boscia.DEFAULT_MODE, - dual_tightening=true, - global_dual_tightening=true, - strong_convexity=0.0, - sharpness_constant=0.0, - sharpness_exponent=Inf, - propagate_bounds=nothing, -) - return Dict( +function settings_tightening(; mode::Mode=Boscia.DEFAULT_MODE) + dual_tightening = true + global_dual_tightening = true + strong_convexity = 0.0 + sharpness_constant = 0.0 + sharpness_exponent = Inf + propagate_bounds = nothing + + return Dict{Symbol,Union{Bool,Float64,Nothing,Function}}( :dual_tightening => dual_tightening, :global_dual_tightening => global_dual_tightening, :strong_convexity => strong_convexity, @@ -295,19 +399,70 @@ Returns: Available settings: +- `mode` the mode of the algorithm. See the `Boscia.Mode` enum for the available modes. If no mode is provided, the default mode is used. - `domain_oracle` given a point `x`: returns `true` if `x` is in the domain of `f`, else false. Per default, it always returns `true`. In case of the non-trivial domain oracle, the initial point has to be domain feasible for `f` and can be set via the `active_set``. Additionally, the user has to provide a function `domain_point`, see below. Also, depending on the line search method, you might have to provide the domain oracle to it, too. The default line search Secant, for example, requires the domain oracle. - `find_domain_point` given the current node bounds return a domain feasible point respecting the bounds. If no such point can be found, return `nothing`. Only necessary for a non-trivial domain oracle. - `active_set` can be used to specify a starting point. By default, the direction (1,..,n) where n is the size of the problem is used to find a start vertex. This has to be of the type `FrankWolfe.ActiveSet`. Beware that the active set may only contain actual vertices of the feasible region. +- `depth_domain` The domain point is used to generate new starting points after branching by solving a projection problem. This parameter is used to control how far we move into the domain. """ -function settings_domain(; - mode::Mode=Boscia.DEFAULT_MODE, - domain_oracle=_trivial_domain, - find_domain_point=_trivial_domain_point, - active_set::Union{Nothing,FrankWolfe.ActiveSet}=nothing, -) - return Dict( +function settings_domain(; mode::Mode=Boscia.DEFAULT_MODE) + domain_oracle = _trivial_domain + find_domain_point = _trivial_domain_point + active_set = nothing + depth_domain = 5 + + return Dict{Symbol,Union{Nothing,Function,FrankWolfe.ActiveSet,Int}}( :domain_oracle => domain_oracle, :find_domain_point => find_domain_point, :active_set => active_set, + :depth_domain => depth_domain, ) end + +""" + settings_smoothing() + +Set the settings for a smoothed objective. +Only important if mode == SMOOTHING_MODE. + +Returns: + +- `Dict` of settings for the smoothing. + +Available settings: + +- `mode` the mode of the algorithm. See the `Boscia.Mode` enum for the available modes. If no mode is provided, the default mode is used. +- `μ_start` the starting value for the smoothing parameter. Per default, this is set to `1.0`. +- `μ_min` the minimum value for the smoothing parameter. Per default, this is set to `1e-3`. +- `μ_decay` the decay factor for the smoothing parameter. Per default, this is set to `0.9`. +- `μ_min_valid` per default `false`. Should only be `true` if `f_μ_min` has the same set of minimizers as `f`. +- `generate_smoothing_objective` function that generates the smoothed objective nd its gradient depending on the `μ` provided. +""" +function settings_smoothing(; mode::Mode=Boscia.DEFAULT_MODE) + smoothing_start = 1.0 + smoothing_min = 1e-3 + smoothing_decay = 0.9 + smoothing_min_valid = false + generate_smoothing_objective = nothing + use_sub_grad_info = false + max_restart_fw_iter = 1000 + best_sol_by_original = false + resolve_integer_solution = false + clip_mu_resolution = false + node_callback = nothing + + return Dict{Symbol,Union{Nothing,Function,Float64,Bool,Int64}}( + :smoothing_start => smoothing_start, + :smoothing_min => smoothing_min, + :smoothing_decay => smoothing_decay, + :smoothing_min_valid => smoothing_min_valid, + :generate_smoothing_objective => generate_smoothing_objective, + :use_sub_grad_info => use_sub_grad_info, + :max_restart_fw_iter => max_restart_fw_iter, + :best_sol_by_original => best_sol_by_original, + :resolve_integer_solution => resolve_integer_solution, + :clip_mu_resolution => clip_mu_resolution, + :node_callback => node_callback, + ) +end + diff --git a/src/strong_branching.jl b/src/strong_branching.jl index f939436c8..f9c2ee5a3 100644 --- a/src/strong_branching.jl +++ b/src/strong_branching.jl @@ -1,9 +1,8 @@ -struct PartialStrongBranching{BLMO<:BoundedLinearMinimizationOracle} <: - Bonobo.AbstractBranchStrategy +struct PartialStrongBranching{LMO<:LinearMinimizationOracle} <: Bonobo.AbstractBranchStrategy max_iteration::Int solving_epsilon::Float64 - bounded_lmo::BLMO + lmo::LMO end """ @@ -12,9 +11,9 @@ Create all possible subproblems, solve them and pick the one with the most progr """ function Bonobo.get_branching_variable( tree::Bonobo.BnBTree, - branching::PartialStrongBranching{BLMO}, + branching::PartialStrongBranching{LMO}, node::Bonobo.AbstractNode, -) where {BLMO<:BoundedLinearMinimizationOracle} +) where {LMO<:LinearMinimizationOracle} xrel = Bonobo.get_relaxed_values(tree, node) max_lowerbound = -Inf max_idx = -1 @@ -34,12 +33,12 @@ function Bonobo.get_branching_variable( end push!(boundsLeft.upper_bounds, (idx => fxi)) build_LMO( - branching.bounded_lmo, + branching.lmo, tree.root.problem.integer_variable_bounds, boundsLeft, Bonobo.get_branching_indices(tree.root), ) - status = check_feasibility(branching.bounded_lmo) + status = check_feasibility(branching.lmo) if status == OPTIMAL empty!(active_set) for (λ, v) in node.active_set @@ -48,17 +47,26 @@ function Bonobo.get_branching_variable( end end @assert !isempty(active_set) - FrankWolfe.active_set_renormalize!(active_set) - _, _, primal_relaxed, dual_gap_relaxed, _ = - FrankWolfe.blended_pairwise_conditional_gradient( - tree.root.problem.f, - tree.root.problem.g, - branching.bounded_lmo, - active_set, - verbose=false, - epsilon=branching.solving_epsilon, - max_iteration=branching.max_iteration, - ) + try + FrankWolfe.active_set_renormalize!(active_set) + _, _, primal_relaxed, dual_gap_relaxed, _, traj_data, _ = + FrankWolfe.blended_pairwise_conditional_gradient( + tree.root.problem.f, + tree.root.problem.g, + branching.lmo, + active_set, + verbose=false, + epsilon=branching.solving_epsilon, + max_iteration=branching.max_iteration, + trajectory=true, + ) + catch e + println(e) + stacktrace(e) + print(branching.lmo.o) + print(traj_data[1][end]) + error("MOI LMO failed in the strong branching step") + end left_relaxed = primal_relaxed - dual_gap_relaxed else @debug "Left non-optimal status $(status)" @@ -73,12 +81,12 @@ function Bonobo.get_branching_variable( end push!(boundsRight.lower_bounds, (idx => cxi)) build_LMO( - branching.bounded_lmo, + branching.lmo, tree.root.problem.integer_variable_bounds, boundsRight, Bonobo.get_branching_indices(tree.root), ) - status = check_feasibility(branching.bounded_lmo) + status = check_feasibility(branching.lmo) if status == OPTIMAL empty!(active_set) for (λ, v) in node.active_set @@ -92,17 +100,26 @@ function Bonobo.get_branching_variable( @info [active_set.atoms[idx] for idx in eachindex(active_set)] error("Empty active set, unreachable") end - FrankWolfe.active_set_renormalize!(active_set) - _, _, primal_relaxed, dual_gap_relaxed, _ = - FrankWolfe.blended_pairwise_conditional_gradient( - tree.root.problem.f, - tree.root.problem.g, - branching.bounded_lmo, - active_set, - verbose=false, - epsilon=branching.solving_epsilon, - max_iteration=branching.max_iteration, - ) + try + FrankWolfe.active_set_renormalize!(active_set) + _, _, primal_relaxed, dual_gap_relaxed, _, traj_data, _ = + FrankWolfe.blended_pairwise_conditional_gradient( + tree.root.problem.f, + tree.root.problem.g, + branching.lmo, + active_set, + verbose=false, + epsilon=branching.solving_epsilon, + max_iteration=branching.max_iteration, + trajectory=true, + ) + catch e + println(e) + stacktrace(e) + print(branching.lmo.o) + print(traj_data[1][end]) + error("MOI LMO failed in the strong branching step") + end right_relaxed = primal_relaxed - dual_gap_relaxed else @debug "Right non-optimal status $(status)" @@ -127,7 +144,7 @@ function Bonobo.get_branching_variable( end # reset LMO build_LMO( - branching.bounded_lmo, + branching.lmo, tree.root.problem.integer_variable_bounds, node.local_bounds, Bonobo.get_branching_indices(tree.root), @@ -140,11 +157,11 @@ Hybrid between partial strong branching and another strategy. `perform_strong_branch(tree, node) -> Bool` decides whether to perform strong branching or not. """ struct HybridStrongBranching{ - BLMO<:BoundedLinearMinimizationOracle, + LMO<:LinearMinimizationOracle, F<:Function, B<:Bonobo.AbstractBranchStrategy, } <: Bonobo.AbstractBranchStrategy - pstrong::PartialStrongBranching{BLMO} + pstrong::PartialStrongBranching{LMO} perform_strong_branch::F alternative_branching::B end @@ -152,12 +169,12 @@ end function HybridStrongBranching( max_iteration::Int, solving_epsilon::Float64, - bounded_lmo::BoundedLinearMinimizationOracle, + lmo::LinearMinimizationOracle, perform_strong_branch::Function, alternative=Bonobo.MOST_INFEASIBLE(), ) return HybridStrongBranching( - PartialStrongBranching(max_iteration, solving_epsilon, bounded_lmo), + PartialStrongBranching(max_iteration, solving_epsilon, lmo), perform_strong_branch, alternative, ) @@ -182,13 +199,13 @@ strong_up_to_depth performs strong branching on nodes up to a predetermined dept function strong_up_to_depth( max_iteration::Int, solving_epsilon::Float64, - bounded_lmo::BoundedLinearMinimizationOracle, + lmo::LinearMinimizationOracle, max_depth::Int, alternative=Bonobo.MOST_INFEASIBLE(), ) - perform_strong_while_depth(_, node) = node.level <= max_depth + perform_strong_while_depth(_, node) = node.std.depth <= max_depth return HybridStrongBranching( - PartialStrongBranching(max_iteration, solving_epsilon, bounded_lmo), + PartialStrongBranching(max_iteration, solving_epsilon, lmo), perform_strong_while_depth, alternative, ) diff --git a/src/tightenings.jl b/src/tightenings.jl index b72193861..12fff1006 100644 --- a/src/tightenings.jl +++ b/src/tightenings.jl @@ -5,6 +5,12 @@ function dual_tightening(tree, node, x, dual_gap) if tree.root.options[:dual_tightening] && isfinite(tree.incumbent) grad = similar(x) tree.root.problem.g(grad, x) + gradients = [grad] + if tree.root.options[:use_sub_grad_info] && tree.root.options[:mode] == SMOOTHING_MODE + sub_grad = [] + tree.root.options[:sub_grad!](sub_grad, x) + gradients = vcat(gradients, sub_grad) + end num_tightenings = 0 num_potential_tightenings = 0 μ = tree.root.options[:strong_convexity] @@ -30,7 +36,8 @@ function dual_tightening(tree, node, x, dual_gap) # variable already fixed continue end - gj = grad[j] + grads_j = [gradients[i][j] for i in eachindex(gradients)] + gj = argmax(abs, grads_j) if ≈(x[j], lb, atol=tree.options.atol, rtol=tree.options.rtol) if !isapprox(gj, 0, atol=1e-5) num_potential_tightenings += 1 @@ -225,7 +232,7 @@ function tightening_lowerbound(tree, node, x, lower_bound) fx = tree.root.problem.f(x) if node.dual_gap < 0.0 - @assert abs(node.dual_gap) > eps() "node dual gap is negative: $(node.dual_gap)" + @assert abs(node.dual_gap) < sqrt(eps()) "node dual gap is negative: $(node.dual_gap)" node.dual_gap = 0.0 end diff --git a/src/time_tracking_lmo.jl b/src/time_tracking_lmo.jl index f70dc3ebb..ae5bae7b9 100644 --- a/src/time_tracking_lmo.jl +++ b/src/time_tracking_lmo.jl @@ -1,12 +1,12 @@ """ - TimeTrackingLMO{BLMO<:BoundedLinearMinimizationOracle} <: FrankWolfe.LinearMinimizationOracle + TimeTrackingLMO{LMO<:LinearMinimizationOracle} <: FrankWolfe.LinearMinimizationOracle A wrapper for the BLMO tracking the solving time, number of calls etc. Is created in Boscia itself. """ -mutable struct TimeTrackingLMO{BLMO<:BoundedLinearMinimizationOracle,D<:Dates.DateTime} <: +mutable struct TimeTrackingLMO{LMO<:LinearMinimizationOracle,D<:Dates.DateTime} <: FrankWolfe.LinearMinimizationOracle - blmo::BLMO + lmo::LMO optimizing_times::Vector{Float64} optimizing_nodes::Vector{Int} simplex_iterations::Vector{Int} @@ -18,70 +18,82 @@ mutable struct TimeTrackingLMO{BLMO<:BoundedLinearMinimizationOracle,D<:Dates.Da end """ - TimeTrackingLMO(blmo::BoundedLinearMinimizationOracle) + TimeTrackingLMO(lmo::LinearMinimizationOracle) Constructor with just the blmo. """ -TimeTrackingLMO(blmo::BoundedLinearMinimizationOracle, time_ref, time_limit) = TimeTrackingLMO( - blmo, +TimeTrackingLMO(lmo::LinearMinimizationOracle, time_ref, time_limit) = TimeTrackingLMO( + lmo, Float64[], Int[], Int[], 0, Int[], time_ref, - isa(blmo, MathOptBLMO), + lmo isa Union{MathOptBLMO,FrankWolfe.MathOptLMO}, time_limit, ) """ - TimeTrackingLMO(blmo::BoundedLinearMinimizationOracle, int_vars) + TimeTrackingLMO(lmo::LinearMinimizationOracle, int_vars) Constructor with just the blmo. """ -TimeTrackingLMO(blmo::BoundedLinearMinimizationOracle, int_vars, time_ref, time_limit) = - TimeTrackingLMO( - blmo, - Float64[], - Int[], - Int[], - 0, - int_vars, - time_ref, - isa(blmo, MathOptBLMO), - time_limit, - ) +TimeTrackingLMO(lmo::LinearMinimizationOracle, int_vars, time_ref, time_limit) = TimeTrackingLMO( + lmo, + Float64[], + Int[], + Int[], + 0, + int_vars, + time_ref, + lmo isa Union{MathOptBLMO,FrankWolfe.MathOptLMO}, + time_limit, +) is_decomposition_invariant_oracle(tlmo::TimeTrackingLMO) = - is_decomposition_invariant_oracle(tlmo.blmo) + is_decomposition_invariant_oracle(tlmo.lmo) function is_inface_feasible(tlmo::TimeTrackingLMO, a, x) - return is_inface_feasible(tlmo.blmo, a, x) + return is_inface_feasible(tlmo.lmo, a, x) end function compute_inface_extreme_point(tlmo::TimeTrackingLMO, direction, x; lazy=false, kwargs...) tlmo.ncalls += 1 - free_model(tlmo.blmo) - a = compute_inface_extreme_point(tlmo.blmo, direction, x) + free_model(tlmo.lmo) + + # keep track of the solving data, since the in-face problem is solved using a new solver + MOI_attribute = if tlmo.type_moi + Dict(MOI.SolveTimeSec() => 0.0, MOI.NodeCount() => 0, MOI.SimplexIterations() => 0) + else + Dict() + end + + a = compute_inface_extreme_point(tlmo.lmo, direction, x; solve_data=MOI_attribute) if !is_linear_feasible(tlmo, a) @debug "Vertex not linear feasible $(a)" @assert is_linear_feasible(tlmo, a) end - opt_times, numberofnodes, simplex_iterations = get_BLMO_solve_data(tlmo.blmo) - - push!(tlmo.optimizing_times, opt_times) - push!(tlmo.optimizing_nodes, numberofnodes) - push!(tlmo.simplex_iterations, simplex_iterations) + if tlmo.type_moi + push!(tlmo.optimizing_times, MOI_attribute[MOI.SolveTimeSec()]) + push!(tlmo.optimizing_nodes, MOI_attribute[MOI.NodeCount()]) + push!(tlmo.simplex_iterations, MOI_attribute[MOI.SimplexIterations()]) + else + opt_times, numberofnodes, simplex_iterations = get_LMO_solve_data(tlmo.lmo) + push!(tlmo.optimizing_times, opt_times) + push!(tlmo.optimizing_nodes, numberofnodes) + push!(tlmo.simplex_iterations, simplex_iterations) + end - free_model(tlmo.blmo) + free_model(tlmo.lmo) return a end function dicg_maximum_step(tlmo::TimeTrackingLMO, direction, x) - gamma_max = dicg_maximum_step(tlmo.blmo, direction, x) + gamma_max = dicg_maximum_step(tlmo.lmo, direction, x) return gamma_max end @@ -103,13 +115,13 @@ Compute the extreme point and collect statistics. """ function FrankWolfe.compute_extreme_point(tlmo::TimeTrackingLMO, d; kwargs...) tlmo.ncalls += 1 - free_model(tlmo.blmo) + free_model(tlmo.lmo) if tlmo.type_moi && isfinite(tlmo.time_limit) time_limit = tlmo.time_limit - float(Dates.value(Dates.now() - tlmo.time_ref)) / 1000 time_limit = time_limit <= 0 ? 1 : time_limit - MOI.set(tlmo.blmo.o, MOI.TimeLimitSec(), time_limit) + MOI.set(tlmo.lmo.o, MOI.TimeLimitSec(), time_limit) end - v = FrankWolfe.compute_extreme_point(tlmo.blmo, d; kwargs) + v = FrankWolfe.compute_extreme_point(tlmo.lmo, d; kwargs) if !is_linear_feasible(tlmo, v) @debug "Vertex not linear feasible $(v)" @@ -117,12 +129,12 @@ function FrankWolfe.compute_extreme_point(tlmo::TimeTrackingLMO, d; kwargs...) end v[tlmo.int_vars] = round.(v[tlmo.int_vars]) - opt_times, numberofnodes, simplex_iterations = get_BLMO_solve_data(tlmo.blmo) + opt_times, numberofnodes, simplex_iterations = get_LMO_solve_data(tlmo.lmo) push!(tlmo.optimizing_times, opt_times) push!(tlmo.optimizing_nodes, numberofnodes) push!(tlmo.simplex_iterations, simplex_iterations) - free_model(tlmo.blmo) + free_model(tlmo.lmo) return v end diff --git a/src/utilities.jl b/src/utilities.jl index 682e2f9f6..54cc7cfbe 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -27,7 +27,7 @@ end Check feasibility and boundedness """ function check_feasibility(tlmo::TimeTrackingLMO) - return check_feasibility(tlmo.blmo) + return check_feasibility(tlmo.lmo) end @@ -35,7 +35,7 @@ end Check if at a given index we have an integer constraint respectivily. """ function has_integer_constraint(tree::Bonobo.BnBTree, idx::Int) - return has_integer_constraint(tree.root.problem.tlmo.blmo, idx) + return has_integer_constraint(tree.root.problem.tlmo.lmo, idx) end @@ -43,7 +43,7 @@ end Check wether a split is valid. """ function is_valid_split(tree::Bonobo.BnBTree, vidx::Int) - return is_valid_split(tree, tree.root.problem.tlmo.blmo, vidx) + return is_valid_split(tree, tree.root.problem.tlmo.lmo, vidx) end @@ -84,6 +84,7 @@ function split_vertices_set!( (λ, a) = tup if !is_bound_feasible(local_bounds, a) @info "removed" + @debug "removed: index $var value $(a[var]) local bounds: $local_bounds" push!(left_del_indices, idx) continue end @@ -101,8 +102,8 @@ function split_vertices_set!( end end deleteat!(active_set, left_del_indices) - @assert !isempty(active_set) - @assert !isempty(right_as) + @assert !isempty(active_set) "Left active set is empty: x[$var]=$(x[var]) left active set: $(active_set.atoms) right active set: $(right_as.atoms)" + @assert !isempty(right_as) "Right active set is empty: x[$var]=$(x[var]) left active set: $(active_set.atoms) right active set: $(right_as.atoms)" # renormalize active set and recompute new iterates if !isempty(active_set) FrankWolfe.active_set_renormalize!(active_set) @@ -142,45 +143,96 @@ function split_pre_computed_set!( end """ -Default starting point function which generates a random vertex +Default first iterate for decomposition-invariant vairants: extreme point for linear objective `ones(n)`. """ -function trivial_build_dicg_start_point(blmo::BoundedLinearMinimizationOracle) - n, _ = get_list_of_variables(blmo) +function trivial_build_decomposition_invariant_start_iterate(lmo::LinearMinimizationOracle) + n, _ = get_list_of_variables(lmo) d = ones(n) - x0 = FrankWolfe.compute_extreme_point(blmo, d) + x0 = FrankWolfe.compute_extreme_point(lmo, d) return x0 end -function dicg_start_point_initialize( +function compute_decomposition_invariant_start_iterate( lmo::TimeTrackingLMO, active_set::FrankWolfe.ActiveSet{T,R}, pre_computed_set, - build_dicg_start_point; + build_start_iterate; domain_oracle=_trivial_domain, ) where {T,R} if lmo.ncalls == 0 return FrankWolfe.get_active_set_iterate(active_set) end if pre_computed_set === nothing - x0 = build_dicg_start_point(lmo.blmo) + x0 = build_start_iterate(lmo.lmo) else if !isempty(pre_computed_set) # We pick a point by averaging the pre_computed_atoms as warm-start. num_pre_computed_set = length(pre_computed_set) x0 = sum(pre_computed_set) / num_pre_computed_set if !domain_oracle(x0) - x0 = build_dicg_start_point(lmo.blmo) + x0 = build_start_iterate(lmo.lmo) end else # We pick a random point. - x0 = build_dicg_start_point(lmo.blmo) + x0 = build_start_iterate(lmo.lmo) end end return x0 end +function initialize_decomposition_invariant_starting_point( + lmo, + active_set, + pre_computed_set, + build_start_point::Function; + starting_point=nothing, + domain_oracle=_trivial_domain, +) + if starting_point !== nothing + x0 = starting_point + else + x0 = compute_decomposition_invariant_start_iterate( + lmo, + active_set, + pre_computed_set, + build_start_point; + domain_oracle=domain_oracle, + ) + end + + if x0 === nothing || !domain_oracle(x0) + return nothing + end + @assert is_linear_feasible(lmo, x0) + return x0 +end + +function cleanup_precomputed_set_after_solve!(pre_computed_set, lmo, x, use_strong_warm_start::Bool) + if pre_computed_set !== nothing && use_strong_warm_start + indices_to_delete = [] + for idx in eachindex(pre_computed_set) + atom = pre_computed_set[idx] + if !is_inface_feasible(lmo, atom, x) + push!(indices_to_delete, idx) + end + end + deleteat!(pre_computed_set, indices_to_delete) + end + return nothing +end + +function make_precomputed_set_callback(callback, pre_computed_set) + return function wrapped_callback(state, kwargs...) + return callback(state, pre_computed_set) + end +end + """ Split a discarded vertices set between left and right children. +Uses the same left/right bound convention as get_branching_nodes_info: +- Left child: var ≤ new_bound_left +- Right child: var ≥ new_bound_right +Works for both fractional and integer x[var] (e.g. BRANCH_ALL when solution is already integer). """ function split_vertices_set!( discarded_set::FrankWolfe.DeletedVertexStorage{T}, @@ -191,23 +243,45 @@ function split_vertices_set!( atol=1e-5, rtol=1e-5, ) where {T} + gb = tree.root.problem.integer_variable_bounds + lb_global = get(gb.lower_bounds, var, -Inf) + ub_global = get(gb.upper_bounds, var, Inf) + x_var = x[var] + + # Same convention as in node.jl get_branching_nodes_info: three cases + # (1) x at global lower bound → left var ≤ lb, right var ≥ lb+1 + # (2) x at global upper bound → left var ≤ ub-1, right var ≥ ub + # (3) else: x fractional or integer in (lb, ub) → left var ≤ floor(x), right var ≥ ceil(x) + # When x is integer in the middle, floor(x) == ceil(x) == k, so left var ≤ k, right var ≥ k + new_bound_left, new_bound_right = if isapprox(lb_global, x_var, atol=atol, rtol=rtol) + floor(x_var), floor(x_var) + 1 + elseif isapprox(ub_global, x_var, atol=atol, rtol=rtol) + ceil(x_var) - 1, ceil(x_var) + else + # Covers both fractional x and integer x strictly between lb and ub + floor(x_var), ceil(x_var) + end + right_as = FrankWolfe.DeletedVertexStorage{T}(T[], discarded_set.return_kth) - # indices to remove later from the left active set left_del_indices = BitSet() for (idx, vertex) in enumerate(discarded_set.storage) if !is_bound_feasible(local_bounds, vertex) push!(left_del_indices, idx) continue end - if vertex[var] >= ceil(x[var]) || isapprox(vertex[var], ceil(x[var]), atol=atol, rtol=rtol) + v_var = vertex[var] + # Left child: var ≤ new_bound_left. Right child: var ≥ new_bound_right. + # When new_bound_left == new_bound_right (integer x in the middle), vertex[var]==k is + # feasible for both; we assign it to the left only (first branch below). + if v_var < new_bound_left || isapprox(v_var, new_bound_left, atol=atol, rtol=rtol) + # Feasible for left (and only left when new_bound_left < new_bound_right) + continue + elseif v_var > new_bound_right || isapprox(v_var, new_bound_right, atol=atol, rtol=rtol) + # Feasible for right only push!(right_as.storage, vertex) push!(left_del_indices, idx) - elseif vertex[var] <= floor(x[var]) || - isapprox(vertex[var], floor(x[var]), atol=atol, rtol=rtol) - # keep in left, don't add to right - else #floor(x[var]) < vertex[var] < ceil(x[var]) - # if you are in middle, delete from the left and do not add to the right! - @warn "Attention! Vertex in the middle." + else + # new_bound_left < v_var < new_bound_right: feasible for neither (e.g. fractional vertex) push!(left_del_indices, idx) end end @@ -285,7 +359,7 @@ function build_active_set_by_domain_oracle( # Once we find a domain feasible point, we count the iteration # and stop if we have not found a feasible point after 5 iterations.. if tree.root.options[:domain_oracle](state.x) - if domain_counter > 5 + if domain_counter > tree.root.options[:depth_domain] return false end domain_counter += 1 @@ -294,7 +368,8 @@ function build_active_set_by_domain_oracle( end inner_callback = build_inner_callback(tree) - x, _, _, _, _, active_set = FrankWolfe.blended_pairwise_conditional_gradient( + x, _, _, _, atoms_set = solve_frank_wolfe( + tree.root.options[:variant], inner_f, inner_grad!, tree.root.problem.tlmo, @@ -439,3 +514,5 @@ _value_to_print(::LargestGradient) = "Largest Gradient" _value_to_print(::LargestMostInfeasibleGradient) = "Largest most infeasible gradient" _value_to_print(::LargestIndex) = "Largest Index" _value_to_print(::RandomBranching) = "Uniform Random Choice" +_value_to_print(::BRANCH_ALL) = "Branch on all variables" +_value_to_print(::BiasedDepthFirstSearch) = "BiasedDepthFirstSearch" diff --git a/test/LMO_test.jl b/test/LMO_test.jl index 5f37cdfbe..955e553bd 100644 --- a/test/LMO_test.jl +++ b/test/LMO_test.jl @@ -11,6 +11,9 @@ using Dates const MOI = MathOptInterface const MOIU = MOI.Utilities using StableRNGs +using LinearAlgebra + +println("\nLMO Tests") seed = rand(UInt64) @show seed @@ -39,7 +42,7 @@ rng = StableRNG(seed) MOI.add_constraint(o, xi, MOI.LessThan(5.0)) end end - lmo = Boscia.MathOptBLMO(o) + lmo = FrankWolfe.MathOptLMO(o) global_bounds = Boscia.IntegerBounds() @test isempty(global_bounds) @@ -74,23 +77,15 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 ubs = ones(n) int_vars = collect(1:n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) x, _, result = Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) # testing for cube inface oracles - x_dicg, _, result_dicg = Boscia.solve( - f, - grad!, - sblmo, - lbs[int_vars], - ubs[int_vars], - int_vars, - n, - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_dicg, _, result_dicg = + Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n, settings=settings) @test sum(isapprox.(x, round.(diffi), atol=1e-6, rtol=1e-2)) == n @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -98,6 +93,76 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 @test isapprox(f(x_dicg), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) end +@testset "Hypersimplex" begin + function f(x) + return 0.5 * sum((x[i] - diffi[i])^2 for i in eachindex(x)) + end + function grad!(storage, x) + @. storage = x - diffi + end + + int_vars = collect(1:(n÷2)) + @testset "K $K radius $radius" for K in (1, 2, n ÷ 2), radius in (3.0, 5.0) + for lmo in + (FrankWolfe.UnitHyperSimplexLMO(K, radius), FrankWolfe.HyperSimplexLMO(K, radius)) + for _ in 1:10 + direction = randn(n) + # compute extreme point with zero-one + lbs = zeros(n ÷ 2) + ubs = ones(n ÷ 2) + v0 = Boscia.bounded_compute_extreme_point(lmo, direction, lbs, ubs, int_vars) + @test sum(v0) <= K + @test Boscia.is_simple_linear_feasible(lmo, v0) + if lmo isa FrankWolfe.HyperSimplexLMO + @test sum(v0) == K + end + @test maximum(v0) <= radius + for (i, idx) in enumerate(int_vars) + if v0[idx] > 0 + @test lbs[i] <= v0[idx] <= ubs[i] + end + end + # set some variables bounds + lbs = zeros(n ÷ 2) + ubs = ones(n ÷ 2) + idxmin = 3 + direction[idxmin] = minimum(direction) - 1 + i = findfirst(==(idxmin), int_vars) + ubs[i] = 0 + v1 = Boscia.bounded_compute_extreme_point(lmo, direction, lbs, ubs, int_vars) + @test Boscia.is_simple_linear_feasible(lmo, v1) + # test upper bound respected + @test v1[idxmin] == 0 + idxmax = 4 + direction[idxmax] = maximum(direction) + 1 + v2 = Boscia.bounded_compute_extreme_point(lmo, direction, lbs, ubs, int_vars) + @test Boscia.is_simple_linear_feasible(lmo, v2) + @test v2[idxmax] == 0 + i = findfirst(==(idxmax), int_vars) + lbs[i] = 1 + v3 = Boscia.bounded_compute_extreme_point(lmo, direction, lbs, ubs, int_vars) + @test v3[idxmax] == 1 + @test sum(v3) <= K + if lmo isa FrankWolfe.HyperSimplexLMO + @test sum(v3) == max(K, sum(lbs)) + end + @test Boscia.is_simple_linear_feasible(lmo, v3) + v_wrong = 1.0 * v3 + v_wrong[1] = K + 1 + @test !Boscia.is_simple_linear_feasible(lmo, v_wrong) + if lmo isa FrankWolfe.UnitHyperSimplexLMO + direction = rand(n) + @test norm(FrankWolfe.compute_extreme_point(lmo, direction)) <= 1e-6 + v4 = Boscia.bounded_compute_extreme_point(lmo, direction, lbs, ubs, int_vars) + @test v4[int_vars] ≈ lbs + @test norm(v4) ≈ norm(lbs) + end + end + end + end +end + + @testset "BLMO - Strong Branching" begin function f(x) return 0.5 * sum((x[i] - diffi[i])^2 for i in eachindex(x)) @@ -110,17 +175,14 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) blmo = Boscia.ManagedBoundedLMO(sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - x, _, result = Boscia.solve( - f, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) @test x == round.(diffi) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -130,20 +192,17 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) blmo = Boscia.ManagedBoundedLMO(sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) function perform_strong_branch(tree, node) - return node.level <= length(tree.root.problem.integer_variables) / 3 + return node.std.depth <= length(tree.root.problem.integer_variables) / 3 end branching_strategy = Boscia.HybridStrongBranching(10, 1e-3, blmo, perform_strong_branch) - x, _, result = Boscia.solve( - f, - grad!, - blmo, - settings_bnb=Boscia.settings_bnb(branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, blmo, settings=settings) @test x == round.(diffi) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -169,6 +228,8 @@ diffi = x_sol + 0.3 * dir x, _, result = Boscia.solve(f, grad!, sblmo, fill(0.0, n), fill(1.0 * N, n), collect(1:n), n) # testing for Probability simplex inface oracles + settings = Boscia.create_default_settings() + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() x_dicg, _, result_dicg = Boscia.solve( f, grad!, @@ -177,9 +238,7 @@ diffi = x_sol + 0.3 * dir fill(1.0 * N, n), collect(1:n), n, - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), + settings=settings, ) @test sum(isapprox.(x, x_sol, atol=1e-6, rtol=1e-2)) == n diff --git a/test/aqua.jl b/test/aqua.jl index 859636759..dc6f724a1 100644 --- a/test/aqua.jl +++ b/test/aqua.jl @@ -2,6 +2,8 @@ using Aqua using Test using Boscia +println("\nAqua Tests") + @testset "Aqua.jl" begin Aqua.test_all( Boscia; diff --git a/test/branching_strategy_test.jl b/test/branching_strategy_test.jl index 584b3bbb1..84b22e367 100644 --- a/test/branching_strategy_test.jl +++ b/test/branching_strategy_test.jl @@ -11,6 +11,7 @@ using StableRNGs import MathOptInterface const MOI = MathOptInterface +println("\nBranching Strategy Tests") seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -61,32 +62,22 @@ end f, grad!, lmo = build_examples(o, dimension, seed) time_limit = 60 - x_mi, _, result_mi = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=Bonobo.MOST_INFEASIBLE(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = Bonobo.MOST_INFEASIBLE() + x_mi, _, result_mi = Boscia.solve(f, grad!, lmo, settings=settings) @testset "Largest Gradient Branching" begin branching_strategy = Boscia.LargestGradient() o = SCIP.Optimizer() f, grad!, lmo = build_examples(o, dimension, seed) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -97,16 +88,11 @@ end o = SCIP.Optimizer() f, grad!, lmo = build_examples(o, dimension, seed) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -117,16 +103,11 @@ end o = SCIP.Optimizer() f, grad!, lmo = build_examples(o, dimension, seed) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -137,16 +118,11 @@ end o = SCIP.Optimizer() f, grad!, lmo = build_examples(o, dimension, seed) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -161,16 +137,11 @@ end f, grad!, lmo = build_examples(o, dimension, seed) time_limit = 60 - x_mi, _, result_mi = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=Bonobo.MOST_INFEASIBLE(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = Bonobo.MOST_INFEASIBLE() + x_mi, _, result_mi = Boscia.solve(f, grad!, lmo, settings=settings) @testset "Pseudocost with Most-Infeasible alternative and weighted_sum decision function" begin o = SCIP.Optimizer() @@ -183,16 +154,11 @@ end iterations_until_stable=1, ) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -209,16 +175,11 @@ end iterations_until_stable=1, ) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -235,16 +196,11 @@ end iterations_until_stable=1, ) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -261,16 +217,11 @@ end iterations_until_stable=5, ) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -334,16 +285,11 @@ end f, grad!, lmo = build_examples(o, dimension, seed) time_limit = 60 - x_mi, _, result_mi = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=Bonobo.MOST_INFEASIBLE(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = Bonobo.MOST_INFEASIBLE() + x_mi, _, result_mi = Boscia.solve(f, grad!, lmo, settings=settings) @testset "Hierarchy with Most-Infeasible pseudocost alternative and weighted_sum decision function" begin o = SCIP.Optimizer() @@ -357,16 +303,11 @@ end ) branching_strategy = Boscia.Hierarchy(lmo; stages) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -380,16 +321,11 @@ end Boscia.default_hierarchy_strategies("most_infeasible", "largest_gradient", 1, "product") branching_strategy = Boscia.Hierarchy(lmo; stages) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -407,16 +343,11 @@ end ) branching_strategy = Boscia.Hierarchy(lmo; stages) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -432,16 +363,11 @@ end branching_strategy = Boscia.Hierarchy(lmo; stages) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb( - verbose=verbose, - time_limit=time_limit, - branching_strategy=branching_strategy, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = verbose + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -483,14 +409,12 @@ const diff1 = rand(rng, Bool, n) * 0.8 .+ 1.1 blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - MOI.set(branching_strategy.bounded_lmo.o, MOI.Silent(), true) + MOI.set(branching_strategy.lmo.o, MOI.Silent(), true) - x, _, result_strong_branching = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result_strong_branching = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(x, round.(diff1), atol=1e-5, rtol=1e-5) end @@ -528,18 +452,16 @@ end function perform_strong_branch(tree, node) - return node.level <= length(tree.root.problem.integer_variables) / 3 + return node.std.depth <= length(tree.root.problem.integer_variables) / 3 end blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) branching_strategy = Boscia.HybridStrongBranching(10, 1e-3, blmo, perform_strong_branch) - MOI.set(branching_strategy.pstrong.bounded_lmo.o, MOI.Silent(), true) + MOI.set(branching_strategy.pstrong.lmo.o, MOI.Silent(), true) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(x, round.(diff1), atol=1e-5, rtol=1e-5) end diff --git a/test/callback_tests.jl b/test/callback_tests.jl new file mode 100644 index 000000000..d5bb63a9c --- /dev/null +++ b/test/callback_tests.jl @@ -0,0 +1,79 @@ +using Boscia +using FrankWolfe +using Test +using Random +using StableRNGs + + +println("\nCallback Tests") + +seed = rand(UInt64) +@show seed +rng = StableRNG(seed) + +# Testing of the interface function solve + +n = 20 +diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 + +@testset "Callback tests" begin + function f(x) + return sum(0.5 * (x .- diffi) .^ 2) + end + function grad!(storage, x) + @. storage = x - diffi + end + + cube_lmo = Boscia.CubeLMO(zeros(n), ones(n), collect(1:n)) + lmo = Boscia.ManagedLMO(cube_lmo, zeros(n), ones(n), collect(1:n), n) + + @testset "BnB callback" begin + # Stop once + eval_nodes = 0 + function bnb_callback( + tree, + node; + worse_than_incumbent=false, + node_infeasible=false, + lb_update=false, + ) + eval_nodes += 1 + if eval_nodes > rand(rng, 1:Int(floor(n / 2))) + tree.root.problem.solving_stage = Boscia.USER_STOP + return false + end + return true + end + settings = Boscia.create_default_settings() + settings.branch_and_bound[:bnb_callback] = bnb_callback + x_bnb, _, result = Boscia.solve(f, grad!, lmo, settings=settings) + + @test result[:status] == Boscia.USER_STOP + @test eval_nodes <= Int(floor(n / 2)) + end + + @testset "Branch callback" begin + num_branch = 0 + function branch_callback(tree, node, vidx) + num_branch += 1 + return false, false + end + settings = Boscia.create_default_settings() + settings.branch_and_bound[:branch_callback] = branch_callback + x_branch, _, result = Boscia.solve(f, grad!, lmo, settings=settings) + + @test num_branch != 0 + end + + @testset "Propagate bounds" begin + calls = 0 + function propagate_bounds(tree, node) + return calls += 1 + end + settings = Boscia.create_default_settings() + settings.tightening[:propagate_bounds] = propagate_bounds + x_propagate, _, result = Boscia.solve(f, grad!, lmo, settings=settings) + + @test calls != 0 + end +end diff --git a/test/heuristics.jl b/test/heuristics.jl index f0aea315b..0d9774ceb 100644 --- a/test/heuristics.jl +++ b/test/heuristics.jl @@ -12,6 +12,8 @@ const MOI = MathOptInterface const MOIU = MOI.Utilities using StableRNGs +println("\nHeuristics Tests") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -32,6 +34,8 @@ diffi = x_sol + 0.3 * dir sblmo = Boscia.ProbabilitySimplexSimpleBLMO(N) + settings = Boscia.create_default_settings() + settings.heuristic[:hyperplane_aware_rounding_prob] = 0.8 x, _, result = Boscia.solve( f, grad!, @@ -40,7 +44,7 @@ diffi = x_sol + 0.3 * dir fill(1.0 * N, n), collect(1:n), n, - settings_heuristic=Boscia.settings_heuristic(hyperplane_aware_rounding_prob=0.8), + settings=settings, ) @test sum(isapprox.(x, x_sol, atol=1e-6, rtol=1e-2)) == n @@ -62,6 +66,8 @@ diffi = x_sol + 0.3 * rand(rng, [-1, 1], n) N = sum(x_sol) + floor(n / 2) sblmo = Boscia.UnitSimplexSimpleBLMO(N) + settings = Boscia.create_default_settings() + settings.heuristic[:hyperplane_aware_rounding_prob] = 0.8 x, _, result = Boscia.solve( f, grad!, @@ -70,7 +76,7 @@ diffi = x_sol + 0.3 * rand(rng, [-1, 1], n) fill(1.0 * N, n), collect(1:n), n, - settings_heuristic=Boscia.settings_heuristic(hyperplane_aware_rounding_prob=0.8), + settings=settings, ) @test sum(isapprox.(x, x_sol, atol=1e-6, rtol=1e-2)) == n @@ -89,6 +95,9 @@ end sblmo = Boscia.UnitSimplexSimpleBLMO(N) depth = 5 + settings = Boscia.create_default_settings() + settings.heuristic[:follow_gradient_prob] = 1.0 + settings.heuristic[:follow_gradient_steps] = depth x_heu, _, result_heu = Boscia.solve( f, grad!, @@ -97,10 +106,7 @@ end fill(1.0 * N, n), collect(1:n), n, - settings_heuristic=Boscia.settings_heuristic( - follow_gradient_prob=1.0, - follow_gradient_steps=depth, - ), + settings=settings, ) x, _, result = Boscia.solve(f, grad!, sblmo, fill(0.0, n), fill(1.0 * N, n), collect(1:n), n) @@ -130,6 +136,8 @@ end Boscia.solve(f, grad!, sblmo, fill(0.0, n), fill(1.0 * N, n), collect(1:n), n) sblmo = Boscia.UnitSimplexSimpleBLMO(N) + settings = Boscia.create_default_settings() + settings.heuristic[:rounding_prob] = 0.5 x, _, result = Boscia.solve( f, grad!, @@ -138,7 +146,7 @@ end fill(1.0 * N, n), collect(1:n), n, - settings_heuristic=Boscia.settings_heuristic(rounding_prob=0.5), + settings=settings, ) @test sum(isapprox.(x_always, x_sol, atol=1e-6, rtol=1e-2)) == n @@ -164,21 +172,13 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 x_sol = copy(diffi) x_sol[int_vars] = round.(x_sol[int_vars]) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) - x, _, result = Boscia.solve( - f, - grad!, - sblmo, - lbs[int_vars], - ubs[int_vars], - int_vars, - n, - settings_heuristic=Boscia.settings_heuristic( - probability_rounding_prob=0.6, - rounding_prob=0.0, - ), - ) + settings = Boscia.create_default_settings() + settings.heuristic[:probability_rounding_prob] = 0.6 + settings.heuristic[:rounding_prob] = 0.0 + x, _, result = + Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n, settings=settings) @test sum(isapprox.(x, x_sol, atol=1e-6, rtol=1e-2)) == n @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -205,19 +205,11 @@ diffi = x_sol + 0.3 * dir sblmo = Boscia.ProbabilitySimplexSimpleBLMO(N) - x, _, result = Boscia.solve( - f, - grad!, - sblmo, - fill(0.0, m), - fill(1.0, m), - int_vars, - n, - settings_heuristic=Boscia.settings_heuristic( - probability_rounding_prob=0.6, - rounding_prob=0.0, - ), - ) + settings = Boscia.create_default_settings() + settings.heuristic[:probability_rounding_prob] = 0.6 + settings.heuristic[:rounding_prob] = 0.0 + x, _, result = + Boscia.solve(f, grad!, sblmo, fill(0.0, m), fill(1.0, m), int_vars, n, settings=settings) @test f(x) ≥ f(x_sol) if isapprox(sum(x_sol), N) diff --git a/test/indicator_test.jl b/test/indicator_test.jl index f865209e2..a9e8d3bea 100644 --- a/test/indicator_test.jl +++ b/test/indicator_test.jl @@ -11,6 +11,12 @@ using Dates const MOI = MathOptInterface const MOIU = MOI.Utilities +println("\nIndicator Tests") + +seed = rand(UInt64) +@show seed +rng = StableRNG(seed) + @testset "Indicators" begin n = 5 o = SCIP.Optimizer() @@ -26,7 +32,7 @@ const MOIU = MOI.Utilities MOI.add_constraint(o, z[i], MOI.LessThan(1.0)) MOI.add_constraint(o, z[i], MOI.ZeroOne()) end - blmo = Boscia.MathOptBLMO(o) + blmo = FrankWolfe.MathOptLMO(o) @test Boscia.indicator_present(blmo) == false diff --git a/test/interface_test.jl b/test/interface_test.jl index ac7d28fa7..93f10447e 100644 --- a/test/interface_test.jl +++ b/test/interface_test.jl @@ -11,6 +11,8 @@ using Test using StableRNGs using Suppressor +println("\nInterface Tests") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -40,27 +42,19 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 end return FrankWolfe.MathOptLMO(o) end - x_baseline, _, result = Boscia.solve( - f, - grad!, - build_norm_lmo(), - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_tightening=Boscia.settings_tightening(dual_tightening=false), - ) - x_tighten, _, result = Boscia.solve( - f, - grad!, - build_norm_lmo(), - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_tightening=Boscia.settings_tightening(dual_tightening=true), - ) - x_strong, _, result = Boscia.solve( - f, - grad!, - build_norm_lmo(), - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_tightening=Boscia.settings_tightening(dual_tightening=true, strong_convexity=1.0), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.tightening[:dual_tightening] = false + x_baseline, _, result = Boscia.solve(f, grad!, build_norm_lmo(), settings=settings) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.tightening[:dual_tightening] = true + x_tighten, _, result = Boscia.solve(f, grad!, build_norm_lmo(), settings=settings) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.tightening[:dual_tightening] = true + settings.tightening[:strong_convexity] = 1.0 + x_strong, _, result = Boscia.solve(f, grad!, build_norm_lmo(), settings=settings) @test x_baseline == round.(diffi) @test f(x_tighten) == f(result[:raw_solution]) @@ -87,16 +81,14 @@ end @. storage = x - diffi end - blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) - branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - MOI.set(branching_strategy.bounded_lmo.o, MOI.Silent(), true) + branch_lmo = FrankWolfe.MathOptLMO(HiGHS.Optimizer()) + branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, branch_lmo) + MOI.set(branching_strategy.lmo.o, MOI.Silent(), true) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false, branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test x == round.(diffi) @test f(x) == f(result[:raw_solution]) @@ -114,6 +106,7 @@ end lbs = zeros(n) ubs = ones(n) + # Test depecrated type CubeSimpleBLMO still works sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) x, _, result = Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n) @@ -126,8 +119,10 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:use_shadow_set] = false x, _, result = Boscia.solve( f, grad!, @@ -136,7 +131,7 @@ end ubs[int_vars], int_vars, n, - settings_bnb=Boscia.settings_bnb(use_shadow_set=false), + settings=settings, ) @test x == round.(diffi) @@ -157,9 +152,11 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) μ = 1.0 + settings = Boscia.create_default_settings() + settings.tightening[:strong_convexity] = μ x, _, result = Boscia.solve( f, grad!, @@ -168,7 +165,7 @@ end ubs[int_vars], int_vars, n, - settings_tightening=Boscia.settings_tightening(strong_convexity=μ), + settings=settings, ) @test x == round.(diffi) @@ -180,10 +177,13 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) θ = 1 / 2 M = 2.0 + settings = Boscia.create_default_settings() + settings.tightening[:sharpness_constant] = M + settings.tightening[:sharpness_exponent] = θ x, _, result = Boscia.solve( f, grad!, @@ -192,10 +192,7 @@ end ubs[int_vars], int_vars, n, - settings_tightening=Boscia.settings_tightening( - sharpness_constant=M, - sharpness_exponent=θ, - ), + settings=settings, ) @test x == round.(diffi) @@ -207,11 +204,15 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) μ = 1.0 θ = 1 / 2 M = 2.0 + settings = Boscia.create_default_settings() + settings.tightening[:strong_convexity] = μ + settings.tightening[:sharpness_constant] = M + settings.tightening[:sharpness_exponent] = θ x, _, result = Boscia.solve( f, grad!, @@ -220,11 +221,7 @@ end ubs[int_vars], int_vars, n, - settings_tightening=Boscia.settings_tightening( - strong_convexity=μ, - sharpness_constant=M, - sharpness_exponent=θ, - ), + settings=settings, ) @test x == round.(diffi) @@ -245,21 +242,15 @@ end lbs = zeros(n) ubs = ones(n) - sblmo = Boscia.CubeSimpleBLMO(lbs, ubs, int_vars) + sblmo = Boscia.BoxLMO(lbs, ubs) direction = rand(n) v = Boscia.bounded_compute_extreme_point(sblmo, direction, lbs, ubs, int_vars) active_set = FrankWolfe.ActiveSet([(1.0, v)]) - x, _, result = Boscia.solve( - f, - grad!, - sblmo, - lbs[int_vars], - ubs[int_vars], - int_vars, - n, - settings_domain=Boscia.settings_domain(active_set=active_set), - ) + settings = Boscia.create_default_settings() + settings.domain[:active_set] = active_set + x, _, result = + Boscia.solve(f, grad!, sblmo, lbs[int_vars], ubs[int_vars], int_vars, n, settings=settings) @test x == round.(diffi) @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -342,7 +333,7 @@ Ns = 0.1 w = @view(θ[1:p]) b = θ[end] storage[1:p] .= 2α .* w - storage[p+1:2p] .= 0 + storage[(p+1):2p] .= 0 storage[end] = 0 for i in 1:n xi = @view(Xs[:, i]) @@ -351,17 +342,20 @@ Ns = 0.1 storage[1:p] .-= 1 / n * ys[i] * xi storage[end] += 1 / n * (exp(a) - ys[i]) end - storage ./= norm(storage) return storage end - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=false)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) - @test sum(x[p+1:2p]) <= k + @test sum(x[(p+1):2p]) <= k @test f(x) <= f(result[:raw_solution]) - x2, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(start_solution=x)) - @test sum(x2[p+1:2p]) <= k + settings = Boscia.create_default_settings() + settings.branch_and_bound[:start_solution] = x + x2, _, result = Boscia.solve(f, grad!, lmo, settings=settings) + @test sum(x2[(p+1):2p]) <= k @test f(x2) == f(x) end @@ -392,67 +386,63 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 end lmo = build_model() - x_afw, _, result_afw = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe(variant=Boscia.AwayFrankWolfe()), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.AwayFrankWolfe() + x_afw, _, result_afw = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_blended, _, result_blended = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.BlendedConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.BlendedConditionalGradient() + x_blended, _, result_blended = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_bpcg, _, result_bpcg = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.BlendedPairwiseConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.BlendedPairwiseConditionalGradient() + x_bpcg, _, result_bpcg = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_dicg, _, result_dicg = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(), - fw_verbose=false, - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.PairwiseFrankWolfe() + x_pcg, _, result_pcg = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_vfw, _, result_vfw = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe(variant=Boscia.StandardFrankWolfe()), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + settings.frank_wolfe[:fw_verbose] = false + x_dicg, _, result_dicg = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + settings.frank_wolfe[:fw_verbose] = false + x_bdicg, _, result_bdicg = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.StandardFrankWolfe() + x_vfw, _, result_vfw = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_afw), f(result_afw[:raw_solution]), atol=1e-6, rtol=1e-3) @test isapprox(f(x_blended), f(result_blended[:raw_solution]), atol=1e-6, rtol=1e-3) + @test isapprox(f(x_pcg), f(result_pcg[:raw_solution]), atol=1e-6, rtol=1e-3) @test isapprox(f(x_bpcg), f(result_bpcg[:raw_solution]), atol=1e-6, rtol=1e-3) @test isapprox(f(x_dicg), f(result_dicg[:raw_solution]), atol=1e-6, rtol=1e-3) + @test isapprox(f(x_bdicg), f(result_bdicg[:raw_solution]), atol=1e-6, rtol=1e-3) @test isapprox(f(x_vfw), f(result_vfw[:raw_solution]), atol=1e-6, rtol=1e-3) @test sum(isapprox.(x_afw, x_blended, atol=1e-6, rtol=1e-3)) == n @test sum(isapprox.(x_blended, x_bpcg, atol=1e-6, rtol=1e-3)) == n - @test sum(isapprox.(x_bpcg, x_vfw, atol=1e-6, rtol=1e-3)) == n + @test sum(isapprox.(x_bpcg, x_pcg, atol=1e-6, rtol=1e-3)) == n + @test sum(isapprox.(x_pcg, x_vfw, atol=1e-6, rtol=1e-3)) == n @test sum(isapprox.(x_bpcg, x_dicg, atol=1e-6, rtol=1e-3)) == n + @test sum(isapprox.(x_bpcg, x_bdicg, atol=1e-6, rtol=1e-3)) == n @test sum(isapprox.(x_vfw, x_afw, atol=1e-6, rtol=1e-3)) == n end @@ -482,35 +472,35 @@ end lmo = build_model() line_search = FrankWolfe.Adaptive() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:line_search] = line_search x_adaptive, _, result_adaptive = @suppress begin - Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - ) + Boscia.solve(f, grad!, lmo, settings=settings) end lmo = build_model() line_search = FrankWolfe.MonotonicStepSize() - x_monotonic, _, result_monotonic = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false, time_limit=60), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:time_limit] = 60 + settings.frank_wolfe[:line_search] = line_search + x_monotonic, _, result_monotonic = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() line_search = FrankWolfe.Agnostic() - x_agnostic, _, result_agnostic = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false, time_limit=60), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), + settings = Boscia.create_default_settings() + settings = merge( + settings, + ( + branch_and_bound=merge( + settings.branch_and_bound, + Dict(:verbose => false, :time_limit => 60), + ), + frank_wolfe=merge(settings.frank_wolfe, Dict(:line_search => line_search)), + ), ) + x_agnostic, _, result_agnostic = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_adaptive), f(result_adaptive[:raw_solution]), atol=1e-6, rtol=1e-3) @test isapprox(f(x_monotonic), f(result_monotonic[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -520,16 +510,15 @@ end @test sum(isapprox.(x_agnostic, x_monotonic, atol=1e-6, rtol=1e-3)) == n @test sum(isapprox.(x_adaptive, x_agnostic, atol=1e-6, rtol=1e-3)) == n - x_monotonic, _, result_monotonic_node_limit = Boscia.solve( - f, - grad!, - lmo, - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_bnb=Boscia.settings_bnb(verbose=false, print_iter=1, node_limit=2), - ) + settings = Boscia.create_default_settings() + settings.frank_wolfe[:line_search] = line_search + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:print_iter] = 1 + settings.branch_and_bound[:node_limit] = 2 + x_monotonic, _, result_monotonic_node_limit = Boscia.solve(f, grad!, lmo, settings=settings) @test length(result_monotonic_node_limit[:list_ub]) <= 3 - @test result_monotonic_node_limit[:status] == "Node limit reached" + @test result_monotonic_node_limit[:status] == Boscia.NODE_LIMIT_REACHED end n = 20 @@ -559,26 +548,22 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 end lmo = build_model() - x_lazy, _, result_lazy = - Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=false)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + x_lazy, _, result_lazy = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_no, _, result_no = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe(lazy=false), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = false + x_no, _, result_no = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_mid, _, result_mid = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe(lazy=true, lazy_tolerance=1.5), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = true + settings.frank_wolfe[:lazy_tolerance] = 1.5 + x_mid, _, result_mid = Boscia.solve(f, grad!, lmo, settings=settings) @test isapprox(f(x_lazy), f(result_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) @test isapprox(f(x_no), f(result_no[:raw_solution]), atol=1e-6, rtol=1e-2) @@ -587,7 +572,7 @@ diffi = rand(rng, Bool, n) * 0.6 .+ 0.3 @test sum(isapprox.(x_lazy, x_mid, atol=1e-6, rtol=1e-2)) == n end -@testset "DICG - Lazification" begin +@testset "DICG/BDICG - Lazification" begin function build_model() o = SCIP.Optimizer() @@ -612,92 +597,140 @@ end # testing for weak lazification lmo = build_model() - x_lazy, _, result_lazy = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_dicg_lazy, _, result_dicg_lazy = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_no, _, result_no = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - lazy=false, - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + x_bdicg_lazy, _, result_bdicg_lazy = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_warm_start, _, result_warm_start = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - lazy=true, - lazy_tolerance=1.5, - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_dicg_no, _, result_dicg_no = Boscia.solve(f, grad!, lmo, settings=settings) - @test isapprox(f(x_lazy), f(result_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) - @test isapprox(f(x_no), f(result_no[:raw_solution]), atol=1e-6, rtol=1e-2) - @test isapprox(f(x_warm_start), f(result_warm_start[:raw_solution]), atol=1e-6, rtol=1e-2) - @test sum(isapprox.(x_lazy, x_no, atol=1e-6, rtol=1e-2)) == n - @test sum(isapprox.(x_lazy, x_warm_start, atol=1e-6, rtol=1e-2)) == n + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + x_bdicg_no, _, result_bdicg_no = Boscia.solve(f, grad!, lmo, settings=settings) - # testing for strong lazification lmo = build_model() - x_lazy, _, result_lazy = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(use_strong_lazy=true), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = true + settings.frank_wolfe[:lazy_tolerance] = 1.5 + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_dicg_warm_start, _, result_dicg_warm_start = Boscia.solve(f, grad!, lmo, settings=settings) lmo = build_model() - x_no, _, result_no = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - lazy=false, - variant=Boscia.DecompositionInvariantConditionalGradient(use_strong_lazy=true), - ), + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = true + settings.frank_wolfe[:lazy_tolerance] = 1.5 + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + x_bdicg_warm_start, _, result_bdicg_warm_start = Boscia.solve(f, grad!, lmo, settings=settings) + + @test isapprox(f(x_dicg_lazy), f(result_dicg_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_bdicg_lazy), f(result_bdicg_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_dicg_no), f(result_dicg_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_bdicg_no), f(result_bdicg_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox( + f(x_dicg_warm_start), + f(result_dicg_warm_start[:raw_solution]), + atol=1e-6, + rtol=1e-2, ) + @test isapprox( + f(x_bdicg_warm_start), + f(result_bdicg_warm_start[:raw_solution]), + atol=1e-6, + rtol=1e-2, + ) + @test sum(isapprox.(x_dicg_lazy, x_dicg_no, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_bdicg_lazy, x_bdicg_no, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_dicg_lazy, x_dicg_warm_start, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_bdicg_lazy, x_bdicg_warm_start, atol=1e-6, rtol=1e-2)) == n + # testing for strong lazification lmo = build_model() - x_warm_start, _, result_warm_start = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - lazy=true, - lazy_tolerance=1.5, - variant=Boscia.DecompositionInvariantConditionalGradient(use_strong_lazy=true), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = + Boscia.DecompositionInvariantConditionalGradient(use_strong_lazy=true) + x_dicg_lazy, _, result_dicg_lazy = Boscia.solve(f, grad!, lmo, settings=settings) - @test isapprox(f(x_lazy), f(result_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) - @test isapprox(f(x_no), f(result_no[:raw_solution]), atol=1e-6, rtol=1e-2) - @test isapprox(f(x_warm_start), f(result_warm_start[:raw_solution]), atol=1e-6, rtol=1e-2) - @test sum(isapprox.(x_lazy, x_no, atol=1e-6, rtol=1e-2)) == n - @test sum(isapprox.(x_lazy, x_warm_start, atol=1e-6, rtol=1e-2)) == n + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = + Boscia.BlendedDecompositionInvariantConditionalGradient(use_strong_lazy=true) + x_bdicg_lazy, _, result_bdicg_lazy = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = + Boscia.DecompositionInvariantConditionalGradient(use_strong_lazy=true) + x_dicg_no, _, result_dicg_no = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = false + settings.frank_wolfe[:variant] = + Boscia.BlendedDecompositionInvariantConditionalGradient(use_strong_lazy=true) + x_bdicg_no, _, result_bdicg_no = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = true + settings.frank_wolfe[:lazy_tolerance] = 1.5 + settings.frank_wolfe[:variant] = + Boscia.DecompositionInvariantConditionalGradient(use_strong_lazy=true) + x_dicg_warm_start, _, result_dicg_warm_start = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:lazy] = true + settings.frank_wolfe[:lazy_tolerance] = 1.5 + settings.frank_wolfe[:variant] = + Boscia.BlendedDecompositionInvariantConditionalGradient(use_strong_lazy=true) + x_bdicg_warm_start, _, result_bdicg_warm_start = Boscia.solve(f, grad!, lmo, settings=settings) + + @test isapprox(f(x_dicg_lazy), f(result_dicg_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_bdicg_lazy), f(result_bdicg_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_dicg_no), f(result_dicg_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_bdicg_no), f(result_bdicg_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox( + f(x_dicg_warm_start), + f(result_dicg_warm_start[:raw_solution]), + atol=1e-6, + rtol=1e-2, + ) + @test isapprox( + f(x_bdicg_warm_start), + f(result_bdicg_warm_start[:raw_solution]), + atol=1e-6, + rtol=1e-2, + ) + @test sum(isapprox.(x_dicg_lazy, x_dicg_no, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_bdicg_lazy, x_bdicg_no, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_dicg_lazy, x_dicg_warm_start, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_bdicg_lazy, x_bdicg_warm_start, atol=1e-6, rtol=1e-2)) == n end -@testset "DICG - warm_start" begin +@testset "DICG/BDICG - warm_start" begin function build_model() o = SCIP.Optimizer() @@ -722,57 +755,83 @@ end lmo = build_model() - x_no, _, result_no = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_dicg_no, _, result_dicg_no = Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient() + x_bdicg_no, _, result_bdicg_no = Boscia.solve(f, grad!, lmo, settings=settings) # testing for weak warm-start lmo = build_model() - x_weak_warm_start, _, result_weak_warm_start = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(use_DICG_warm_start=true), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = + Boscia.DecompositionInvariantConditionalGradient(use_warm_start=true) + x_dicg_weak_warm_start, _, result_dicg_weak_warm_start = + Boscia.solve(f, grad!, lmo, settings=settings) + + lmo = build_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = + Boscia.BlendedDecompositionInvariantConditionalGradient(use_warm_start=true) + x_bdicg_weak_warm_start, _, result_bdicg_weak_warm_start = + Boscia.solve(f, grad!, lmo, settings=settings) # testing for strong warm_start - x_strong_warm_start, _, result_strong_warm_start = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient( - use_DICG_warm_start=true, - use_strong_warm_start=true, - ), - ), + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient( + use_warm_start=true, + use_strong_warm_start=true, + ) + x_dicg_strong_warm_start, _, result_dicg_strong_warm_start = + Boscia.solve(f, grad!, lmo, settings=settings) + + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.frank_wolfe[:variant] = Boscia.BlendedDecompositionInvariantConditionalGradient( + use_warm_start=true, + use_strong_warm_start=true, ) + x_bdicg_strong_warm_start, _, result_bdicg_strong_warm_start = + Boscia.solve(f, grad!, lmo, settings=settings) - @test isapprox(f(x_no), f(result_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_dicg_no), f(result_dicg_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_bdicg_no), f(result_bdicg_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox( + f(x_dicg_weak_warm_start), + f(result_dicg_weak_warm_start[:raw_solution]), + atol=1e-6, + rtol=1e-2, + ) + @test isapprox( + f(x_dicg_strong_warm_start), + f(result_dicg_strong_warm_start[:raw_solution]), + atol=1e-6, + rtol=1e-2, + ) + @test sum(isapprox.(x_dicg_no, x_dicg_weak_warm_start, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_dicg_no, x_dicg_strong_warm_start, atol=1e-6, rtol=1e-2)) == n @test isapprox( - f(x_weak_warm_start), - f(result_weak_warm_start[:raw_solution]), + f(x_bdicg_weak_warm_start), + f(result_bdicg_weak_warm_start[:raw_solution]), atol=1e-6, rtol=1e-2, ) @test isapprox( - f(x_strong_warm_start), - f(result_strong_warm_start[:raw_solution]), + f(x_bdicg_strong_warm_start), + f(result_bdicg_strong_warm_start[:raw_solution]), atol=1e-6, rtol=1e-2, ) - @test sum(isapprox.(x_no, x_weak_warm_start, atol=1e-6, rtol=1e-2)) == n - @test sum(isapprox.(x_no, x_strong_warm_start, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_bdicg_no, x_bdicg_weak_warm_start, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_bdicg_no, x_bdicg_strong_warm_start, atol=1e-6, rtol=1e-2)) == n end @testset "User stop" begin @@ -810,18 +869,57 @@ end end lmo = build_model() - x_no, _, result_no = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=false, bnb_callback=callback), - settings_frank_wolfe=Boscia.settings_frank_wolfe( - variant=Boscia.DecompositionInvariantConditionalGradient(), - ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:bnb_callback] = callback + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + x_no, _, result_no = Boscia.solve(f, grad!, lmo, settings=settings) + + @test result_no[:status] == Boscia.USER_STOP +end + +@testset "Integral objective" begin + n = 8 + + function build_integral_objective_model() + o = SCIP.Optimizer() + MOI.set(o, MOI.Silent(), true) + MOI.empty!(o) + x = MOI.add_variables(o, n) + for xi in x + MOI.add_constraint(o, xi, MOI.GreaterThan(0.0)) + MOI.add_constraint(o, xi, MOI.LessThan(1.0)) + MOI.add_constraint(o, xi, MOI.ZeroOne()) + end + return FrankWolfe.MathOptLMO(o) + end + + function f(x) + return 4.0 * sum((x[i] - 0.5)^2 for i in eachindex(x)) + end + + function grad!(storage, x) + @. storage = 8.0 * (x - 0.5) + return storage + end - @test result_no[:status] == "User defined stop" - @test result_no[:solving_stage] == Boscia.USER_STOP + # baseline solve without integral lower-bound rounding + lmo = build_integral_objective_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:integral_objective] = false + x_no_int, _, result_no_int = Boscia.solve(f, grad!, lmo, settings=settings) + + # solve with integral_objective enabled + lmo = build_integral_objective_model() + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:integral_objective] = true + x_int, _, result_int = Boscia.solve(f, grad!, lmo, settings=settings) + + # primal objective should not deteriorate + @test isapprox(f(x_int), f(x_no_int); atol=1e-6, rtol=1e-3) + @test result_int[:dual_bound] >= result_no_int[:dual_bound] - 1e-6 end @testset "Linear feasible" begin @@ -840,3 +938,88 @@ end @test Boscia.is_linear_feasible(o, vcat([0.5, 0.5], ones(n - 2))) @test Boscia.is_linear_feasible(o, vcat([0.0, 0.0], ones(n - 2))) end + +@testset "Float N test with ProbabilitySimplexLMO" begin + n = 10 + N = 24.5 #Float N + d = randn(n) + nint = 4 + + lb = zeros(nint) + ub = ones(nint) * 20.0 + + int_vars = collect(1:nint) + + blmo = Boscia.ProbabilitySimplexLMO(N) + + x_feas = [1.0, 2.0, 0.0, 2.0, 0.0, 0.0, 4.2, 1.5, 4.1, 9.7] #exactly equal to N + + + Q = Matrix(I, n, n) + b = -Q * x_feas + + function f(x) + return 0.5 * x' * Q * x + b' * x + end + + function grad!(storage, x) + return storage .= Q * x + b + end + + settings = Boscia.create_default_settings() + settings.branch_and_bound[:time_limit] = 10.0 + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + + x, tlmo, result = Boscia.solve(f, grad!, blmo, lb, ub, int_vars, n; settings=settings) + + + @test length(x) == n + @test isfinite(f(x)) + @test Boscia.is_simple_linear_feasible(blmo, x) + + + println("Solution x = ", x) + println("Objective f(x) = ", f(x)) + println("Status = ", result[:status]) +end + +@testset "Float N test with UnitSimplexSimpleBLMO" begin + n = 10 + N = 22.4 #Float N + d = randn(n) + nint = 3 + + lb = zeros(nint) + ub = ones(nint) * 10.0 + + int_vars = collect(1:nint) + + blmo = Boscia.UnitSimplexSimpleBLMO(N) + + x_feas = [1.0, 2.0, 0.0, 2.0, 0.0, 0.0, 4.2, 1.5, 4.1, 0.6] #smaller than N + + Q = Matrix(I, n, n) + b = -Q * x_feas + + function f(x) + return 0.5 * x' * Q * x + b' * x + end + + function grad!(storage, x) + return storage .= Q * x + b + end + + settings = Boscia.create_default_settings() + settings.branch_and_bound[:time_limit] = 10.0 + settings.frank_wolfe[:variant] = Boscia.DecompositionInvariantConditionalGradient() + + x, tlmo, result = Boscia.solve(f, grad!, blmo, lb, ub, int_vars, n; settings=settings) + + @test length(x) == n + @test isfinite(f(x)) + @test Boscia.is_simple_linear_feasible(blmo, x_feas) + + println("Solution x = ", x) + println("Objective f(x) = ", f(x)) + println("Status = ", result[:status]) +end diff --git a/test/mean_risk.jl b/test/mean_risk.jl index 593488cb5..b1e18948b 100644 --- a/test/mean_risk.jl +++ b/test/mean_risk.jl @@ -13,6 +13,8 @@ using Dates using Test using StableRNGs +println("\nMean Risk Test") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -65,8 +67,10 @@ const M1 = (A1 + A1') / 2 return storage end - x, _, result = - Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test dot(a, x) <= b + 1e-2 @test f(x) <= f(result[:raw_solution]) + 1e-6 diff --git a/test/poisson.jl b/test/poisson.jl index c22a86a56..47831e13f 100644 --- a/test/poisson.jl +++ b/test/poisson.jl @@ -13,6 +13,8 @@ using Dates using Test using StableRNGs +println("\nPoisson Tests") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -86,12 +88,13 @@ N = 1.0 storage[1:p] .-= 1 / n0 * y0[i] * xi storage[end] += 1 / n0 * (exp(a) - y0[i]) end - storage ./= norm(storage) return storage end - x, _, result = - Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=120)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 120 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x) <= f(result[:raw_solution]) + 1e-6 @test sum(x[p+1:2p]) <= k @@ -157,20 +160,18 @@ end storage[1:p] .-= 1 / n0 * y0[i] * xi storage[end] += 1 / n0 * (exp(a) - y0[i]) end - storage ./= norm(storage) return storage end blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - MOI.set(branching_strategy.bounded_lmo.o, MOI.Silent(), true) + MOI.set(branching_strategy.lmo.o, MOI.Silent(), true) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:branching_strategy] = branching_strategy + settings.tolerances[:fw_epsilon] = 1e-3 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test sum(x[p+1:2p]) <= k @test f(x) <= f(result[:raw_solution]) + 1e-6 @test sum(x[p+1:2p]) <= k @@ -180,14 +181,14 @@ n0g = 20 pg = n0g # underlying true weights -const w0g = 2 * rand(rng, Float64, pg) .- 1 +w0g = 2 * rand(rng, Float64, pg) .- 1 # set 50 entries to 0 for _ in 1:15 w0g[rand(rng, 1:pg)] = 0 end -const b0g = 2 * rand(rng, Float64) - 1 -const X0g = 2 * rand(rng, Float64, n0g, pg) .- 1 -const y0g = map(1:n0g) do idx +b0g = 2 * rand(rng, Float64) - 1 +X0g = 2 * rand(rng, Float64, n0g, pg) .- 1 +y0g = map(1:n0g) do idx a = dot(X0g[idx, :], w0g) + b0g return rand(Distributions.Poisson(exp(a))) end @@ -195,7 +196,7 @@ Ng = 5.0 k = 10 group_size = convert(Int64, floor(pg / k)) -groups = [] +const groups = [] for i in 1:(k-1) push!(groups, ((i-1)*group_size+1):(i*group_size)) end @@ -265,11 +266,12 @@ push!(groups, ((k-1)*group_size+1):pg) storage[1:pg] .-= 1 / n0g * y0g[i] * xi storage[end] += 1 / n0g * (exp(a) - y0g[i]) end - storage ./= norm(storage) return storage end - x, _, result = Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x) <= f(result[:raw_solution]) + 1e-6 @test sum(x[p+1:2pg]) <= k end @@ -338,20 +340,17 @@ end storage[1:pg] .-= 1 / n0g * y0g[i] * xi storage[end] += 1 / n0g * (exp(a) - y0g[i]) end - storage ./= norm(storage) return storage end blmo = Boscia.MathOptBLMO(HiGHS.Optimizer()) branching_strategy = Boscia.PartialStrongBranching(10, 1e-3, blmo) - MOI.set(branching_strategy.bounded_lmo.o, MOI.Silent(), true) + MOI.set(branching_strategy.lmo.o, MOI.Silent(), true) - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, branching_strategy=branching_strategy), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:branching_strategy] = branching_strategy + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test f(x) <= f(result[:raw_solution]) + 1e-6 @test sum(x[p+1:2pg]) <= k diff --git a/test/runtests.jl b/test/runtests.jl index fbad4d2dc..34fcfc5bc 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -12,9 +12,10 @@ import HiGHS using Dates using Aqua -@testset verbose = true failfast = false "Boscia Test Suite" begin +@testset verbose = true failfast = true "Boscia Test Suite" begin include("interface_test.jl") + include("callback_tests.jl") include("LMO_test.jl") include("indicator_test.jl") include("heuristics.jl") @@ -28,10 +29,19 @@ using Aqua include("time_limit.jl") include("strong_convexity_and_sharpness.jl") include("branching_strategy_test.jl") + include("traverse_strategy_test.jl") + + # Files to exclude from testing (e.g., utilities that require extra dependencies) + excluded_files = ["plot_utilities.jl"] for file in readdir(joinpath(@__DIR__, "../examples/"), join=true) - if endswith(file, "jl") - include(file) + filename = basename(file) + if endswith(file, "jl") && !(filename in excluded_files) + # Isolate each example in its own module to avoid global name clashes + m = Module() + # Provide a local include that resolves relative to this module + Core.eval(m, :(include(x) = Base.include(@__MODULE__, x))) + Base.include(m, file) end end diff --git a/test/sparse_regression.jl b/test/sparse_regression.jl index b1dfa2bee..89137ab6a 100644 --- a/test/sparse_regression.jl +++ b/test/sparse_regression.jl @@ -10,6 +10,8 @@ using Bonobo using FrankWolfe using StableRNGs +println("\nSparse Regression Tests") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -69,8 +71,10 @@ const M = 2 * var(A) return storage end - x, _, result = - Boscia.solve(f, grad!, lmo, settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=100)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 100 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) # println("Solution: $(x[1:p])") @test sum(x[1+p:2p]) <= k @test f(x) <= f(result[:raw_solution]) + 1e-6 @@ -147,13 +151,10 @@ end end lmo = build_sparse_lmo_grouped() - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_tolerances=Boscia.settings_tolerances(fw_epsilon=1e-3), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.tolerances[:fw_epsilon] = 1e-3 + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) @test sum(x[p+1:2p]) <= k for i in 1:k_int @@ -170,14 +171,11 @@ end μ = 2lambda_0_g lmo = build_sparse_lmo_grouped() - x2, _, result2 = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true), - settings_tolerances=Boscia.settings_tolerances(fw_epsilon=1e-3), - settings_tightening=Boscia.settings_tightening(strong_convexity=μ), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.tolerances[:fw_epsilon] = 1e-3 + settings.tightening[:strong_convexity] = μ + x2, _, result2 = Boscia.solve(f, grad!, lmo, settings=settings) @test sum(x2[p+1:2p]) <= k for i in 1:k_int @test sum(x2[groups[i]]) >= 1 diff --git a/test/strong_convexity_and_sharpness.jl b/test/strong_convexity_and_sharpness.jl index ff5bb315e..063fb0330 100644 --- a/test/strong_convexity_and_sharpness.jl +++ b/test/strong_convexity_and_sharpness.jl @@ -6,6 +6,8 @@ using FrankWolfe using StableRNGs using Suppressor +println("\nStrong Convexity and Sharpness Tests") + ## Log barrier # min_x - ∑ log(xi + ϵ) - log(N - ∑ xi + ϵ) # s.t. x ∈ {0,1}^n @@ -46,6 +48,11 @@ rng = StableRNG(seed) line_search = FrankWolfe.Adaptive() @suppress begin + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.branch_and_bound[:print_iter] = 1000 + settings.frank_wolfe[:line_search] = line_search x, _, result = Boscia.solve( f, grad!, @@ -54,11 +61,16 @@ rng = StableRNG(seed) fill(floor(N / 2), n), int_vars, n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60, print_iter=1000), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), + settings=settings, ) μ = 1 / (1 + ϵ)^(2 * n) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.branch_and_bound[:print_iter] = 1000 + settings.frank_wolfe[:line_search] = line_search + settings.tightening[:strong_convexity] = μ x_sc, _, result_sc = Boscia.solve( f, grad!, @@ -67,9 +79,7 @@ rng = StableRNG(seed) fill(floor(N / 2), n), int_vars, n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60, print_iter=1000), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_tightening=Boscia.settings_tightening(strong_convexity=μ), + settings=settings, ) @@ -101,6 +111,12 @@ rng = StableRNG(seed) blmo = Boscia.ProbabilitySimplexSimpleBLMO(N) μ = minimum(eigvals(Q)) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.branch_and_bound[:print_iter] = 1000 + settings.frank_wolfe[:line_search] = FrankWolfe.Secant() + settings.tightening[:strong_convexity] = μ x, _, _ = Boscia.solve( f, grad!, @@ -109,9 +125,7 @@ rng = StableRNG(seed) fill(1.0, n), collect(1:n), n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60, print_iter=1000), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=FrankWolfe.Secant()), - settings_tightening=Boscia.settings_tightening(strong_convexity=μ), + settings=settings, ) @test isapprox(f(x), f(sol), atol=1e-5, rtol=1e-2) @@ -140,6 +154,11 @@ end line_search = FrankWolfe.Adaptive() @suppress begin + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.branch_and_bound[:print_iter] = 1000 + settings.frank_wolfe[:line_search] = line_search x, _, result = Boscia.solve( f, grad!, @@ -148,13 +167,19 @@ end fill(floor(N / 2), n), int_vars, n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60, print_iter=1000), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), + settings=settings, ) μ = 1 / (1 + ϵ)^(2 * n) θ = 1 / 2 M = sqrt(2 / μ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 120 + settings.branch_and_bound[:print_iter] = 1000 + settings.frank_wolfe[:line_search] = line_search + settings.tightening[:sharpness_constant] = M + settings.tightening[:sharpness_exponent] = θ x_sc, _, result_sc = Boscia.solve( f, grad!, @@ -163,12 +188,7 @@ end fill(floor(N / 2), n), int_vars, n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=120, print_iter=1000), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=line_search), - settings_tightening=Boscia.settings_tightening( - sharpness_constant=M, - sharpness_exponent=θ, - ), + settings=settings, ) @@ -201,6 +221,13 @@ end θ = 1 / 2 M = sqrt(2 / μ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = 60 + settings.branch_and_bound[:print_iter] = 1000 + settings.frank_wolfe[:line_search] = FrankWolfe.Secant() + settings.tightening[:sharpness_constant] = M + settings.tightening[:sharpness_exponent] = θ x, _, _ = Boscia.solve( f, grad!, @@ -209,12 +236,7 @@ end fill(1.0, n), collect(1:n), n, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=60, print_iter=1000), - settings_frank_wolfe=Boscia.settings_frank_wolfe(line_search=FrankWolfe.Secant()), - settings_tightening=Boscia.settings_tightening( - sharpness_constant=M, - sharpness_exponent=θ, - ), + settings=settings, ) @test isapprox(f(x), f(sol), atol=1e-5, rtol=1e-2) diff --git a/test/time_limit.jl b/test/time_limit.jl index 6d2463fae..92494f96a 100644 --- a/test/time_limit.jl +++ b/test/time_limit.jl @@ -16,6 +16,8 @@ import MathOptSetDistances const MOD = MathOptSetDistances using StableRNGs +println("\nTime Limit Test") + seed = rand(UInt64) @show seed rng = StableRNG(seed) @@ -61,14 +63,13 @@ time_limit = 30.0 end start_time = Dates.now() - x, _, result = Boscia.solve( - f, - grad!, - lmo, - settings_bnb=Boscia.settings_bnb(verbose=true, time_limit=time_limit), - ) + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = true + settings.branch_and_bound[:time_limit] = time_limit + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) time_taken = float(Dates.value(Dates.now() - start_time)) / 1000 - @test sum(ai' * x) <= bi + 1e-3 + # coarse tolerance because of windows + @test sum(ai' * x) <= bi + 1e-2 @test f(x) <= f(result[:raw_solution]) + 1e-6 @test result[:total_time_in_sec] <= time_limit + 5 end diff --git a/test/traverse_strategy_test.jl b/test/traverse_strategy_test.jl new file mode 100644 index 000000000..a7757a0ce --- /dev/null +++ b/test/traverse_strategy_test.jl @@ -0,0 +1,85 @@ +using Boscia +using Bonobo +using FrankWolfe +using Test +using Random +using SCIP +using LinearAlgebra + +import MathOptInterface +const MOI = MathOptInterface + +function build_examples(o, n, seed) + Random.seed!(seed) + A = let + A = randn(n, n) + A' * A + end + + @assert isposdef(A) + + y = Random.rand(Bool, n) * 0.6 .+ 0.3 + + MOI.set(o, MOI.Silent(), true) + MOI.empty!(o) + x = MOI.add_variables(o, n) + for xi in x + MOI.add_constraint(o, xi, MOI.ZeroOne()) + MOI.add_constraint(o, xi, MOI.GreaterThan(0.0)) + MOI.add_constraint(o, xi, MOI.LessThan(1.0)) + end + lmo = Boscia.MathOptBLMO(o) + + function f(x) + d = x - y + return dot(d, A, d) + end + + function grad!(storage, x) + # storage = Ax + mul!(storage, A, x) + # storage = 2Ax - 2Ay + return mul!(storage, A, y, -2, 2) + end + return f, grad!, lmo +end + + +@testset "BiasedDepthFirstSearch Traverse Strategy" begin + dimension = 20 + seed = 1 + o = SCIP.Optimizer() + f, grad!, lmo = build_examples(o, dimension, seed) + time_limit = 60 + + settings = Boscia.create_default_settings() + settings.branch_and_bound[:time_limit] = time_limit + x_mi, _, result_mi = Boscia.solve(f, grad!, lmo, settings=settings) + + @testset "BiasedDepthFirstSearch favoring right" begin + o = SCIP.Optimizer() + f, grad!, lmo = build_examples(o, dimension, seed) + + settings = Boscia.create_default_settings() + settings.branch_and_bound[:verbose] = false + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:traverse_strategy] = Boscia.BiasedDepthFirstSearch(true) + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) + + @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) + @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) + end + + @testset "BiasedDepthFirstSearch favoring left" begin + o = SCIP.Optimizer() + f, grad!, lmo = build_examples(o, dimension, seed) + + settings = Boscia.create_default_settings() + settings.branch_and_bound[:time_limit] = time_limit + settings.branch_and_bound[:traverse_strategy] = Boscia.BiasedDepthFirstSearch(false) + x, _, result = Boscia.solve(f, grad!, lmo, settings=settings) + + @test isapprox(f(x_mi), f(x), atol=1e-6, rtol=1e-3) + @test isapprox(f(x), f(result[:raw_solution]), atol=1e-6, rtol=1e-3) + end +end