diff --git a/Project.toml b/Project.toml index f41791a..2f1220c 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "UnsupervisedClustering" uuid = "c6b3b713-2a2b-4e07-a35c-d57ac4f46186" authors = ["raphasampaio", "joaquimg", "mvpoggi", "vidalt"] -version = "0.4.6" +version = "0.4.7" [deps] Distances = "b4f34e82-e78d-54a5-968a-f98e89d6e8f7" diff --git a/docs/docs.bat b/docs/docs.bat index 48934ec..debc154 100644 --- a/docs/docs.bat +++ b/docs/docs.bat @@ -1,6 +1,6 @@ @echo off -SET BASEPATH=%~dp0 +SET BASE_PATH=%~dp0 -CALL julia +1.12 --project=%BASEPATH% -e "using Pkg; Pkg.develop(PackageSpec(path=dirname(pwd()))); Pkg.instantiate()" -CALL julia +1.12 --project=%BASEPATH% %BASEPATH%\make.jl \ No newline at end of file +CALL julia +1.12 --project=%BASE_PATH% -e "using Pkg; Pkg.develop(PackageSpec(path=dirname(pwd()))); Pkg.instantiate()" +CALL julia +1.12 --project=%BASE_PATH% %BASE_PATH%\make.jl \ No newline at end of file diff --git a/docs/docs.sh b/docs/docs.sh index 7942634..44554d6 100755 --- a/docs/docs.sh +++ b/docs/docs.sh @@ -1,6 +1,6 @@ #!/bin/bash -BASEPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +BASE_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" julia +1.12 --project -e "using Pkg; Pkg.develop(PackageSpec(path=dirname(pwd()))); Pkg.instantiate()" -julia +1.12 --project $BASEPATH/make.jl \ No newline at end of file +julia +1.12 --project $BASE_PATH/make.jl \ No newline at end of file diff --git a/format/format.bat b/format/format.bat index 77915b7..d16faf7 100644 --- a/format/format.bat +++ b/format/format.bat @@ -1,5 +1,5 @@ @echo off -SET BASEPATH=%~dp0 +SET BASE_PATH=%~dp0 -CALL julia +1.12 --project=%BASEPATH% %BASEPATH%\format.jl \ No newline at end of file +CALL julia +1.12 --project=%BASE_PATH% %BASE_PATH%\format.jl \ No newline at end of file diff --git a/format/format.sh b/format/format.sh index 646c842..2621af5 100755 --- a/format/format.sh +++ b/format/format.sh @@ -1,5 +1,5 @@ #!/bin/bash -BASEPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +BASE_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -julia +1.12 --project=$BASEPATH $BASEPATH/format.jl \ No newline at end of file +julia +1.12 --project=$BASE_PATH $BASE_PATH/format.jl \ No newline at end of file diff --git a/revise/revise.bat b/revise/revise.bat index d12e24d..ffde339 100644 --- a/revise/revise.bat +++ b/revise/revise.bat @@ -1,5 +1,5 @@ @echo off -SET BASEPATH=%~dp0 +SET BASE_PATH=%~dp0 -CALL julia +1.12 --project=%BASEPATH% --interactive --load=%BASEPATH%\revise.jl +CALL julia +1.12 --project=%BASE_PATH% --interactive --load=%BASE_PATH%\revise.jl diff --git a/revise/revise.sh b/revise/revise.sh index 3e815f0..96cd886 100755 --- a/revise/revise.sh +++ b/revise/revise.sh @@ -1,5 +1,5 @@ #!/bin/bash -BASEPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +BASE_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -julia +1.12 --project=$BASEPATH --interactive $BASEPATH/revise.jl \ No newline at end of file +julia +1.12 --project=$BASE_PATH --interactive $BASE_PATH/revise.jl \ No newline at end of file diff --git a/src/metaheuristic/random_swap.jl b/src/metaheuristic/random_swap.jl index 3103fe5..08db7f6 100644 --- a/src/metaheuristic/random_swap.jl +++ b/src/metaheuristic/random_swap.jl @@ -4,6 +4,7 @@ verbose::Bool = DEFAULT_VERBOSE max_iterations::Int = 200 max_iterations_without_improvement::Int = 150 + discard_empty_clusters::Bool = false ) where {LS <: AbstractAlgorithm} RandomSwap is a meta-heuristic approach used for clustering problems. It follows an iterative process that combines local optimization with perturbation to explore the search space effectively. A local optimization algorithm is applied at each iteration to converge toward a local optimum. Then, a perturbation operator generates a new starting point and continues the search. @@ -16,6 +17,7 @@ RandomSwap is a meta-heuristic approach used for clustering problems. It follows - `verbose`: controls whether the algorithm should display additional information during execution. - `max_iterations`: represents the maximum number of iterations the algorithm will perform before stopping. - `max_iterations_without_improvement`: represents the maximum number of iterations allowed without improving the best solution. +- `discard_empty_clusters`: when `true`, candidate solutions whose local-search result contains at least one empty cluster are rejected and never replace the current best solution. # References * Fränti, Pasi. @@ -27,6 +29,7 @@ Base.@kwdef struct RandomSwap{LS <: AbstractAlgorithm} <: AbstractAlgorithm verbose::Bool = DEFAULT_VERBOSE max_iterations::Int = 200 max_iterations_without_improvement::Int = 150 + discard_empty_clusters::Bool = false end @doc """ @@ -83,7 +86,9 @@ function fit(meta::RandomSwap{LS}, data::AbstractMatrix{<:Real}, k::Integer) whe print_result(result) end - if isbetter(result, best_result) + discard = meta.discard_empty_clusters && has_empty_clusters(result) + + if !discard && isbetter(result, best_result) best_result = result iterations_without_improvement = 0 @@ -92,6 +97,11 @@ function fit(meta::RandomSwap{LS}, data::AbstractMatrix{<:Real}, k::Integer) whe end else iterations_without_improvement += 1 + + if meta.verbose && discard + print_string("(discarded: empty cluster)") + end + if iterations_without_improvement > meta.max_iterations_without_improvement break end diff --git a/src/result/counts.jl b/src/result/counts.jl index ac3352a..cdc14e1 100644 --- a/src/result/counts.jl +++ b/src/result/counts.jl @@ -1,3 +1,7 @@ function counts(result::AbstractResult) return StatsBase.counts(result.assignments, result.k) end + +function has_empty_clusters(result::AbstractResult) + return any(==(0), counts(result)) +end diff --git a/test/test.bat b/test/test.bat index a290856..4d8681b 100644 --- a/test/test.bat +++ b/test/test.bat @@ -1,9 +1,9 @@ @echo off -SET BASEPATH=%~dp0 +SET BASE_PATH=%~dp0 IF "%~1"=="" ( - CALL julia +1.12 --project=%BASEPATH%\.. -e "import Pkg; Pkg.test()" + CALL julia +1.12 --project=%BASE_PATH%\.. -e "import Pkg; Pkg.test()" ) ELSE ( - CALL julia +1.12 --project=%BASEPATH%\.. %BASEPATH%\runtests.jl %1 + CALL julia +1.12 --project=%BASE_PATH%\.. %BASE_PATH%\runtests.jl %1 ) \ No newline at end of file diff --git a/test/test.sh b/test/test.sh index 7c2e11d..4859d81 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1,5 +1,5 @@ #!/bin/bash -BASEPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +BASE_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -julia +1.12 --project=$BASEPATH/.. -e "import Pkg; Pkg.test()" \ No newline at end of file +julia +1.12 --project=$BASE_PATH/.. -e "import Pkg; Pkg.test()" \ No newline at end of file diff --git a/test/test_discard_empty_clusters.jl b/test/test_discard_empty_clusters.jl new file mode 100644 index 0000000..5915a2b --- /dev/null +++ b/test/test_discard_empty_clusters.jl @@ -0,0 +1,60 @@ +module TestDiscardEmptyClusters + +using Random +using Test +using UnsupervisedClustering + +Base.@kwdef struct EmptyingLocalSearch <: UnsupervisedClustering.AbstractAlgorithm + rng::AbstractRNG = MersenneTwister(0) +end + +function UnsupervisedClustering.fit(::EmptyingLocalSearch, data::AbstractMatrix{<:Real}, k::Integer) + n, d = size(data) + result = UnsupervisedClustering.KmeansResult(zeros(Int, n), zeros(d, k)) + for i in 1:n + result.assignments[i] = mod(i - 1, k) + 1 + end + result.objective = 100.0 + return result +end + +function UnsupervisedClustering.fit!( + ::EmptyingLocalSearch, + ::AbstractMatrix{<:Real}, + result::UnsupervisedClustering.KmeansResult, +) + fill!(result.assignments, 1) + result.objective = 1.0 + return nothing +end + +@testset "RandomSwap - discard_empty_clusters" begin + data = rand(MersenneTwister(1), 50, 2) + k = 4 + + no_discard = RandomSwap( + local_search = EmptyingLocalSearch(rng = MersenneTwister(2)), + max_iterations = 5, + max_iterations_without_improvement = 100, + discard_empty_clusters = false, + verbose = true, + ) + result_no_discard = UnsupervisedClustering.fit(no_discard, data, k) + @test count(==(0), UnsupervisedClustering.counts(result_no_discard)) == k - 1 + @test result_no_discard.objective == 1.0 + + discard = RandomSwap( + local_search = EmptyingLocalSearch(rng = MersenneTwister(2)), + max_iterations = 5, + max_iterations_without_improvement = 100, + discard_empty_clusters = true, + verbose = true, + ) + result_discard = UnsupervisedClustering.fit(discard, data, k) + @test count(==(0), UnsupervisedClustering.counts(result_discard)) == 0 + @test result_discard.objective == 100.0 + + return nothing +end + +end