diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b57e3..062beff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,12 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - version: [1.6, 1] + version: [1.9, 1] arch: [x64] os: [ubuntu-latest] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Julia Setup uses: julia-actions/setup-julia@v1 with: @@ -45,10 +45,12 @@ jobs: uses: julia-actions/julia-processcoverage@v1 if: ${{ startsWith(matrix.os, 'ubuntu') && (matrix.version == '1') }} - name: Coverage Upload - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 if: ${{ startsWith(matrix.os, 'ubuntu') && (matrix.version == '1') }} with: file: lcov.info + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Setup Node for Percy if: ${{ matrix.version == '1' }} uses: actions/setup-node@v3 diff --git a/.github/workflows/documenter.yml b/.github/workflows/documenter.yml index 4ebdbde..6049df5 100644 --- a/.github/workflows/documenter.yml +++ b/.github/workflows/documenter.yml @@ -16,7 +16,7 @@ jobs: name: Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: julia-actions/cache@v1 with: cache-compiled: "true" diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 072396c..934a03b 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -20,19 +20,19 @@ jobs: permissions: write-all strategy: matrix: - julia-version: [1.6] + julia-version: [1.9] steps: - uses: julia-actions/setup-julia@latest with: version: ${{ matrix.julia-version }} - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Instantiate `format` environment and format + shell: julia --color=yes -O0 {0} run: | - julia -e' using Pkg; Pkg.activate() Pkg.add("JuliaFormatter") using JuliaFormatter - format(".", YASStyle())' + format(".", YASStyle()) - uses: reviewdog/action-suggester@v1 if: github.event_name == 'pull_request' with: diff --git a/.gitignore b/.gitignore index d315dfa..f0d4b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ docs/site/ # committed for packages, but should be committed for applications that require a static # environment. Manifest.toml +Manifest-v*.toml # Editor files .vscode/* *~ +coverage/lcov.info diff --git a/Project.toml b/Project.toml index 994b2f2..03a8580 100644 --- a/Project.toml +++ b/Project.toml @@ -1,10 +1,9 @@ name = "BoxCox" uuid = "1248164d-f7a6-4bdb-8e8d-8c4a187b3ce6" authors = ["Phillip Alday "] -version = "0.2.4" +version = "0.3.0" [deps] -Compat = "34da2185-b29b-5c13-b0c7-acf172513d20" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" @@ -32,10 +31,9 @@ BoxCoxStatsModelsExt = ["StatsModels", "Tables"] [compat] Aqua = "0.6" -Compat = "3.29, 4" DocStringExtensions = "0.9" LinearAlgebra = "1" -Makie = "0.19.0, 0.20" +Makie = "0.19, 0.20" MixedModels = "4" NLopt = "0.6, 1" PrecompileTools = "1" @@ -47,12 +45,11 @@ StatsBase = "0.33, 0.34" StatsFuns = "1" StatsModels = "0.6, 0.7" Tables = "1" -julia = "1.6" +julia = "1.9" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" -Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" MixedModels = "ff71e718-51f3-5ec2-a782-8ffcbfa3c316" RDatasets = "ce6b1742-4840-55fa-b093-852dadbb1d8b" StatsModels = "3eaba693-59b7-5ba5-a881-562e759f1c8d" diff --git a/docs/Project.toml b/docs/Project.toml index bf38072..509084b 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -9,4 +9,4 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" StatsModels = "3eaba693-59b7-5ba5-a881-562e759f1c8d" [compat] -Documenter = "0.27" +Documenter = "1.3" diff --git a/docs/make.jl b/docs/make.jl index 7236046..29902a0 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -5,7 +5,6 @@ using BoxCox makedocs(; root=joinpath(dirname(pathof(BoxCox)), "..", "docs"), sitename="BoxCox", doctest=true, - strict=true, pages=["index.md", "mixed-models.md", "api.md"]) diff --git a/docs/src/mixed-models.md b/docs/src/mixed-models.md index 42f1e76..a900e00 100644 --- a/docs/src/mixed-models.md +++ b/docs/src/mixed-models.md @@ -5,8 +5,7 @@ CurrentModule = BoxCox ``` BoxCox.jl supports finding fitting the Box-Cox transformation to a `LinearMixedModel` from MixedModels.jl. -On Julia 1.9 and above, this support is done via a package extension and so the user pays no dependency or precompilation penalty when this functionality is not used. -On Julia 1.6 to 1.8, this functionality is defined unconditionally (thus incurring the dependency and precompilation penalty), but neither the `@formula` macro nor the `MixedModel` type are re-exported, so MixedModels.jl must still be loaded to use this functionality. +This support is done via a package extension and so the user pays no dependency or precompilation penalty when this functionality is not used. Let us examine the classic sleepstudy dataset from MixedModels.jl. First, we load the necessary packages. @@ -77,7 +76,7 @@ model_bc = fit(MixedModel, dataset(:sleepstudy)) ``` -For our original model on the untransformed scale, the intercept was approximately 250, which means that the average response time was about 250 milliseconds. +For our original model on the untransformed scale, the intercept was approximately 250, which means that the average response time was about 250 milliseconds. For the model on the speed scale, we have an intercept about approximately 4, which means that the average response speed is about 4 responses per second, which implies that the the average response time is 250 milliseconds. In other words, our new results are compatible with our previous estimates. diff --git a/ext/BoxCoxMakieExt.jl b/ext/BoxCoxMakieExt.jl index 77f8fd2..d893f16 100644 --- a/ext/BoxCoxMakieExt.jl +++ b/ext/BoxCoxMakieExt.jl @@ -5,32 +5,17 @@ using Makie using BoxCox: _loglikelihood_boxcox!, _loglikelihood_boxcox, - qr, chisqinvcdf, @compat, + qr, chisqinvcdf, @setup_workload, @compile_workload # XXX it would be great to have a 1-1 aspect ratio here, # but this seems like something that should be done upstream -function Makie.convert_arguments(P::Type{<:Makie.QQNorm}, x::BoxCoxTransformation, args...; - qqline=:fitrobust, kwargs...) - return convert_arguments(P, x.(x.y), args...; qqline, kwargs...) +function Makie.qqnorm!(ax::Axis, bc::BoxCoxTransformation, args...; kwargs...) + return qqnorm!(ax, bc.(bc.y), args...; kwargs...) end -function Makie.convert_arguments(P::Type{<:Union{Makie.Scatter,Makie.Lines}}, - bc::BoxCoxTransformation, args...; - λ=nothing, n_steps=21, kwargs...) - if isnothing(λ) - # TODO: cache this somehow so we're not computing it twice - # when also plotting the CI - ci = confint(bc; fast=true) - lower = first(ci) - 0.05 * abs(first(ci)) - upper = last(ci) + 0.05 * abs(last(ci)) - λ = range(lower, upper; length=n_steps) - end - sort!(collect(λ)) - - @compat (; X, y) = bc - ll = _loglikelihood_boxcox(X, y, λ) - return convert_arguments(P, λ, ll, args...; kwargs...) +function Makie.qqnorm(bc::BoxCoxTransformation, args...; kwargs...) + return qqnorm(bc.(bc.y), args...; kwargs...) end function BoxCox._loglikelihood_boxcox(X::AbstractMatrix{<:Number}, y::Vector{<:Number}, @@ -54,97 +39,50 @@ function BoxCox._loglikelihood_boxcox(::Nothing, y::Vector{<:Number}, return ll end -function Makie.convert_arguments(P::Type{<:Makie.VLines}, bc::BoxCoxTransformation, args...; - kwargs...) - return convert_arguments(P, bc.λ, args...; kwargs...) -end - -@recipe(BCPlot, boxcox) do scene - s_theme = default_theme(scene, Scatter) - l_theme = default_theme(scene, Lines) - automatic = Makie.automatic - scatline = Attributes(; color=l_theme.color, - colormap=l_theme.colormap, - # colorscale = l_theme.colorscale, - colorrange=get(l_theme.attributes, :colorrange, automatic), - linestyle=l_theme.linestyle, - linewidth=l_theme.linewidth, - markercolor=automatic, - markercolormap=theme(scene, :colormap), - markercolorrange=get(s_theme.attributes, :colorrange, automatic), - markersize=s_theme.markersize, - strokecolor=s_theme.strokecolor, - strokewidth=s_theme.strokewidth, - marker=s_theme.marker, - inspectable=theme(scene, :inspectable), - cycle=[:color]) - return merge(scatline, Attributes(; conf_level=0.95, n_steps=10, λ=nothing)) -end - -function Makie.plot!(p::BCPlot) - # markercolor is the same as linecolor if left automatic - # RGBColors -> union of all colortypes that `to_color` accepts + returns - real_markercolor = Observable{Makie.RGBColors}() - map!(real_markercolor, p.color, p.markercolor) do col, mcol - if mcol === Makie.automatic - return to_color(col) - else - return to_color(mcol) - end - end - - bc = p[1][] - n_steps = p.n_steps[] - λ = p.λ[] - # TODO use splines - scatterlines!(p, bc; λ, n_steps, - p.strokecolor, p.strokewidth, p.marker, p.markersize, - color=real_markercolor, p.linestyle, p.linewidth, p.colormap, # p.colorscale, - p.colorrange, p.inspectable) - # seem to be hitting some buggy behavior in Makie - # vlines!(p, bc; λ, - # p.color, linestyle=:dash, p.linewidth, p.colormap, # p.colorscale, - # p.colorrange, p.inspectable) - return plot +function BoxCox.boxcoxplot(bc::BoxCoxTransformation; kwargs...) + fig = Figure() + boxcoxplot!(Axis(fig[1, 1]), bc; kwargs...) + return fig end -BoxCox.boxcoxplot!(ax, bc::BoxCoxTransformation; kwargs...) = bcplot!(ax, bc; kwargs...) -BoxCox.boxcoxplot(bc::BoxCoxTransformation; kwargs...) = bcplot(bc; kwargs...) +function BoxCox.boxcoxplot!(ax::Axis, bc::BoxCoxTransformation; + xlabel="λ", + ylabel="log likelihood", + n_steps=21, + λ=nothing, + conf_level=nothing, + attributes...) + ax.xlabel = xlabel + ax.ylabel = ylabel -function Makie.plot!(ax::Axis, P::Type{<:BCPlot}, allattrs::Makie.Attributes, bc) - allattrs = merge(default_theme(P), allattrs) - plot = Makie.plot!(ax.scene, P, allattrs, bc) + ci = nothing - if haskey(allattrs, :title) - ax.title = allattrs.title[] - end - if haskey(allattrs, :xlabel) - ax.xlabel = allattrs.xlabel[] - else - ax.xlabel = "λ" - end - if haskey(allattrs, :ylabel) - ax.ylabel = allattrs.ylabel[] - else - ax.ylabel = "log likelihood" - end - # scatterlines!(ax, bc) - # the ylim error doesn't happen if we do this here - vlines!(ax, bc; bc.λ, linestyle=:dash, color=:black) - if haskey(allattrs, :conf_level) - level = allattrs.conf_level[] - lltarget = loglikelihood(bc) - chisqinvcdf(1, level) / 2 + if !isnothing(conf_level) + lltarget = loglikelihood(bc) - chisqinvcdf(1, conf_level) / 2 hlines!(ax, lltarget; linestyle=:dash, color=:black) - ci = confint(bc; level) + ci = confint(bc; level=conf_level) vlines!(ax, ci; linestyle=:dash, color=:black) - text = "$(round(Int, 100 * level))% CI" + text = "$(round(Int, 100 * conf_level))% CI" text!(ax, first(ci) + 0.05 * abs(first(ci)), lltarget; text) end + + if isnothing(λ) + ci = @something(ci, confint(bc; fast=true)) + lower = first(ci) - 0.05 * abs(first(ci)) + upper = last(ci) + 0.05 * abs(last(ci)) + λ = range(lower, upper; length=n_steps) + end + sort!(collect(λ)) + + (; X, y) = bc + ll = _loglikelihood_boxcox(X, y, λ) + + scatterlines!(ax, λ, ll; attributes...) + vlines!(ax, bc.λ; linestyle=:dash, color=:black) + return plot end -Makie.plottype(::BoxCoxTransformation) = BCPlot - @setup_workload begin # Putting some things in `@setup_workload` instead of `@compile_workload` can reduce the size of the # precompile file and potentially make loading faster. diff --git a/src/BoxCox.jl b/src/BoxCox.jl index 76a7a1e..7a2aac9 100644 --- a/src/BoxCox.jl +++ b/src/BoxCox.jl @@ -1,6 +1,5 @@ module BoxCox -using Compat using DocStringExtensions using LinearAlgebra # we use NLopt because that's what MixedModels uses and this was developed @@ -140,25 +139,29 @@ end """ boxcoxplot(bc::BoxCoxTransformation; kwargs...) - boxcoxplot!(axis, bc::BoxCoxTransformation; λ=nothing, n_steps=21) + boxcoxplot!(axis::Axis, bc::BoxCoxTransformation; + λ=nothing, n_steps=21, xlabel="λ", ylabel="log likelihood", + conf_level=nothing, attributes...) Create a diagnostic plot for the Box-Cox transformation. +The mutating method for `Axis` returns the (modified) original `Axis`. +The non-mutating method returns a `Figure`. + If λ is `nothing`, the range of possible values for the λ parameter is automatically determined, with a total of `n_steps`. If `λ` is a vector of numbers, then the λ parameter is evaluated at each element of that vector. -!!! note - You must load an appropriate Makie backend (e.g., CairoMakie or GLMakie) to actually render a plot. +If `conf_level` is `nothing`, then no confidence interval is displayed. + +`attributes` are forwarded to `scatterlines!`. !!! note A meaningful plot is only possible when `bc` has not been `empty!`'ed. -!!! compat "Julia 1.6" - The plotting functionality is defined unconditionally. - -!!! compat "Julia 1.9" +!!! note The plotting functionality interface is defined as a package extension and only loaded when Makie is available. + You must load an appropriate Makie backend (e.g., CairoMakie or GLMakie) to actually render a plot. """ function boxcoxplot!(::Any, ::PowerTransformation; kwargs...) # specialize slightly so that they can't just throw Any and get this message @@ -227,11 +230,7 @@ If a `LinearMixedModel` is provided, then `X` and `y` are extracted from the mod The formula interface is only available if StatsModels.jl is loaded either directly or via another package such GLM.jl or MixedModels.jl. -!!! compat "Julia 1.6" - - The formula interface is defined unconditionally, but `@formula` is not loaded. - - The MixedModels interface is defined unconditionally. - -!!! compat "Julia 1.9" +!!! note - The formula interface is defined as a package extension. - The MixedModels interface is defined as a package extension. @@ -427,12 +426,6 @@ function Base.show(io::IO, t::BoxCoxTransformation) return nothing end -if !isdefined(Base, :get_extension) - include("../ext/BoxCoxMakieExt.jl") - include("../ext/BoxCoxMixedModelsExt.jl") - include("../ext/BoxCoxStatsModelsExt.jl") -end - @setup_workload begin # Putting some things in `@setup_workload` instead of `@compile_workload` can reduce the size of the # precompile file and potentially make loading faster. diff --git a/test/runtests.jl b/test/runtests.jl index 14935fa..5c01a87 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -11,9 +11,7 @@ struct FakeTransformation <: BoxCox.PowerTransformation end path(x) = joinpath(@__DIR__, "out", x) @testset "Aqua" begin - @static if VERSION >= v"1.9" - Aqua.test_all(BoxCox; ambiguities=false, piracy=true) - end + Aqua.test_all(BoxCox; ambiguities=false, piracy=true) end trees = rdataset("datasets", "trees") @@ -86,13 +84,18 @@ end @testset "plotting" begin vol = fit(BoxCoxTransformation, trees.Volume) qq = qqnorm(vol) + @test qq isa Makie.FigureAxisPlot save(path("qq.png"), qq) - p = plot(vol) - save(path("plot.png"), p) + qqfig = Figure(; title="QQNorm Mutating") + qqnorm!(Axis(qqfig[1, 1]; xlabel="Theoretical Quantiles", ylabel="Observed Quantiles"), + vol) + @test qqfig isa Makie.Figure + save(path("qqfig.png"), qqfig) bcp = boxcoxplot(vol) save(path("boxcox.png"), bcp) + @test bcp isa Makie.Figure volform = fit(BoxCoxTransformation, @formula(Volume ~ 1 + log(Height) + log(Girth)), @@ -167,7 +170,7 @@ end @testset "mixed models + makie integration" begin bcpmm = boxcoxplot(bc; conf_level=0.95, title="sleep study should use speed") - @test bcpmm isa Makie.FigureAxisPlot + @test bcpmm isa Makie.Figure save(path("boxcox_mixedmodel.png"), bcpmm) end end