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.
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.
linkage_method = "ward" distance_metric = "euclidean"
Ward, centroid, and median linkage are defined only for Euclidean pairwise distances.
linkage_matrix = linkage( observations, method=linkage_method, metric=distance_metric, )
merge_distances = linkage_matrix[:, 2]
Nine observations produce eight linkage rows. The third column records the distance at each successive merge.
cut_distance = 1.0
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.
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() )
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)}")
expected_groups = { frozenset(["web-1", "web-2", "web-3"]), frozenset(["cache-1", "cache-2", "cache-3"]), frozenset(["db-1", "db-2", "db-3"]), }
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")
$ 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.