diff --git a/README.md b/README.md index e088b5f..ce87bd6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ general approach taken is to take the standard HDBSCAN algorithm and swap compon non-Euclidean data for scalable primitives. This often requires resorting to algorithms that are approximations of what is done in HDBSCAN, sometimes without approximation guarantees. -Note: This code works best if the input data is de-duplicated, with exact duplicate points removed. +Note: This code works best if the input data is de-duplicated, with exact duplicate points removed. If not, HDBSCAN will identify clusters with duplicate points as highly stable and always select them. @@ -84,7 +84,7 @@ source ./venv/bin/activate # In clams/build # -m: min cluster size # -s: min samples -python3 ../script/benchmark/hdbscan/run_hdbscan.py -m 10 -s 5 -p ../dataset/fashion-mnist/points.txt -g ../dataset/fashion-mnist/labels.txt +python3 ../script/benchmark/hdbscan_benchmark/run_hdbscan.py -m 10 -s 5 -p ../dataset/fashion-mnist/points.txt -g ../dataset/fashion-mnist/labels.txt ``` # License diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index 6cb80ae..81646e9 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -30,10 +30,14 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points print(f"After filtering: {len(true_labels)} ground truth points") non_noise_points_mask = (cluster_labels >= 0) - pct_clustered = (np.sum(non_noise_points_mask) / cluster_labels.shape[0]) - print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") + num_non_noise_points = np.sum(non_noise_points_mask) + pct_clustered = (num_non_noise_points / cluster_labels.shape[0]) + print(f"Cluster coverage (%): {pct_clustered * 100:.2f}%") - if len(non_noise_points_mask) < cluster_labels.shape[0]: # Has noise points + # Make sure always num_non_noise_points <= cluster_labels.shape[0] + assert num_non_noise_points <= cluster_labels.shape[0] + + if num_non_noise_points != cluster_labels.shape[0]: # Has noise points if singleton_cluster_to_noise_points: print( "Assigning a singleton cluster to each noise point in the clustering result") @@ -99,7 +103,7 @@ def find_files_in_dir(dir_path, ext=''): # If the first column is not a point ID, set has_ids to False. # If there are multiple files in a directory, the point IDs must be present. def read_point_data(data_path, has_ids=True): - print(f"Loading data from {data_path}") + print(f"Loading data from {data_path}", flush=True) files = find_files_in_dir(data_path) if len(files) == 0: @@ -137,7 +141,7 @@ def read_point_data(data_path, has_ids=True): points_table[pid] = list(map(float, items)) - print(f"Loaded {len(points_table)} items from {len(files)} files") + print(f"Loaded {len(points_table)} items from {len(files)} files", flush=True) # numpy array of feature vectors # if the IDs are not continuous, fill the missing IDs with -1 @@ -167,7 +171,7 @@ def read_point_data(data_path, has_ids=True): # # Both File types can also contain comment lines, which must start from #. def read_label_data(data_path): - print(f"Loading data from {data_path}") + print(f"Loading data from {data_path}", flush=True) labels_dict = {} files = [] if os.path.isdir(data_path): @@ -186,9 +190,9 @@ def read_label_data(data_path): break if contains_ids: - print("Loading point IDs and labels") + print("Loading point IDs and labels", flush=True) else: - print("Loading only labels") + print("Loading only labels", flush=True) if len(files) > 1 and not contains_ids: print("Multiple files are provided," @@ -220,8 +224,8 @@ def read_label_data(data_path): exit(1) labels_dict[pid] = label - print(f"Loaded {len(labels_dict)} items from {len(files)} files") - print(f"Max ID: {max_id}") + print(f"Loaded {len(labels_dict)} items from {len(files)} files", flush=True) + print(f"Max ID: {max_id}", flush=True) # numpy array of labels # if the IDs are not continuous, fill the missing IDs with -1 diff --git a/script/benchmark/extract_clams_clustering_time.sh b/script/benchmark/extract_clams_clustering_time.sh index 6cc017f..6e4d828 100755 --- a/script/benchmark/extract_clams_clustering_time.sh +++ b/script/benchmark/extract_clams_clustering_time.sh @@ -18,7 +18,7 @@ if [[ $# -eq 0 ]]; then exit 1 fi -printf "kNNG k,nodes,tasks/node,kNNG (s),MFC (s),AMST (s),CLAMS-HDBSCAN (s),Assigning noise points (s),file\n" +printf "kNNG k,nodes,tasks/node,kNNG (s),MFC (s),AMST (s),CLAMS-HDBSCAN (s),Noise clustering (s),file\n" for file in "$@"; do awk -v fname="$file" ' diff --git a/script/benchmark/hdbscan/run_hdbscan.py b/script/benchmark/hdbscan/run_hdbscan.py deleted file mode 100644 index e7a5ace..0000000 --- a/script/benchmark/hdbscan/run_hdbscan.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS -# Project Developers. See the top-level COPYRIGHT file for details. - - -# Description: This script generates a synthetic dataset and runs HDBSCAN on it. -# It also can read a point file and run HDBSCAN on it. -# Usage: -# python run_hdbscan.py -n 1000 -m 5 -# Or -# python run_hdbscan.py -p points.txt -m 5 - - -from sklearn.datasets import make_blobs -import hdbscan -import os -import argparse -import numpy as np -from clustering_utilities import * - - -def parse_options(): - parser = argparse.ArgumentParser( - description='Evaluate kNN index') - - # For input point (feature) data - parser.add_argument('-p', '--point_data_path', - dest='point_data_path', - required=False, action='store', type=str, - help='Input point file path') - - parser.add_argument('-I', '--has_ids', - dest='has_ids', - required=False, action='store_true', - help='If specified, the input point file has point IDs') - - # For generate synthetic data - parser.add_argument('-n', '--n_samples', - dest='n_samples', - required=False, action='store', type=int, - default=1000, - help='#of samples to generate by the data generator') - parser.add_argument('-f', '--n_features', - dest='n_features', - required=False, action='store', type=int, - default=32, - help='#of features to generate by the data generator') - - # HDBSCAN parameters - parser.add_argument('-m', '--min_cluster_size', - dest='min_cluster_size', - required=False, action='store', type=int, - default=5, - help='Minimum cluster size.') - parser.add_argument('-s', '--min_samples', - dest='min_samples', - required=False, action='store', type=int, - default=None, - help='#of samples in a neighborhood for a point to be considered as a core point.' - 'When None, defaults to min_cluster_size') - - # Output file paths - parser.add_argument('-o', '--cluster_labels_out_path', - dest='cluster_labels_out_path', - required=False, action='store', type=str, - default='out_clusters.txt', - help='File path to store computed cluster IDs') - parser.add_argument('-M', '--mst_out_path', - dest='mst_out_path', - required=False, action='store', type=str, - help='Output file path for intermediate MST data') - parser.add_argument('-c', '--condensed_tree_out_path', - dest='condensed_tree_out_path', - required=False, action='store', type=str, - default=None, - help='File path to store internal cluster data (mainly for debugging)') - parser.add_argument('-C', '--cluster_persistence_out_path', - dest='cluster_persistence_out_path', - required=False, action='store', type=str, - default=None, - help='File path to store cluster persistence data') - - # Ground truth file path - parser.add_argument('-g', '--ground_truth', - dest='gt_file', - required=False, action='store', type=str, - help='Ground truth file path') - - args = parser.parse_args() - return args - - -def run_hdbscan_kernel(points, min_cluster_size, min_samples, - gen_min_span_tree=False): - clusters = hdbscan.HDBSCAN(gen_min_span_tree=gen_min_span_tree, - min_cluster_size=min_cluster_size, - min_samples=min_samples, - core_dist_n_jobs=-1) - print(f'\nStart clustering') - show_time_now() - clusters.fit(points) - print(f'Finish clustering') - show_time_now() - - return clusters - - -def run_hdbscan(points, min_cluster_size, min_samples, - cluster_labels_out_path='out_clusters.txt', - gt_file=None, - mst_out_path=None, - condensed_tree_out_path=None, - cluster_persistence_out_path=None, - assign_cluster_to_noise=False): - clusters = run_hdbscan_kernel(points, min_cluster_size, min_samples, - gen_min_span_tree=(mst_out_path is not None)) - - # Evaluate the clustering quality - if gt_file: - print('\nLoading ground truth data') - gt_labels = read_label_data(gt_file) - - print('\nEvaluating clustering quality') - eval_clusters(clusters.labels_, gt_labels) - - if assign_cluster_to_noise: - print('\nAssigning a cluster ID to every noise point') - no_noise_labels = assign_singleton_cluster_to_noise_point( - clusters.labels_) - eval_clusters(no_noise_labels, gt_labels) - - # Save the condensed tree data - if condensed_tree_out_path: - print(f'\nSaving condensed tree data in {condensed_tree_out_path}') - clusters.condensed_tree_.to_pandas().to_csv(condensed_tree_out_path) - - # Save the MST data - if mst_out_path: - print(f'\nSaving MST data in {mst_out_path}') - with open(mst_out_path, 'w') as fout_mst: - mst = clusters.minimum_spanning_tree_.to_numpy() - # Format: Point0, Point1, Distance - for edge in mst: - fout_mst.write(f'{int(edge[0])}\t{int(edge[1])}\t{edge[2]}\n') - fout_mst.close() - show_time_now() - - # Save the cluster IDs. - if cluster_labels_out_path: - print(f'\nSaving cluster IDs in {cluster_labels_out_path}') - with open(cluster_labels_out_path, 'w') as fout: - fout.write(f'# Node ID\tCluster ID\n') - for i, label in enumerate(clusters.labels_): - fout.write(f'{i}\t{label}\n') - show_time_now() - print(f'Cluster IDs are saved in {cluster_labels_out_path}') - - # Save the cluster persistence data - if cluster_persistence_out_path: - print( - f'\nSaving cluster persistence data in {cluster_persistence_out_path}') - with open(cluster_persistence_out_path, 'w') as fout: - fout.write('Cluster ID\tPersistence\n') - for i, persistence in enumerate(clusters.cluster_persistence_): - fout.write(f'{i}\t{persistence}\n') - - -def main(): - opt = parse_options() - - show_time_now() - if opt.point_data_path: - points = read_point_data(opt.point_data_path, opt.has_ids) - else: - points, _ = make_blobs(n_samples=opt.n_samples, - n_features=opt.n_features, - centers=10) - print(f'points data shape: {points.shape}') - - run_hdbscan(points, opt.min_cluster_size, opt.min_samples, - gt_file=opt.gt_file, - cluster_labels_out_path=opt.cluster_labels_out_path, - mst_out_path=opt.mst_out_path, - condensed_tree_out_path=opt.condensed_tree_out_path, - cluster_persistence_out_path=opt.cluster_persistence_out_path) - - -if __name__ == '__main__': - main() diff --git a/script/benchmark/hdbscan/test_hdbscan_clustering.sh b/script/benchmark/hdbscan/test_hdbscan_clustering.sh deleted file mode 100755 index 8efab4c..0000000 --- a/script/benchmark/hdbscan/test_hdbscan_clustering.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash - -# Usage: -# cd /build -# ../script/test_hdbscan_clustering.sh - -mst_file="./blobs_mst.txt" -hdbscan_cluster_ids_file="./hdbscan_cluster_ids_file.txt" -num_samples=$((2**15)) -min_cluster_size=5 - -echo "Running HDBSCAN" -python3 ../script/run_hdbscan.py --n_samples $num_samples --min_cluster_size $min_cluster_size --mst_out_path ${mst_file} --cluster_labels_out_path ${hdbscan_cluster_ids_file} - - -cluster_ids_file="out_cluster_ids.txt" -echo "" -./src/run_hdbscan_clustering -i ${mst_file} -o ${cluster_ids_file} -m $min_cluster_size - -echo "" -echo "Reassigning cluster ids" -python3 ../script/reassign_sequential_cluster_ids.py -i ${hdbscan_cluster_ids_file} -o ${hdbscan_cluster_ids_file} - -echo "" -echo "Reassigning cluster ids" -python3 ../script/reassign_sequential_cluster_ids.py -i ${cluster_ids_file} -o ${cluster_ids_file} - -echo "" -echo "Running diff" -diff ${hdbscan_cluster_ids_file} ${cluster_ids_file} > diff.txt - -echo "#of lines in diff.txt:" -wc -l diff.txt - -echo "Done" \ No newline at end of file diff --git a/script/benchmark/run_hdbscan_bench.py b/script/benchmark/run_hdbscan_bench.py index 88b40d0..94b8a6c 100644 --- a/script/benchmark/run_hdbscan_bench.py +++ b/script/benchmark/run_hdbscan_bench.py @@ -11,9 +11,13 @@ """ import argparse +import hdbscan +import os +import numpy as np +import time + from clustering_utilities import * -from script.benchmark.bench_utilities import * -from script.benchmark.hdbscan.run_hdbscan import run_hdbscan +from bench_utilities import * def parse_options(): @@ -67,9 +71,87 @@ def parse_options(): return args +def run_hdbscan_kernel(points, min_cluster_size, min_samples, + gen_min_span_tree=False): + clusters = hdbscan.HDBSCAN(gen_min_span_tree=gen_min_span_tree, + min_cluster_size=min_cluster_size, + min_samples=min_samples, + core_dist_n_jobs=-1) + print(f'\nStart clustering', flush=True) + show_time_now() + # start timer + start_time = time.time() + clusters.fit(points) + print(f'Finish clustering in {time.time() - start_time:.2f} seconds', flush=True) + show_time_now() + + return clusters + + +def run_hdbscan(points, min_cluster_size, min_samples, + cluster_labels_out_path='out_clusters.txt', + gt_file=None, + mst_out_path=None, + condensed_tree_out_path=None, + cluster_persistence_out_path=None, + assign_cluster_to_noise=False): + clusters = run_hdbscan_kernel(points, min_cluster_size, min_samples, + gen_min_span_tree=(mst_out_path is not None)) + + # Evaluate the clustering quality + if gt_file: + print('\nLoading ground truth data') + gt_labels = read_label_data(gt_file) + + print('\nEvaluating clustering quality') + eval_clusters(clusters.labels_, gt_labels) + + if assign_cluster_to_noise: + print('\nAssigning a cluster ID to every noise point') + no_noise_labels = assign_singleton_cluster_to_noise_point( + clusters.labels_) + eval_clusters(no_noise_labels, gt_labels) + + # Save the condensed tree data + if condensed_tree_out_path: + print(f'\nSaving condensed tree data in {condensed_tree_out_path}') + clusters.condensed_tree_.to_pandas().to_csv(condensed_tree_out_path) + + # Save the MST data + if mst_out_path: + print(f'\nSaving MST data in {mst_out_path}') + with open(mst_out_path, 'w') as fout_mst: + mst = clusters.minimum_spanning_tree_.to_numpy() + # Format: Point0, Point1, Distance + for edge in mst: + fout_mst.write(f'{int(edge[0])}\t{int(edge[1])}\t{edge[2]}\n') + fout_mst.close() + show_time_now() + + # Save the cluster IDs. + if cluster_labels_out_path: + print(f'\nSaving cluster IDs in {cluster_labels_out_path}') + with open(cluster_labels_out_path, 'w') as fout: + fout.write(f'# Node ID\tCluster ID\n') + for i, label in enumerate(clusters.labels_): + fout.write(f'{i}\t{label}\n') + show_time_now() + print(f'Cluster IDs are saved in {cluster_labels_out_path}') + + # Save the cluster persistence data + if cluster_persistence_out_path: + print( + f'\nSaving cluster persistence data in {cluster_persistence_out_path}') + with open(cluster_persistence_out_path, 'w') as fout: + fout.write('Cluster ID\tPersistence\n') + for i, persistence in enumerate(clusters.cluster_persistence_): + fout.write(f'{i}\t{persistence}\n') + + def main(): opts = parse_options() + points = read_point_data(opts.point_data_path, opts.has_ids) min_cluster_size_list = parse_range(opts.min_cluster_size_range) @@ -77,9 +159,9 @@ def main(): for min_cluster_size in min_cluster_size_list: for min_samples in min_samples_list: - print(f'\n--------------------------------') - print((f"min_cluster_size: {min_cluster_size}")) - print((f"min_samples: {min_samples}")) + print(f'\n--------------------------------', flush=True) + print((f"min_cluster_size: {min_cluster_size}"), flush=True) + print((f"min_samples: {min_samples}"), flush=True) run_hdbscan(points, min_cluster_size, min_samples, gt_file=opts.gt_file, cluster_labels_out_path=opts.cluster_labels_out_path,