From fee2ac1fa177e35c7368e026c83499cbc34eed8c Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Tue, 11 Feb 2025 17:10:06 +1300 Subject: [PATCH 1/7] typo --- src/LearnTestAPI.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LearnTestAPI.jl b/src/LearnTestAPI.jl index 9c15c20..a361ecd 100644 --- a/src/LearnTestAPI.jl +++ b/src/LearnTestAPI.jl @@ -1,7 +1,7 @@ """ LearnTestAPI -Module for testing implementations of the interfacde defined in +Module for testing implementations of the interface defined in [LearnAPI.jl](https://juliaai.github.io/LearnAPI.jl/dev/). If your package defines an object `learner` implementing the interface, then put something From c6022738fb4bd7322fe820466f774fe1ed95e32f Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Fri, 14 Feb 2025 17:37:51 +1300 Subject: [PATCH 2/7] add data front end for RidgeRegression --- Project.toml | 1 + src/LearnTestAPI.jl | 1 + src/learners/regression.jl | 54 +++++++++++++------------------------ test/learners/regression.jl | 2 +- 4 files changed, 22 insertions(+), 36 deletions(-) diff --git a/Project.toml b/Project.toml index 8ccfe04..00cb23c 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" IsURL = "ceb4388c-583f-448d-bb30-00b11e8c5682" LearnAPI = "92ad9a40-7767-427a-9ee6-6e577f1266cb" +LearnDataFrontEnds = "5cca22a3-9356-470e-ba1b-8268d0135a4b" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MLCore = "c2834f40-e789-41da-a90e-33b280584a8c" MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" diff --git a/src/LearnTestAPI.jl b/src/LearnTestAPI.jl index a361ecd..837dced 100644 --- a/src/LearnTestAPI.jl +++ b/src/LearnTestAPI.jl @@ -46,6 +46,7 @@ using LinearAlgebra using Random using Statistics using UnPack +import LearnDataFrontEnds include("tools.jl") include("logging.jl") diff --git a/src/learners/regression.jl b/src/learners/regression.jl index 63e416f..0549ec3 100644 --- a/src/learners/regression.jl +++ b/src/learners/regression.jl @@ -6,12 +6,14 @@ using LearnAPI using Tables using LinearAlgebra +import LearnDataFrontEnds as FrontEnds # # NAIVE RIDGE REGRESSION WITH NO INTERCEPTS -# We overload `obs` to expose internal representation of data. See later for a simpler -# variation using the `obs` fallback. +# We implement a canned data front end. See `BabyRidgeRegressor` below for a no-frills +# version. +# no docstring here; that goes with the constructor struct Ridge lambda::Float64 end @@ -43,28 +45,25 @@ Base.getindex(data::RidgeFitObs, I) = RidgeFitObs(data.A[:,I], data.names, data.y[I]) Base.length(data::RidgeFitObs) = length(data.y) -# observations for consumption by `fit`: -function LearnAPI.obs(::Ridge, data) - X, y = data - table = Tables.columntable(X) - names = Tables.columnnames(table) |> collect - RidgeFitObs(Tables.matrix(table)', names, y) -end +# add a canned data front end; `obs` will return objects of type `FrontEnds.Obs`: +const frontend = FrontEnds.Saffron(view=true) +LearnAPI.obs(learner::Ridge, data) = FrontEnds.fitobs(learner, data, frontend) +LearnAPI.obs(model::RidgeFitted, data) = obs(model, data, frontend) -# for involutivity: -LearnAPI.obs(::Ridge, data::RidgeFitObs) = data +# training data deconstructors: +LearnAPI.features(learner::Ridge, data) = LearnAPI.features(learner, data, frontend) +LearnAPI.target(learner::Ridge, data) = LearnAPI.target(learner, data, frontend) -# for observations: -function LearnAPI.fit(learner::Ridge, observations::RidgeFitObs; verbosity=1) +function LearnAPI.fit(learner::Ridge, observations::FrontEnds.Obs; verbosity=1) # unpack hyperparameters and data: lambda = learner.lambda - A = observations.A + A = observations.features names = observations.names - y = observations.y + y = observations.target # apply core learner: - coefficients = (A*A' + learner.lambda*I)\(A*y) # 1 x p matrix + coefficients = (A*A' + learner.lambda*I)\(A*y) # p x 1 matrix # determine crude feature importances: feature_importances = @@ -78,28 +77,13 @@ function LearnAPI.fit(learner::Ridge, observations::RidgeFitObs; verbosity=1) return RidgeFitted(learner, coefficients, feature_importances, names) end - -# for unprocessed `data = (X, y)`: LearnAPI.fit(learner::Ridge, data; kwargs...) = fit(learner, obs(learner, data); kwargs...) -# extracting stuff from training data: -LearnAPI.target(::Ridge, observations::RidgeFitObs) = observations.y -LearnAPI.features(::Ridge, observations::RidgeFitObs) = observations.A -LearnAPI.target(learner::Ridge, data) = - LearnAPI.target(learner, obs(learner, data)) - -# observations for consumption by `predict`: -LearnAPI.obs(::RidgeFitted, X) = Tables.matrix(X)' -LearnAPI.obs(::RidgeFitted, X::AbstractMatrix) = X - -# matrix input: -LearnAPI.predict(model::RidgeFitted, ::Point, observations::AbstractMatrix) = - observations'*model.coefficients - -# tabular input: -LearnAPI.predict(model::RidgeFitted, ::Point, Xnew) = - predict(model, Point(), obs(model, Xnew)) +LearnAPI.predict(model::RidgeFitted, ::Point, observations::FrontEnds.Obs) = + (observations.features)'*model.coefficients +LearnAPI.predict(model::RidgeFitted, kind_of_proxy, data) = + LearnAPI.predict(model, kind_of_proxy, obs(model, data)) # accessor function: LearnAPI.feature_importances(model::RidgeFitted) = model.feature_importances diff --git a/test/learners/regression.jl b/test/learners/regression.jl index f90b409..c213b01 100644 --- a/test/learners/regression.jl +++ b/test/learners/regression.jl @@ -26,7 +26,7 @@ learner = LearnTestAPI.Ridge(lambda=0.5) @test :(LearnAPI.obs) in LearnAPI.functions(learner) @test LearnAPI.target(learner, data) == y - @test LearnAPI.features(learner, data) == X + @test LearnAPI.features(learner, data).features == Tables.matrix(X)' # verbose fitting: @test_logs( From 1d165a29d15a12f8002c64c9bc028ba3b8c490f2 Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Fri, 14 Feb 2025 18:48:28 +1300 Subject: [PATCH 3/7] add front end to dimension_reduction.jl learner --- src/learners/dimension_reduction.jl | 36 ++++++++++++++++++++++------ src/learners/regression.jl | 4 +++- test/learners/dimension_reduction.jl | 1 + 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/learners/dimension_reduction.jl b/src/learners/dimension_reduction.jl index 20424c7..c9d577d 100644 --- a/src/learners/dimension_reduction.jl +++ b/src/learners/dimension_reduction.jl @@ -1,13 +1,14 @@ # This file defines `TruncatedSVD(; codim=1)` using LearnAPI -using LinearAlgebra +using LinearAlgebra +import LearnDataFrontEnds as FrontEnds # # DIMENSION REDUCTION USING TRUNCATED SVD DECOMPOSITION # Recall that truncated SVD reduction is the same as PCA reduction, but without -# centering. We suppose observations are presented as the columns of a `Real` matrix. +# centering. # Some struct fields are left abstract for simplicity. @@ -23,6 +24,11 @@ end Instantiate a truncated singular value decomposition algorithm for reducing the dimension of observations by `codim`. +Data can be provided to `fit` or `transform` in any form supported by the `Tarragon` data +front end at LearnDataFrontEnds.jl. However, the outputs of `transform` and +`inverse_transform` are always matrices. + + ```julia learner = Truncated() X = rand(3, 100) # 100 observations in 3-space @@ -49,10 +55,21 @@ end LearnAPI.learner(model::TruncatedSVDFitted) = model.learner -function LearnAPI.fit(learner::TruncatedSVD, X; verbosity=1) +# add a canned data front end; `obs` will return objects of type `FrontEnds.Obs`: +LearnAPI.obs(learner::TruncatedSVD, data) = + FrontEnds.fitobs(learner, data, FrontEnds.Tarragon()) +LearnAPI.obs(model::TruncatedSVDFitted, data) = + obs(model, data, FrontEnds.Tarragon()) + +# training data deconstructor: +LearnAPI.features(learner::TruncatedSVD, data) = + LearnAPI.features(learner, data, FrontEnds.Tarragon()) + +function LearnAPI.fit(learner::TruncatedSVD, observations::FrontEnds.Obs; verbosity=1) # unpack hyperparameters: codim = learner.codim + X = observations.features p, n = size(X) n ≥ p || error("Insufficient number observations. ") outdim = p - codim @@ -70,14 +87,19 @@ function LearnAPI.fit(learner::TruncatedSVD, X; verbosity=1) return TruncatedSVDFitted(learner, U, Ut, singular_values) end +LearnAPI.fit(learner::TruncatedSVD, data; kwargs...) = + LearnAPI.fit(learner, LearnAPI.obs(learner, data); kwargs...) -LearnAPI.transform(model::TruncatedSVDFitted, X) = model.Ut*X +LearnAPI.transform(model::TruncatedSVDFitted, observations::FrontEnds.Obs) = + model.Ut*(observations.features) +LearnAPI.transform(model::TruncatedSVDFitted, data) = + LearnAPI.transform(model, obs(model, data)) # convenience fit-transform: -LearnAPI.transform(learner::TruncatedSVD, X; kwargs...) = - transform(fit(learner, X; kwargs...), X) +LearnAPI.transform(learner::TruncatedSVD, data; kwargs...) = + transform(fit(learner, data; kwargs...), data) -LearnAPI.inverse_transform(model::TruncatedSVDFitted, W) = model.U*W +LearnAPI.inverse_transform(model::TruncatedSVDFitted, W::AbstractMatrix) = model.U*W # accessor function: function LearnAPI.extras(model::TruncatedSVDFitted) diff --git a/src/learners/regression.jl b/src/learners/regression.jl index 0549ec3..43c3155 100644 --- a/src/learners/regression.jl +++ b/src/learners/regression.jl @@ -21,7 +21,9 @@ end """ Ridge(; lambda=0.1) -Instantiate a ridge regression learner, with regularization of `lambda`. +Instantiate a ridge regression learner, with regularization of `lambda`. Data can be +provided to `fit` or `predict` in any form supported by the `Saffron` data front end at +LearnDataFrontEnds.jl. """ Ridge(; lambda=0.1) = Ridge(lambda) # LearnAPI.constructor defined later diff --git a/test/learners/dimension_reduction.jl b/test/learners/dimension_reduction.jl index ff2eae7..a7ade73 100644 --- a/test/learners/dimension_reduction.jl +++ b/test/learners/dimension_reduction.jl @@ -3,6 +3,7 @@ using LearnAPI using LearnTestAPI using StableRNGs using Statistics +using LinearAlgebra # synthesize test data: rng = StableRNG(123) From 7e5f65106a915d1e8879f8ad3f6a7c3ccbae3ac9 Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Fri, 14 Feb 2025 21:43:06 +1300 Subject: [PATCH 4/7] add learners/classification.jl and tests --- Project.toml | 15 +++- src/LearnTestAPI.jl | 1 + src/learners/classification.jl | 117 ++++++++++++++++++++++++++++++++ src/learners/ensembling.jl | 3 +- test/learners/classification.jl | 57 ++++++++++++++++ test/learners/ensembling.jl | 2 +- test/learners/regression.jl | 2 +- test/runtests.jl | 1 + 8 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 src/learners/classification.jl create mode 100644 test/learners/classification.jl diff --git a/Project.toml b/Project.toml index 00cb23c..26e751b 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,8 @@ authors = ["Anthony D. Blaom "] version = "0.2.1" [deps] +CategoricalArrays = "324d7699-5711-5eae-9e2f-1d82baa6b597" +CategoricalDistributions = "af321ab8-2d2e-40a6-b165-3d674595d28e" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" IsURL = "ceb4388c-583f-448d-bb30-00b11e8c5682" @@ -23,6 +25,8 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" UnPack = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" [compat] +CategoricalArrays = "0.10.8" +CategoricalDistributions = "0.1.15" Distributions = "0.25" InteractiveUtils = "<0.0.1, 1" IsURL = "0.2.0" @@ -47,7 +51,16 @@ Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +StatsModels = "3eaba693-59b7-5ba5-a881-562e759f1c8d" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" [targets] -test = ["DataFrames", "Distributions", "Random", "LinearAlgebra", "Statistics", "Tables"] +test = [ + "DataFrames", + "Distributions", + "Random", + "LinearAlgebra", + "Statistics", + "StatsModels", + "Tables", + ] diff --git a/src/LearnTestAPI.jl b/src/LearnTestAPI.jl index 837dced..2c3db16 100644 --- a/src/LearnTestAPI.jl +++ b/src/LearnTestAPI.jl @@ -53,6 +53,7 @@ include("logging.jl") include("testapi.jl") include("learners/static_algorithms.jl") include("learners/regression.jl") +include("learners/classification.jl") include("learners/ensembling.jl") # next learner excluded because of heavy dependencies: # include("learners/gradient_descent.jl") diff --git a/src/learners/classification.jl b/src/learners/classification.jl new file mode 100644 index 0000000..1607bb9 --- /dev/null +++ b/src/learners/classification.jl @@ -0,0 +1,117 @@ +# This file defines `ConstantClassifier()` + +using LearnAPI +import LearnDataFrontEnds as FrontEnds +import MLCore +import CategoricalArrays +import CategoricalDistributions +import CategoricalDistributions.OrderedCollections.OrderedDict +import CategoricalDistributions.Distributions.StatsBase.proportionmap + +# The implementation of a constant classifier below is not the simplest, but it +# demonstrates some patterns that apply more generally in classification. + +""" + ConstantClassifier() + +Instantiate a constant (dummy) classifier. Can predict `Point` or `Distribution` targets. + +""" +struct ConstantClassifier end + +struct ConstantClassifierFitted + learner::ConstantClassifier + probabilities + names::Vector{Symbol} + classes_seen + codes_seen + decoder +end + +LearnAPI.learner(model::ConstantClassifierFitted) = model.learner + +# add a data front end; `obs` will return objects with type `FrontEnds.Obs`: +const front_end = FrontEnds.Sage(code_type=:small) +LearnAPI.obs(learner::ConstantClassifier, data) = + FrontEnds.fitobs(learner, data, front_end) +LearnAPI.obs(model::ConstantClassifierFitted, data) = + obs(model, data, front_end) + +# data deconstructors: +LearnAPI.features(learner::ConstantClassifier, data) = + LearnAPI.features(learner, data, front_end) +LearnAPI.target(learner::ConstantClassifier, data) = + LearnAPI.target(learner, data, front_end) + +function LearnAPI.fit(learner::ConstantClassifier, observations::FrontEnds.Obs; verbosity=1) + y = observations.target # integer "codes" + names = observations.names + classes_seen = observations.classes_seen + codes_seen = sort(unique(y)) + decoder = observations.decoder + + d = proportionmap(y) + # proportions ordered by key, i.e., by codes seen: + probabilities = values(sort!(OrderedDict(d))) |> collect + + return ConstantClassifierFitted( + learner, + probabilities, + names, + classes_seen, + codes_seen, + decoder, + ) +end +LearnAPI.fit(learner::ConstantClassifier, data; kwargs...) = + fit(learner, obs(learner, data); kwargs...) + +function LearnAPI.predict( + model::ConstantClassifierFitted, + ::Point, + observations::FrontEnds.Obs, + ) + n = MLCore.numobs(observations) + idx = argmax(model.probabilities) + code_of_mode = model.codes_seen[idx] + return model.decoder.(fill(code_of_mode, n)) +end +LearnAPI.predict(model::ConstantClassifierFitted, ::Point, data) = + predict(model, Point(), obs(model, data)) + +function LearnAPI.predict( + model::ConstantClassifierFitted, + ::Distribution, + observations::FrontEnds.Obs, + ) + n = MLCore.numobs(observations) + probs = model.probabilities + # repeat vertically to get rows of a matrix: + probs_matrix = reshape(repeat(probs, n), (length(probs), n))' + return CategoricalDistributions.UnivariateFinite(model.classes_seen, probs_matrix) +end +LearnAPI.predict(model::ConstantClassifierFitted, ::Distribution, data) = + predict(model, Distribution(), obs(model, data)) + +# accessor function: +LearnAPI.feature_names(model::ConstantClassifierFitted) = model.names + +@trait( + ConstantClassifier, + constructor = ConstantClassifier, + kinds_of_proxy = (Point(),Distribution()), + tags = ("classification",), + functions = ( + :(LearnAPI.fit), + :(LearnAPI.learner), + :(LearnAPI.clone), + :(LearnAPI.strip), + :(LearnAPI.obs), + :(LearnAPI.features), + :(LearnAPI.target), + :(LearnAPI.predict), + :(LearnAPI.feature_names), + ) +) + +true diff --git a/src/learners/ensembling.jl b/src/learners/ensembling.jl index 2894d17..b634399 100644 --- a/src/learners/ensembling.jl +++ b/src/learners/ensembling.jl @@ -211,7 +211,7 @@ LearnAPI.components(model::EnsembleFitted) = [:atom => model.models,] # - `out_of_sample_losses` # For simplicity, this implementation is restricted to univariate features. The simplistic -# algorithm is explained in the docstring. of the data presented. +# algorithm is explained in the docstring. # ## HELPERS @@ -276,6 +276,7 @@ function update!( stump = Stump(ξ, left, right) push!(forest, stump) new_predictions = _predict(stump, x) + # efficient in-place update of `predictions`: predictions .= (k*predictions .+ new_predictions)/(k + 1) push!(training_losses, (predictions[training_indices] .- ytrain).^2 |> sum) diff --git a/test/learners/classification.jl b/test/learners/classification.jl new file mode 100644 index 0000000..3c0eb1d --- /dev/null +++ b/test/learners/classification.jl @@ -0,0 +1,57 @@ +using Test +using LearnTestAPI +using LearnAPI +import MLCore +using StableRNGs +import DataFrames +using Tables +import CategoricalArrays +import StatsModels: @formula +import CategoricalDistributions.pdf + +# # SYNTHESIZE LOTS OF DATASETS + +n = 2 +rng = StableRNG(345) +# has a "hidden" level, `C`: +t = CategoricalArrays.categorical(repeat("ABA", 3n)*"CC" |> collect)[1:3n] +c, a = randn(rng, 3n), rand(rng, 3n) +y = t +Y = (; t) + +# feature matrix: +x = hcat(c, a) |> permutedims + +# feature tables: +X = (; c, a) +X1, X2, X3, X4, X5 = X, +Tables.rowtable(X), +Tables.dictrowtable(X), +Tables.dictcolumntable(X), +DataFrames.DataFrame(X); + +# full tables: +T = (; c, t, a) +T1, T2, T3, T4, T5 = T, + Tables.rowtable(T), + Tables.dictrowtable(T), + Tables.dictcolumntable(T), + DataFrames.DataFrame(T); + +# StatsModels.jl @formula: +f = @formula(t ~ c + a) + + +# # TESTS + +learner = LearnTestAPI.ConstantClassifier() +@testapi learner (X1, y) +@testapi learner (X2, y) (X3, y) (X4, y) (T1, :t) (T2, :t) (T3, f) (T4, f) verbosity=0 + +@testset "extra tests for constant classifier" begin + model = fit(learner, (x, y)) + @test predict(model, x) == fill('A', 3n) + @test pdf.(predict(model, Distribution(), x), 'A') ≈ fill(2/3, 3n) +end + +true diff --git a/test/learners/ensembling.jl b/test/learners/ensembling.jl index 2464425..d4db7e6 100644 --- a/test/learners/ensembling.jl +++ b/test/learners/ensembling.jl @@ -30,7 +30,7 @@ learner = LearnTestAPI.Ensemble(atom; n=4, rng) @testset "extra tests for ensemble" begin @test LearnAPI.clone(learner) == learner @test LearnAPI.target(learner, data) == y - @test LearnAPI.features(learner, data) == X + @test LearnAPI.features(learner, data).features == Tables.matrix(X)' model = @test_logs( (:info, r"Trained 4 ridge"), diff --git a/test/learners/regression.jl b/test/learners/regression.jl index c213b01..1ee064c 100644 --- a/test/learners/regression.jl +++ b/test/learners/regression.jl @@ -22,7 +22,7 @@ data = (X, y) learner = LearnTestAPI.Ridge(lambda=0.5) @testapi learner data verbosity=1 -@testset "extra tests for ridge regression" begin +@testset "extra tests for ridge regressor" begin @test :(LearnAPI.obs) in LearnAPI.functions(learner) @test LearnAPI.target(learner, data) == y diff --git a/test/runtests.jl b/test/runtests.jl index bac12f1..75d8ed6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,6 +4,7 @@ test_files = [ "tools.jl", "learners/static_algorithms.jl", "learners/regression.jl", + "learners/classification.jl", "learners/ensembling.jl", # "learners/gradient_descent.jl", "learners/incremental_algorithms.jl", From aed8ef0b347f13b8a97df2ffa18c0abf2832eef5 Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Tue, 18 Feb 2025 09:50:28 +1300 Subject: [PATCH 5/7] add code comment --- src/learners/classification.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/learners/classification.jl b/src/learners/classification.jl index 1607bb9..93e214d 100644 --- a/src/learners/classification.jl +++ b/src/learners/classification.jl @@ -9,7 +9,8 @@ import CategoricalDistributions.OrderedCollections.OrderedDict import CategoricalDistributions.Distributions.StatsBase.proportionmap # The implementation of a constant classifier below is not the simplest, but it -# demonstrates some patterns that apply more generally in classification. +# demonstrates some patterns that apply more generally in classification, including +# inclusion of the canned data front end, `Sage`. """ ConstantClassifier() From 4b7f70818f8fae2fdb1ad5d081711d0df0e2c2dc Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Tue, 18 Feb 2025 11:17:09 +1300 Subject: [PATCH 6/7] update perceptron classifier: param replacement API change --- src/learners/gradient_descent.jl | 70 +++++++++++++------------------ src/testapi.jl | 15 ++++--- test/learners/gradient_descent.jl | 19 ++++----- 3 files changed, 47 insertions(+), 57 deletions(-) diff --git a/src/learners/gradient_descent.jl b/src/learners/gradient_descent.jl index 0f10bb3..f745e79 100644 --- a/src/learners/gradient_descent.jl +++ b/src/learners/gradient_descent.jl @@ -1,8 +1,8 @@ -# THIS FILE IS NOT INCLUDED +# THIS FILE IS NOT INCLUDED BUT EXISTS AS AN IMPLEMENTATION EXEMPLAR # This file defines: - # - `PerceptronClassifier(; epochs=50, optimiser=Optimisers.Adam(), rng=Random.default_rng()) + using LearnAPI using Random using Statistics @@ -49,7 +49,7 @@ end """ corefit(perceptron, optimiser, X, y_hot, epochs, state, verbosity) -Return updated `perceptron`, `state` and training losses by carrying out gradient descent +Return updated `perceptron`, `state`, and training losses by carrying out gradient descent for the specified number of `epochs`. - `perceptron`: component array with components `weights` and `bias` @@ -108,13 +108,7 @@ point predictions with `predict(model, Point(), Xnew)`. # Warm restart options - update_observations(model, newdata; replacements...) - -Return an updated model, with the weights and bias of the previously learned perceptron -used as the starting state in new gradient descent updates. Adopt any specified -hyperparameter `replacements` (properties of `LearnAPI.learner(model)`). - - update(model, newdata; epochs=n, replacements...) + update(model, newdata, :epochs=>n, other_replacements...; verbosity=1) If `Δepochs = n - perceptron.epochs` is non-negative, then return an updated model, with the weights and bias of the previously learned perceptron used as the starting state in @@ -123,17 +117,18 @@ instead of the previous training data. Any other hyperparaameter `replacements` adopted. If `Δepochs` is negative or not specified, instead return `fit(learner, newdata)`, where `learner=LearnAPI.clone(learner; epochs=n, replacements....)`. + update_observations(model, newdata, replacements...; verbosity=1) + +Return an updated model, with the weights and bias of the previously learned perceptron +used as the starting state in new gradient descent updates. Adopt any specified +hyperparameter `replacements` (properties of `LearnAPI.learner(model)`). + """ PerceptronClassifier(; epochs=50, optimiser=Optimisers.Adam(), rng=Random.default_rng()) = PerceptronClassifier(epochs, optimiser, rng) -# ### Data interface - -# For raw training data: -LearnAPI.target(learner::PerceptronClassifier, data::Tuple) = last(data) - -# For wrapping pre-processed training data (output of `obs(learner, data)`): +# Type for internal representation of data (output of `obs(learner, data)`): struct PerceptronClassifierObs X::Matrix{Float32} y_hot::BitMatrix # one-hot encoded target @@ -164,15 +159,19 @@ Base.getindex(observations::PerceptronClassifierObs, I) = PerceptronClassifierOb observations.classes, ) +# training data deconstructors: LearnAPI.target( learner::PerceptronClassifier, observations::PerceptronClassifierObs, ) = decode(observations.y_hot, observations.classes) - +LearnAPI.target(learner::PerceptronClassifier, data) = + LearnAPI.target(learner, obs(learner, data)) LearnAPI.features( learner::PerceptronClassifier, observations::PerceptronClassifierObs, ) = observations.X +LearnAPI.features(learner::PerceptronClassifier, data) = + LearnAPI.features(learner, obs(learner, data)) # Note that data consumed by `predict` needs no pre-processing, so no need to overload # `obs(model, data)`. @@ -229,9 +228,9 @@ LearnAPI.fit(learner::PerceptronClassifier, data; kwargs...) = # see the `PerceptronClassifier` docstring for `update_observations` logic. function LearnAPI.update_observations( model::PerceptronClassifierFitted, - observations_new::PerceptronClassifierObs; + observations_new::PerceptronClassifierObs, + replacements...; verbosity=1, - replacements..., ) # unpack data: @@ -243,7 +242,7 @@ function LearnAPI.update_observations( classes == model.classes || error("New training target has incompatible classes.") learner_old = LearnAPI.learner(model) - learner = LearnAPI.clone(learner_old; replacements...) + learner = LearnAPI.clone(learner_old, replacements...) perceptron = model.perceptron state = model.state @@ -255,15 +254,15 @@ function LearnAPI.update_observations( return PerceptronClassifierFitted(learner, perceptron, state, classes, losses) end -LearnAPI.update_observations(model::PerceptronClassifierFitted, data; kwargs...) = - update_observations(model, obs(LearnAPI.learner(model), data); kwargs...) +LearnAPI.update_observations(model::PerceptronClassifierFitted, data, args...; kwargs...) = + update_observations(model, obs(LearnAPI.learner(model), data), args...; kwargs...) # see the `PerceptronClassifier` docstring for `update` logic. function LearnAPI.update( model::PerceptronClassifierFitted, - observations::PerceptronClassifierObs; + observations::PerceptronClassifierObs, + replacements...; verbosity=1, - replacements..., ) # unpack data: @@ -275,8 +274,8 @@ function LearnAPI.update( classes == model.classes || error("New training target has incompatible classes.") learner_old = LearnAPI.learner(model) - learner = LearnAPI.clone(learner_old; replacements...) - :epochs in keys(replacements) || return fit(learner, observations) + learner = LearnAPI.clone(learner_old, replacements...) + :epochs in keys(replacements) || return fit(learner, observations; verbosity) perceptron = model.perceptron state = model.state @@ -284,15 +283,16 @@ function LearnAPI.update( epochs = learner.epochs Δepochs = epochs - learner_old.epochs - epochs < 0 && return fit(model, learner) + epochs < 0 && return fit(model, learner; verbosity) - perceptron, state, losses_new = corefit(perceptron, X, y_hot, Δepochs, state, verbosity) + perceptron, state, losses_new = + corefit(perceptron, X, y_hot, Δepochs, state, verbosity) losses = vcat(losses, losses_new) return PerceptronClassifierFitted(learner, perceptron, state, classes, losses) end -LearnAPI.update(model::PerceptronClassifierFitted, data; kwargs...) = - update(model, obs(LearnAPI.learner(model), data); kwargs...) +LearnAPI.update(model::PerceptronClassifierFitted, data, args...; kwargs...) = + update(model, obs(LearnAPI.learner(model), data), args...; kwargs...) # ### Predict @@ -335,13 +335,3 @@ LearnAPI.training_losses(model::PerceptronClassifierFitted) = model.losses :(LearnAPI.training_losses), ) ) - - -# ### Convenience methods - -LearnAPI.fit(learner::PerceptronClassifier, X, y; kwargs...) = - fit(learner, (X, y); kwargs...) -LearnAPI.update_observations(learner::PerceptronClassifier, X, y; kwargs...) = - update_observations(learner, (X, y); kwargs...) -LearnAPI.update(learner::PerceptronClassifier, X, y; kwargs...) = - update(learner, (X, y); kwargs...) diff --git a/src/testapi.jl b/src/testapi.jl index 48d6f38..377584c 100644 --- a/src/testapi.jl +++ b/src/testapi.jl @@ -163,12 +163,12 @@ macro testapi(learner, data...) if _is_static model = @logged_testset $FIT_IS_STATIC verbosity begin - LearnAPI.fit(learner; verbosity=verbosity-1) + LearnAPI.fit(learner; verbosity=verbosity - 1) end else model = @logged_testset $FIT_IS_NOT_STATIC verbosity begin - LearnAPI.fit(learner, data; verbosity=verbosity-1) + LearnAPI.fit(learner, data; verbosity=verbosity - 1) end end @@ -342,7 +342,7 @@ macro testapi(learner, data...) @logged_testset $SELECTED_FOR_FIT verbosity begin data3 = LearnTestAPI.learner_get(learner, data) if _data_interface isa LearnAPI.RandomAccess - LearnAPI.fit(learner, data3; verbosity=verbosity-1) + LearnAPI.fit(learner, data3; verbosity=verbosity - 1) else nothing end @@ -493,14 +493,19 @@ macro testapi(learner, data...) if :(LearnAPI.update) in _functions _is_static && throw($ERR_STATIC_UPDATE) @logged_testset $UPDATE verbosity begin - LearnAPI.update(model, data; verbosity=0) + LearnAPI.update(model, data; verbosity=verbosity - 1) end # only test hyperparameter replacement in case of iteration parameter: iter = LearnAPI.iteration_parameter(learner) if !isnothing(iter) @logged_testset $UPDATE_ITERATIONS verbosity begin n = getproperty(learner, iter) - newmodel = LearnAPI.update(model, data, iter=>n+1; verbosity=0) + newmodel = LearnAPI.update( + model, + data, + iter=>n+1; + verbosity=verbosity - 1, + ) newlearner = LearnAPI.clone(learner, iter=>n+1) Test.@test LearnAPI.learner(newmodel) == newlearner abinitiomodel = LearnAPI.fit(newlearner, data; verbosity=0) diff --git a/test/learners/gradient_descent.jl b/test/learners/gradient_descent.jl index d9183ed..c184e62 100644 --- a/test/learners/gradient_descent.jl +++ b/test/learners/gradient_descent.jl @@ -1,7 +1,7 @@ # THIS FILE IS NOT INCLUDED BY /test/runtests.jl because of heavy dependencies. The # source file, "/src/learners/gradient_descent.jl" is not included in the package, but # exits as a learner exemplar. Next line manually loads the source: -include(joinpath(@__DIR__, "..", "..", "src", "learners", "gradient_descent.jl") +include(joinpath(@__DIR__, "..", "..", "src", "learners", "gradient_descent.jl")) using Test using LearnAPI @@ -42,15 +42,10 @@ rng = StableRNG(123) learner = PerceptronClassifier(; optimiser=Optimisers.Adam(0.01), epochs=40, rng) -@testapi learner (X, y) verbosity=1 +@testapi learner (X, y) verbosity=0 # use verbosity=1 to debug -@testset "PerceptronClassfier" begin - @test LearnAPI.clone(learner) == learner - @test :(LearnAPI.update) in LearnAPI.functions(learner) - @test LearnAPI.target(learner, (X, y)) == y - @test LearnAPI.features(learner, (X, y)) == X - - model40 = fit(learner, Xtrain, ytrain; verbosity=0) +@testset "extra tests for perceptron classfier" begin + model40 = fit(learner, (Xtrain, ytrain); verbosity=0) # 40 epochs is sufficient for 90% accuracy in this case: @test sum(predict(model40, Point(), Xtest) .== ytest)/length(ytest) > 0.9 @@ -60,16 +55,16 @@ learner = @test predict(model40, Xtest) ≈ ŷ40 # add 30 epochs in an `update`: - model70 = update(model40, Xtrain, y[train]; verbosity=0, epochs=70) + model70 = update(model40, (Xtrain, y[train]), :epochs=>70; verbosity=0) ŷ70 = predict(model70, Xtest); @test !(ŷ70 ≈ ŷ40) # compare with cold restart: - model = fit(LearnAPI.clone(learner; epochs=70), Xtrain, y[train]; verbosity=0); + model = fit(LearnAPI.clone(learner; epochs=70), (Xtrain, y[train]); verbosity=0); @test ŷ70 ≈ predict(model, Xtest) # instead add 30 epochs using `update_observations` instead: - model70b = update_observations(model40, Xtrain, y[train]; verbosity=0, epochs=30) + model70b = update_observations(model40, (Xtrain, y[train]), :epochs=>30; verbosity=0) @test ŷ70 ≈ predict(model70b, Xtest) ≈ predict(model, Xtest) end From 51c9792f509b7d93adb150258ae2733e2a67cf1a Mon Sep 17 00:00:00 2001 From: "Anthony D. Blaom" Date: Tue, 18 Feb 2025 11:19:37 +1300 Subject: [PATCH 7/7] bump 0.2.2 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 26e751b..a8cd3ff 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "LearnTestAPI" uuid = "3111ed91-c4f2-40e7-bb19-7f6c618409b8" authors = ["Anthony D. Blaom "] -version = "0.2.1" +version = "0.2.2" [deps] CategoricalArrays = "324d7699-5711-5eae-9e2f-1d82baa6b597"