Skip to content
v0.1.3latest release121 skills4 categories6 sub-categories247 tested scripts397 reference docsContact us

cluster_tendency.py

Helper script for clustering-algorithm-selector · 160 lines

AI & Machine Learning / Classical Machine Learningv1.0.0Updated OCT 25 20264 references2 scripts
scripts/cluster_tendency.py
#!/usr/bin/env python3
"""Test whether numeric data has cluster structure at all before choosing a clustering algorithm.

Computes on standardised numeric columns (z-scores; --no-scale to skip):
  Hopkins statistic  H = sum(u) / (sum(u) + sum(w)), u = distance from uniform random points (in the
                     data's bounding box) to the nearest data point, w = distance from sampled data
                     points to their nearest other data point. H ~ 0.5: random / no structure;
                     H > 0.75: clustered; H < 0.3: regularly spaced. Averaged over --repeats runs.
  nearest-neighbour distance profile: coefficient of variation and the share of points whose
                     nearest-neighbour distance is > 3x the median (isolated points / noise)
  correlation check  column pairs with |r| > 0.7; strongly correlated features (points on a
                     low-dimensional manifold) also push H toward 1 without any clusters, so H is
                     reported with that caveat - re-run on a few principal components or uncorrelated
                     features to confirm
These are hints, not proof: clustering will always return clusters, this says whether they are likely
to mean anything.

Usage:
    python cluster_tendency.py data.csv --drop id label
    python cluster_tendency.py data.csv --columns x1 x2 x3 --sample 300 --repeats 10
    python cluster_tendency.py data.csv --json

Exit codes: 0 clustered (H > 0.7), 1 no clear structure, 2 usage or read error.
"""

import argparse
import csv
import json
import math
import random
import sys


def num(v):
    try:
        x = float(v)
        return x if math.isfinite(x) else None
    except (TypeError, ValueError):
        return None


def load(path, columns, drop):
    with (sys.stdin if path == "-" else open(path, newline="", encoding="utf-8-sig")) as fh:
        rd = csv.DictReader(fh)
        header = rd.fieldnames or []
        rows = list(rd)
    cols = columns or [c for c in header if c not in drop and all(num(r[c]) is not None for r in rows if (r[c] or "").strip())]
    missing = [c for c in cols if c not in header]
    if missing:
        raise ValueError(f"columns not found: {missing}")
    data = []
    for r in rows:
        x = [num(r[c]) for c in cols]
        if all(v is not None for v in x):
            data.append(x)
    return cols, data


def standardise(data):
    d = len(data[0])
    means = [sum(r[j] for r in data) / len(data) for j in range(d)]
    sds = [math.sqrt(sum((r[j] - means[j]) ** 2 for r in data) / len(data)) or 1.0 for j in range(d)]
    return [[(r[j] - means[j]) / sds[j] for j in range(d)] for r in data]


def nn_dist(p, data, skip=None):
    best = float("inf")
    for i, q in enumerate(data):
        if i == skip:
            continue
        s = sum((a - b) ** 2 for a, b in zip(p, q))
        if s < best:
            best = s
    return math.sqrt(best)


def hopkins(data, m, rng, power=1):
    d = len(data[0])
    lo = [min(r[j] for r in data) for j in range(d)]
    hi = [max(r[j] for r in data) for j in range(d)]
    idx = rng.sample(range(len(data)), m)
    w = [nn_dist(data[i], data, skip=i) for i in idx]
    u = [nn_dist([rng.uniform(lo[j], hi[j]) for j in range(d)], data) for _ in range(m)]
    # power=1: original Hopkins; power=d: the dimension-adjusted form (sharper, but more sensitive to correlation)
    su, sw = sum(x ** power for x in u), sum(x ** power for x in w)
    return su / (su + sw)


def correlated_pairs(X, cols, cut=0.7):
    """Pairs of standardised columns with |correlation| > cut."""
    n, d = len(X), len(X[0])
    out = []
    for i in range(d):
        for j in range(i + 1, d):
            r = sum(row[i] * row[j] for row in X) / n
            if abs(r) > cut:
                out.append((cols[i], cols[j], round(r, 2)))
    return out


def main():
    ap = argparse.ArgumentParser(description="Cluster tendency (Hopkins statistic and friends).", epilog=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("csv")
    ap.add_argument("--columns", nargs="*")
    ap.add_argument("--drop", nargs="*", default=[])
    ap.add_argument("--sample", type=int, default=0, help="points per Hopkins run (default: 10%% of rows, 20..200)")
    ap.add_argument("--repeats", type=int, default=5)
    ap.add_argument("--max-rows", type=int, default=3000, help="random subsample cap for O(n^2) steps")
    ap.add_argument("--no-scale", action="store_true")
    ap.add_argument("--power", choices=["1", "d"], default="1", help="distance power: 1 (classic) or d (dimension)")
    ap.add_argument("--seed", type=int, default=0)
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args()
    try:
        cols, data = load(a.csv, a.columns, set(a.drop))
    except (OSError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if len(data) < 20 or not cols:
        print("error: need >= 20 complete numeric rows", file=sys.stderr)
        return 2
    rng = random.Random(a.seed)
    if len(data) > a.max_rows:
        data = rng.sample(data, a.max_rows)
    X = data if a.no_scale else standardise(data)
    m = a.sample or max(20, min(200, len(X) // 10))
    pw = 1 if a.power == "1" else len(cols)
    hs = [hopkins(X, m, rng, pw) for _ in range(a.repeats)]
    H = sum(hs) / len(hs)
    nn = sorted(nn_dist(p, X, skip=i) for i, p in enumerate(X))
    med = nn[len(nn) // 2]
    mean_nn = sum(nn) / len(nn)
    cv = math.sqrt(sum((x - mean_nn) ** 2 for x in nn) / len(nn)) / mean_nn if mean_nn else 0.0
    isolated = sum(1 for x in nn if med and x > 3 * med) / len(nn)
    pairs = correlated_pairs(X if not a.no_scale else standardise(X), cols)
    verdict = ("clustered" if H > 0.75 else "weakly clustered" if H > 0.6 else
               "no clear structure (close to random)" if H > 0.3 else "regularly spaced (anti-clustered)")
    out = {"rows": len(X), "columns": cols, "hopkins": round(H, 4), "hopkins_runs": [round(h, 4) for h in hs],
           "verdict": verdict, "nn_distance_cv": round(cv, 3), "isolated_share": round(isolated, 4),
           "correlated_pairs": pairs}
    if a.json:
        print(json.dumps(out, indent=2))
        return 0 if H > 0.7 else 1
    print(f"{len(X)} rows x {len(cols)} columns ({'raw' if a.no_scale else 'standardised'}); Hopkins sample {m} x {a.repeats}")
    print(f"Hopkins H = {H:.3f}  (runs {min(hs):.3f}..{max(hs):.3f}) -> {verdict}")
    print(f"nearest-neighbour distance CV {cv:.2f}; {isolated:.1%} of points are isolated (> 3x median NN distance)"
          + (" -> density-based methods (DBSCAN/HDBSCAN) can label them as noise" if isolated > 0.02 else ""))
    if pairs:
        print(f"{len(pairs)} column pair(s) with |r| > 0.7 (e.g. {pairs[0][0]}~{pairs[0][1]} r={pairs[0][2]}): correlated "
              "features alone push H toward 1; confirm on a few principal components or decorrelated features")
    if H <= 0.6:
        print("Clusters found on this data are likely arbitrary partitions; consider segmentation by business rules "
              "or different features.")
    return 0 if H > 0.7 else 1


if __name__ == "__main__":
    sys.exit(main())
More in Classical Machine Learning

anomaly-detection-model-builder

Build and evaluate anomaly detection for tabular, sensor and transaction data with few or no labels: define the anomaly type (point, contextual, collective), choose detectors (robust z-score/MAD and IQR rules, Mahalanobis with robust covariance, k-NN distance, LOF, Isolation Forest, one-class SVM, PCA reconstruction / autoencoders, forecasting residuals for time series), combine them by rank, explain each alert by its deviating features, set thresholds from an alert budget or the score distribution, and evaluate with precision@k, PR-AUC and recall on known incidents, or detector agreement when no labels exist. Includes pure-Python detectors (robust z, Mahalanobis, kNN, Isolation Forest, ensemble) and an evaluation script.

catboost-model-builder

Build CatBoost models that use its strengths correctly: raw categorical columns as cat_features (ordered target statistics, no manual encoding), text_features, Pool construction with NaN-safe categories, ordered boosting and has_time for temporal data, symmetric (oblivious) trees, overfitting detector with eval_set and use_best_model, losses for classification, regression (Quantile, Poisson, Tweedie) and ranking (YetiRank), class weights, monotone constraints, feature importance (PredictionValuesChange, LossFunctionChange) and SHAP values, GPU training and model export. Plans cat/text/numeric columns from a CSV and generates a runnable training script, and checks parameter sets for the conflicts CatBoost rejects (iterations vs n_estimators, Bayesian bootstrap with subsample, multiple class-weight options, depth > 16, use_best_model without eval_set).

categorical-encoding-selector

Choose and implement the right encoding for every categorical column given the model family and the data: one-hot (with infrequent-level grouping and handle_unknown), ordinal with an explicit order, cross-fitted target encoding (scikit-learn TargetEncoder, CatBoost ordered statistics, category_encoders inside CV), frequency/count encoding, feature hashing, native categorical support in LightGBM, HistGradientBoosting, XGBoost and CatBoost, prefix levels for codes (zip3, NAICS-2), and embeddings. Measures cardinality, rare-level share, unseen-level rate on a time-ordered holdout and target signal per column, emits the ColumnTransformer, and quantifies target-encoding leakage (in-sample vs out-of-fold vs holdout AUC).

classification-model-selector

Choose the right classification algorithm for tabular, text or mixed data and prove the choice with a fair bake-off: logistic regression (L2, L1, elastic net), linear SVM, naive Bayes, k-NN, RBF SVM, decision trees, random forest, extra trees, HistGradientBoosting, LightGBM, XGBoost, CatBoost, explainable boosting machines (EBM/GAM), points scorecards and MLPs, matched to data size, width, categorical and high-cardinality share, missingness, class imbalance, text, and constraints such as interpretability, monotonic constraints, calibrated probabilities, latency budgets and tiny deployable models. Ranks candidates with starting hyperparameters and cautions, then generates a bake-off script running every candidate on identical CV folds with per-fold metrics, fit time and prediction latency.

collaborative-filtering-implementer

Implement collaborative filtering recommenders that beat popularity: item-based kNN with cosine, Jaccard or asymmetric similarity, shrinkage, neighbourhood size, positive-only or mean-centred rating signals and 'because you liked X' explanations; implicit ALS with confidence weighting, BPR pairwise ranking and the closed-form EASE model in plain NumPy, tuned on a temporal validation split by recall@k and NDCG@k and refit on all data; plus user-kNN, explicit matrix factorisation, the implicit/LightFM libraries, hyperparameter ranges, cold-start fallbacks, scaling with sparse matrices and approximate nearest neighbours. Produces top-k lists in the format of the recommendation evaluator.

counterfactual-explanation-generator

Generate counterfactual explanations and algorithmic recourse: the smallest realistic change to a row's features that changes the model's decision. Solves exact minimal-cost recourse for linear and logistic models (MAD-scaled L1 cost, immutable features, increase/decrease directions, bounds, integer and binary features, diverse alternatives, features every counterfactual must change, and a no-recourse finding when immutable features decide), generates DiCE counterfactuals for any scikit-learn model with constraints turned into permitted ranges and every result re-predicted, and validates counterfactuals from any method for constraint violations, implausible values, off-manifold rows, broken derived-feature relations and failure to flip the prediction.