Table of Contents

How to run hierarchical clustering with SciPy

Hierarchical clustering preserves the order and distance of every merge instead of returning only a final partition. SciPy stores that nested structure in a linkage matrix, while the boundary that turns it into flat clusters remains a separate modeling decision.

The sample uses nine labeled observations with two already standardized numeric features. Ward linkage is stated explicitly with Euclidean distance because its variance-minimization calculation is defined for Euclidean geometry.

The ordered merge distances expose a wide gap between the last within-group merge and the first between-group merge. A distance cut of 1.0 falls inside that gap, and label sets verify the resulting memberships without depending on SciPy's arbitrary numeric cluster IDs.

Steps to build and cut a SciPy linkage hierarchy:

Prepare labeled observations

  1. Prepare hierarchical_clustering.py with SciPy imports, labels, and the standardized observation matrix.
    hierarchical_clustering.py
    import numpy as np
    from scipy.cluster.hierarchy import fcluster, linkage
     
     
    labels = np.array(
        [
            "web-1",
            "web-2",
            "web-3",
            "cache-1",
            "cache-2",
            "cache-3",
            "db-1",
            "db-2",
            "db-3",
        ]
    )
    observations = np.array(
        [
            [0.0, 0.1],
            [0.2, -0.1],
            [-0.2, 0.0],
            [0.1, 4.8],
            [-0.1, 5.1],
            [0.3, 5.0],
            [5.1, 4.9],
            [4.8, 5.2],
            [5.0, 5.1],
        ]
    )

    Each row is one observation and each column is one standardized numeric feature. Scale real columns before Ward clustering when their units or ranges differ.

Define the linkage boundary

  1. Declare Ward linkage with Euclidean distance for the standardized features.
    linkage_method = "ward"
    distance_metric = "euclidean"

    Ward, centroid, and median linkage are defined only for Euclidean pairwise distances.

  2. Construct the linkage matrix from the observation rows.
    linkage_matrix = linkage(
        observations,
        method=linkage_method,
        metric=distance_metric,
    )
  3. Extract the hierarchy's ordered merge distances for cut inspection.
    merge_distances = linkage_matrix[:, 2]

    Nine observations produce eight linkage rows. The third column records the distance at each successive merge.

Select and apply the flat-cluster cut

  1. Select a distance cut of 1.0 between the within-group and between-group merges.
    cut_distance = 1.0
  2. Assign a flat cluster ID to each observation at the selected distance cut.
    cluster_ids = fcluster(
        linkage_matrix,
        t=cut_distance,
        criterion="distance",
    )

    The distance criterion keeps observations together only while their cophenetic distance stays at or below the selected cut.

Report memberships independently of cluster numbers

  1. Group the observation labels by their assigned flat cluster ID.
    memberships = {}
    for label, cluster_id in zip(labels, cluster_ids):
        memberships.setdefault(int(cluster_id), []).append(label)
     
    membership_rows = sorted(
        sorted(members)
        for members in memberships.values()
    )
  2. Print the linkage choice, merge distances, cut, and sorted membership rows.
    print(f"linkage: {linkage_method} ({distance_metric})")
    print("merge_distances:", np.round(merge_distances, 3).tolist())
    print(f"cut_distance: {cut_distance:.1f}")
    print("memberships:")
    for members in membership_rows:
        print(f"  {', '.join(members)}")

Verify the expected grouping

  1. Define the expected memberships without using SciPy's numeric cluster IDs.
    expected_groups = {
        frozenset(["web-1", "web-2", "web-3"]),
        frozenset(["cache-1", "cache-2", "cache-3"]),
        frozenset(["db-1", "db-2", "db-3"]),
    }
  2. Reject any membership set that differs from the expected service groups.
    observed_groups = {
        frozenset(members)
        for members in membership_rows
    }
     
    if observed_groups != expected_groups:
        raise RuntimeError("Flat clusters did not match the expected groups")
     
    print("membership_check: passed")

Execute the completed hierarchy

  1. Execute the completed clustering script with Python 3.
    $ python3 hierarchical_clustering.py
    linkage: ward (euclidean)
    merge_distances: [0.224, 0.224, 0.283, 0.37, 0.387, 0.416, 8.431, 11.24]
    cut_distance: 1.0
    memberships:
      cache-1, cache-2, cache-3
      db-1, db-2, db-3
      web-1, web-2, web-3
    membership_check: passed

    The script exits with an error instead of printing membership_check: passed when any observed label set differs from the expected groups.