| 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); } }