Back to Documentations

Signature Description Parameters
#include <DataFrame/DataFrameMLVisitors.h>

template<typename T, typename I = unsigned long,
         std::size_t A = 0>
struct  SilhouetteScoreVisitor;

// -----------------------------------------

template<typename T, typename I = unsigned long,
         std::size_t A = 0>
using sil_score_v = SilhouetteScoreVisitor<T, I, A>;
Silhouette Score — Cluster Validation Metric

For each data point i the silhouette coefficient si is:
ai = mean intra-cluster distance to all other points in the same cluster
bi = mean nearest-cluster distance (mean distance to every point in the closest *other* cluster)
si = (bi − ai) / max(ai, bi)
          
si ∈ [−1, 1]:
+1 : point is well inside its own cluster and far from all others
0 : point sits on the boundary between two clusters
−1 : point is probably in the wrong cluster

The mean silhouette score over all points summarises overall cluster quality and is the standard model-selection criterion for choosing k in k-means.

Special cases handled:
Singleton cluster (only one labelled point in a cluster): si) = 0 because ai) is undefined.
Noise points (label == -1 or label == -2, matching DBSCAN convention): silhouette score is set to 0 and excluded from the mean.
When the number of distinct non-noise clusters is < 2 the silhouette score is undefined; all values are 0 and mean score is 0.

The two-column operator() receives:
column 1 (T): data values (scalar or MD container, same types accepted by KMeansVisitor / DBSCANVisitor)
column 2 (long): cluster label per point (use KMeansVisitor index in clusters_idxs_, or DBSCANVisitor cluster_ids reconstructed from get_clusters_idxs()/get_noisey_idxs())

Distance function:
The default is the same as KMeansVisitor / DBSCANVisitor: scalar T: (x − y)2 (squared Euclidean, fine for comparisons)
MD T: √Σ(xi − yi)2 (Euclidean)
Pass a custom lambda to the constructor to use cosine, Manhattan, etc.

Complexity:
Time: O(n2) — one pairwise distance per (i,j) pair.
Space: O(n) — accumulates per-cluster sums rather than the full matrix.

References:
  Rousseeuw, P.J. (1987). "Silhouettes: a graphical aid to the
  interpretation and validation of cluster analysis", Journal of
  Computational and Applied Mathematics 20: 53–65.
          
explicit
SilhouetteScoreVisitor(distance_func f = default_dist_());

using distance_func =
    std::function<double(const value_type &, const value_type &)>;

inline static distance_func
default_dist_()  {

    if constexpr (! is_md_)
        return ([](const T &x, const T &y) -> double  {
                    const double    d { static_cast<double>(x - y) };

                    return (d * d);
                });
     else
        return ([](const T &x, const T &y) -> double  {
                    double  sum { 0.0 };

                    for (size_type i { 0 }; i < size_type(x.size()); ++i)  {
                        const double    diff {
                            static_cast<double>(x[i]) -
                            static_cast<double>(y[i])
                        };

                        sum += diff * diff;
                    }
                    return (std::sqrt(sum));
                });
}
get_results() Per-point silhouette coefficients si ∈ [−1, 1]. Noise points in clusters with < 2 members have si = 0. Vector length equals the input column length.
get_mean_score() Mean silhouette score over all non-noise, non-singleton points. Range [−1, 1]; higher is better.
T: Column data type
I: Index type
A: Memory alignment boundary for vectors. Default is system default alignment
static void test_SilhouetteScoreVisitor()  {

    std::cout << "\nTesting SilhouetteScoreVisitor{ } ..." << std::endl;

    using MyDataFrame = StdDataFrame<unsigned long>;

    constexpr std::size_t   col_s { 100 };

    std::vector<unsigned long>  idx(col_s);

    std::iota(idx.begin(), idx.end(), 0UL);

    MyDataFrame df;

    df.load_index(std::move(idx));

    // Two clusters
    //
    {
        df.load_column("x1", std::vector<double>{ 0.0, 1.0, 10.0, 11.0 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl1", std::vector<long>{ 0, 0, 1, 1 }, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x1", "lbl1", sil);

        // All scores should be > 0.9 for perfectly separated clusters
        //
        for (const auto &s : sil.get_result())
            assert(s > 0.9);

        // Mean score ≈ same as individual scores
        //
        assert(sil.get_mean_score() > 0.9);
    }

    // Three tight clusters
    //
    {
        df.load_column("x2", std::vector<double>{ 0.0, 0.1, 0.2, 10.0, 10.1, 10.2, 20.0, 20.1, 20.2 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl2", std::vector<long>{ 0, 0, 0, 1, 1, 1, 2, 2, 2 }, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x2", "lbl2", sil);

        for (const auto &s : sil.get_result())
            assert(s > 0.99);
        assert(sil.get_mean_score() > 0.99);
    }

    // Overlapping clusters
    //
    {
        df.load_column("x3", std::vector<double>{ 0.0, 5.0, 2.0, 10.0 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl3", std::vector<long>{ 0, 0, 1, 1 }, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x3", "lbl3", sil);

        assert(std::abs(sil.get_result()[0] - 0.519231) < 1e-6);
        assert(std::abs(sil.get_result()[1] - -0.32) < 1e-2);
        assert(std::abs(sil.get_result()[2] - -0.898438) < 1e-6);
        assert(std::abs(sil.get_result()[3] - -0.0234375) < 1e-7);
        assert(sil.get_mean_score() < 0.0);
    }

    // Singleton clusters
    //
    {
        df.load_column("x4", std::vector<double>{ 0.0,  10.0, 11.0 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl4", std::vector<long>{ 0, 1, 1 }, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x4", "lbl4", sil);

        // Point 0 is a singleton -> score must be 0
        //
        assert(std::abs(sil.get_result()[0] - 0.0) < 1e-10);

        // Points 1 and 2 are in a proper cluster -> positive scores
        //
        assert(std::abs(sil.get_result()[1] - 0.99) < 1e-3);
        assert(std::abs(sil.get_result()[2] - 0.991736) < 1e-6);
        assert(std::abs(sil.get_mean_score() - 0.660579) < 1e-6);
    }

    // Noise points
    //
    {
        df.load_column("x5", std::vector<double>{ 5.0, 0.0, 1.0, 10.0, 11.0 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl5", std::vector<long>{ -1, 0, 0, 1, 1 }, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x5", "lbl5", sil);

        // Noise point score must be 0
        //
        assert(std::abs(sil.get_result()[0] - 0.0) < 1e-10);

        // Non-noise points should have high scores (well-separated clusters)
        //
        assert(std::abs(sil.get_result()[1] - 0.99095) < 1e-5);
        assert(std::abs(sil.get_result()[2] - 0.98895) < 1e-5);
        assert(std::abs(sil.get_result()[3] - 0.98895) < 1e-5);
        assert(std::abs(sil.get_result()[4] - 0.99095) < 1e-5);
        assert(std::abs(sil.get_mean_score() - 0.98995) < 1e-5);
    }

    // Single cluster
    //
    {
        df.load_column("x6", std::vector<double>{ 1.0, 2.0, 3.0, 4.0 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl6", std::vector<long>{ 0, 0, 0, 0 }, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x6", "lbl6", sil);

        // Cannot compute silhouette without at least 2 clusters
        //
        assert(sil.get_mean_score() == 0.0);
        for (const auto s : sil.get_result())
            assert(s == 0.0);
    }

    // Score range
    //
    {
        df.load_column("x6", std::vector<double>{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl6", std::vector<long>{ 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2}, nan_policy::dont_pad_with_nans);

        sil_score_v<double> sil;

        df.single_act_visit<double, long>("x6", "lbl6", sil);

        for (const auto s : sil.get_result())
            assert(s > 0.1 && s < 1.0);
        assert(std::abs(sil.get_mean_score() - 0.703015) < 1e-6);
    }

    // Custom distance
    //
    {
        df.load_column("x7", std::vector<double>{ 0.0, 1.0, 10.0, 11.0 }, nan_policy::dont_pad_with_nans);
        df.load_column("lbl7", std::vector<long>{ 0, 0, 1, 1 }, nan_policy::dont_pad_with_nans);

        // Absolute difference: dist(x, y) = |x - y|
        //
        auto               abs_dist =
            [](const double &x, const double &y) -> double  {
                return (std::abs(x - y));
            };
        sil_score_v<double> sil { abs_dist };

        df.single_act_visit<double, long>("x7", "lbl7", sil);

        assert(std::abs(sil.get_result()[0] - 0.904762) < 1e-6);
        assert(std::abs(sil.get_result()[1] - 0.894737) < 1e-6);
        assert(std::abs(sil.get_result()[2] - 0.894737) < 1e-6);
        assert(std::abs(sil.get_result()[3] - 0.904762) < 1e-6);
        assert(std::abs(sil.get_mean_score() - 0.899749) < 1e-6);
    }
}

C++ DataFrame