From dcc5488cbea33ea5b18ab486c9f048779f4c64a2 Mon Sep 17 00:00:00 2001 From: raphasampaio Date: Thu, 30 Apr 2026 13:54:02 -0300 Subject: [PATCH 1/4] Update --- Project.toml | 2 +- src/metaheuristic/random_swap.jl | 12 ++++++- src/result/counts.jl | 4 +++ test/test_discard_empty_clusters.jl | 56 +++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 test/test_discard_empty_clusters.jl diff --git a/Project.toml b/Project.toml index f41791ab..2f1220ce 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/src/metaheuristic/random_swap.jl b/src/metaheuristic/random_swap.jl index 3103fe5f..e906d367 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) + discarded = meta.discard_empty_clusters && has_empty_clusters(result) + + if !discarded && 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 && discarded + 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 ac3352a9..0b38015d 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 \ 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 00000000..91397ef6 --- /dev/null +++ b/test/test_discard_empty_clusters.jl @@ -0,0 +1,56 @@ +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 From 29527fdcb148e6eb8fa2f9b5f949e5b630241715 Mon Sep 17 00:00:00 2001 From: raphasampaio Date: Thu, 30 Apr 2026 13:56:11 -0300 Subject: [PATCH 2/4] Update --- src/metaheuristic/random_swap.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/metaheuristic/random_swap.jl b/src/metaheuristic/random_swap.jl index e906d367..08db7f61 100644 --- a/src/metaheuristic/random_swap.jl +++ b/src/metaheuristic/random_swap.jl @@ -86,9 +86,9 @@ function fit(meta::RandomSwap{LS}, data::AbstractMatrix{<:Real}, k::Integer) whe print_result(result) end - discarded = meta.discard_empty_clusters && has_empty_clusters(result) + discard = meta.discard_empty_clusters && has_empty_clusters(result) - if !discarded && isbetter(result, best_result) + if !discard && isbetter(result, best_result) best_result = result iterations_without_improvement = 0 @@ -98,7 +98,7 @@ function fit(meta::RandomSwap{LS}, data::AbstractMatrix{<:Real}, k::Integer) whe else iterations_without_improvement += 1 - if meta.verbose && discarded + if meta.verbose && discard print_string("(discarded: empty cluster)") end From 284952165ff14def35f68696d1a61b3187880255 Mon Sep 17 00:00:00 2001 From: raphasampaio Date: Thu, 30 Apr 2026 13:57:47 -0300 Subject: [PATCH 3/4] Update --- src/result/counts.jl | 2 +- test/test_discard_empty_clusters.jl | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/result/counts.jl b/src/result/counts.jl index 0b38015d..cdc14e14 100644 --- a/src/result/counts.jl +++ b/src/result/counts.jl @@ -4,4 +4,4 @@ end function has_empty_clusters(result::AbstractResult) return any(==(0), counts(result)) -end \ No newline at end of file +end diff --git a/test/test_discard_empty_clusters.jl b/test/test_discard_empty_clusters.jl index 91397ef6..5915a2b8 100644 --- a/test/test_discard_empty_clusters.jl +++ b/test/test_discard_empty_clusters.jl @@ -18,7 +18,11 @@ function UnsupervisedClustering.fit(::EmptyingLocalSearch, data::AbstractMatrix{ return result end -function UnsupervisedClustering.fit!(::EmptyingLocalSearch, ::AbstractMatrix{<:Real}, result::UnsupervisedClustering.KmeansResult) +function UnsupervisedClustering.fit!( + ::EmptyingLocalSearch, + ::AbstractMatrix{<:Real}, + result::UnsupervisedClustering.KmeansResult, +) fill!(result.assignments, 1) result.objective = 1.0 return nothing From 32f8b2cd16dc96a943c189f976b3fe4e85235c23 Mon Sep 17 00:00:00 2001 From: raphasampaio Date: Thu, 30 Apr 2026 13:58:30 -0300 Subject: [PATCH 4/4] Update --- docs/docs.bat | 6 +++--- docs/docs.sh | 4 ++-- format/format.bat | 4 ++-- format/format.sh | 4 ++-- revise/revise.bat | 4 ++-- revise/revise.sh | 4 ++-- test/test.bat | 6 +++--- test/test.sh | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/docs.bat b/docs/docs.bat index 48934ecc..debc154a 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 79426347..44554d68 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 77915b70..d16faf71 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 646c8426..2621af53 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 d12e24d4..ffde339d 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 3e815f08..96cd8867 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/test/test.bat b/test/test.bat index a290856b..4d8681b9 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 7c2e11d8..4859d816 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