diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml new file mode 100644 index 0000000..f196f10 --- /dev/null +++ b/.JuliaFormatter.toml @@ -0,0 +1,9 @@ +# Configuration file for JuliaFormatter.jl +# For more information, see: https://domluna.github.io/JuliaFormatter.jl/stable/config/ + +always_for_in = true +always_use_return = true +margin = 80 +remove_extra_newlines = true +separate_kwargs_with_semicolon = true +short_to_long_function_def = true diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml new file mode 100644 index 0000000..f49313b --- /dev/null +++ b/.github/workflows/TagBot.yml @@ -0,0 +1,15 @@ +name: TagBot +on: + issue_comment: + types: + - created + workflow_dispatch: +jobs: + TagBot: + if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' + runs-on: ubuntu-latest + steps: + - uses: JuliaRegistries/TagBot@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ssh: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.github/workflows/aqua.yml b/.github/workflows/aqua.yml new file mode 100644 index 0000000..904d2e6 --- /dev/null +++ b/.github/workflows/aqua.yml @@ -0,0 +1,23 @@ +name: aqua-lint +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: julia-actions/setup-julia@latest + with: + version: '1' + - uses: actions/checkout@v4 + - name: Aqua + shell: julia --color=yes {0} + run: | + using Pkg + Pkg.add(PackageSpec(name="Aqua")) + Pkg.develop(PackageSpec(path=pwd())) + using Omelette, Aqua + Aqua.test_all(Omelette) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d60e0ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI +on: + push: + branches: + - master + - release-* + pull_request: + types: [opened, synchronize, reopened] +# needed to allow julia-actions/cache to delete old caches that it has created +permissions: + actions: write + contents: read +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Since Omelette doesn't have binary dependencies, only test on a subset of + # possible platforms. + include: + - version: '1' # The latest point-release (Linux) + os: ubuntu-latest + arch: x64 + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v1 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + with: + depwarn: error + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v4 + with: + file: lcov.info diff --git a/.github/workflows/format_check.yml b/.github/workflows/format_check.yml new file mode 100644 index 0000000..6dff182 --- /dev/null +++ b/.github/workflows/format_check.yml @@ -0,0 +1,31 @@ +name: format-check +on: + push: + branches: + - master + - release-* + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: julia-actions/setup-julia@latest + with: + version: '1' + - uses: actions/checkout@v4 + - name: Format check + shell: julia --color=yes {0} + run: | + using Pkg + # If you update the version, also update the style guide docs. + Pkg.add(PackageSpec(name="JuliaFormatter", version="1")) + using JuliaFormatter + format("."; verbose = true) + out = String(read(Cmd(`git diff`))) + if isempty(out) + exit(0) + end + @error "Some files have not been formatted !!!" + write(stdout, out) + exit(1) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fc6527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Manifest.toml + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1789e45 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2024: Oscar Dowson and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..0a82267 --- /dev/null +++ b/Project.toml @@ -0,0 +1,11 @@ +name = "Omelette" +uuid = "e52c2cb8-508e-4e12-9dd2-9c4755b60e73" +authors = ["odow "] +version = "0.1.0" + +[deps] +JuMP = "4076af6c-e467-56ae-b986-b466b2749572" + +[compat] +JuMP = "1" +julia = "1.6" diff --git a/README.md b/README.md index 303e5cd..a315974 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ -# jump-ml \ No newline at end of file +# Omelette + +_If you can come up with a better name, please open an issue._ + +Omelette is a [JuMP](https://jump.dev) extension for embedding common types of +AI, machine learning, and statistical learning models into a JuMP optimization +model. + +## License + +Omelette.jl is licensed under the [MIT license](https://github.com/lanl-ansi/jump-ml/blob/main/LICENSE.md) + +## Getting help + +This package is under active development. For help, questions, comments, and +suggestions, please open a GitHub issue. + +## Inspiration + +This project is inspired by two existing projects: + + * [OMLT](https://github.com/cog-imperial/OMLT) + * [gurobi-machinelearning](https://github.com/Gurobi/gurobi-machinelearning) diff --git a/src/Omelette.jl b/src/Omelette.jl new file mode 100644 index 0000000..b705a5e --- /dev/null +++ b/src/Omelette.jl @@ -0,0 +1,66 @@ +# Copyright (c) 2024: Oscar Dowson and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +module Omelette + +import JuMP + +""" + abstract type AbstractModel end + +## Methods + +All subtypes must implement: + + * `add_model_internal` + * `Base.size` +""" +abstract type AbstractModel end + +""" + add_model( + opt_model::JuMP.Model, + ml_model::AbstractModel, + x::Vector{JuMP.VariableRef}, + y::Vector{JuMP.VariableRef}, + ) + +Add the constraint `ml_model(x) == y` to the optimization model `opt_model`. + +## Input + +## Output + + * `::Nothing` + +## Examples + +TODO +""" +function add_model( + opt_model::JuMP.Model, + ml_model::AbstractModel, + x::Vector{JuMP.VariableRef}, + y::Vector{JuMP.VariableRef}, +) + output_n, input_n = size(ml_model) + if length(x) != input_n + msg = "Input vector x is length $(length(x)), expected $input_n" + throw(DimensionMismatch(msg)) + elseif length(y) != output_n + msg = "Output vector y is length $(length(y)), expected $output_n" + throw(DimensionMismatch(msg)) + end + _add_model_inner(opt_model, ml_model, x, y) + return +end + +for file in readdir(joinpath(@__DIR__, "models"); join = true) + if endswith(file, ".jl") + include(file) + end +end + +end # module Omelette diff --git a/src/models/LinearRegression.jl b/src/models/LinearRegression.jl new file mode 100644 index 0000000..727ec61 --- /dev/null +++ b/src/models/LinearRegression.jl @@ -0,0 +1,24 @@ +# Copyright (c) 2024: Oscar Dowson and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +struct LinearRegression <: AbstractModel + parameters::Matrix{Float64} +end + +function LinearRegression(parameters::Vector{Float64}) + return LinearRegression(reshape(parameters, 1, length(parameters))) +end + +Base.size(f::LinearRegression) = size(f.parameters) + +function _add_model_inner( + opt_model::JuMP.Model, + ml_model::LinearRegression, + x::Vector{JuMP.VariableRef}, + y::Vector{JuMP.VariableRef}, +) + JuMP.@constraint(opt_model, ml_model.parameters * x .== y) + return +end diff --git a/test/Project.toml b/test/Project.toml new file mode 100644 index 0000000..4050cb8 --- /dev/null +++ b/test/Project.toml @@ -0,0 +1,7 @@ +[deps] +JuMP = "4076af6c-e467-56ae-b986-b466b2749572" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[compat] +Test = "<0.0.1, 1.6" +julia = "1.6" diff --git a/test/models/test_LinearRegression.jl b/test/models/test_LinearRegression.jl new file mode 100644 index 0000000..38d8cd0 --- /dev/null +++ b/test/models/test_LinearRegression.jl @@ -0,0 +1,52 @@ +# Copyright (c) 2024: Oscar Dowson and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +module LinearRegressionTests + +using Test +using JuMP +import Omelette + +function runtests() + for name in names(@__MODULE__; all = true) + if startswith("$name", "test_") + @testset "$name" begin + getfield(@__MODULE__, name)() + end + end + end + return +end + +function test_LinearRegression() + model = Model() + @variable(model, x[1:2]) + @variable(model, y[1:1]) + f = Omelette.LinearRegression([2.0, 3.0]) + Omelette.add_model(model, f, x, y) + cons = all_constraints(model; include_variable_in_set_constraints = false) + obj = constraint_object(only(cons)) + @test obj.set == MOI.EqualTo(0.0) + @test isequal_canonical(obj.func, 2.0 * x[1] + 3.0 * x[2] - y[1]) + return +end + +function test_LinearRegression_dimension_mismatch() + model = Model() + @variable(model, x[1:3]) + @variable(model, y[1:2]) + f = Omelette.LinearRegression([2.0, 3.0]) + @test size(f) == (1, 2) + @test_throws DimensionMismatch Omelette.add_model(model, f, x, y[1:1]) + @test_throws DimensionMismatch Omelette.add_model(model, f, x[1:2], y) + g = Omelette.LinearRegression([2.0 3.0; 4.0 5.0; 6.0 7.0]) + @test size(g) == (3, 2) + @test_throws DimensionMismatch Omelette.add_model(model, g, x, y) + return +end + +end + +LinearRegressionTests.runtests() diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..8ea7225 --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,14 @@ +# Copyright (c) 2024: Oscar Dowson and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +using Test + +for file in readdir(joinpath(@__DIR__, "models")) + if startswith(file, "test_") && endswith(file, ".jl") + @testset "$file" begin + include(joinpath(@__DIR__, "models", file)) + end + end +end