From 8b09aa49b064d81ec4ca6a3e3488acd8cf210bb6 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 7 Aug 2026 16:47:21 -0700 Subject: [PATCH 01/21] Change default AMST approx bound value to 1.1 --- script/benchmark/run_clams_bench.py | 18 +++++++--- src/mst/build_amst.cpp | 56 ++++++++++++++--------------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index 90fba81..c810d4d 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -59,6 +59,11 @@ def parse_options(): default='', help='Path to an already constructed DNND PM datastore. If specified, skip the DNND step.') + # Approximate Minimum Spanning Tree (AMST) + # -a: approximate bound, default 1.1 + parser.add_argument('-a', '--amst_approx_bound', type=float, default=1.1, + help='Approximation bound for AMST. Default is 1.1.') + # For HDBSCAN # Min cluster size, conmma separated list of min cluster sizes, or range of min cluster sizes parser.add_argument('-m', '--min_cluster_size', default='5', @@ -148,7 +153,8 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, dnnd_exe, nng_k, distance_func, points_file_format, point_path, mfc_exe, - amst_exe, clustering_exe, + amst_exe, amst_approx_bound, + clustering_exe, evaluator, ygm_cluster_eval, verbose, ground_truth_path, @@ -202,7 +208,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, job_script.write("date\n") job_script.write(f"echo \"Running AMST\"\n") amst_ds_path = f"{work_dir}/amst_pm_datastore" - amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path}" + amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path} -e {amst_approx_bound}" add_srun_cmd(num_tasks_per_node, amst_command, job_script) # Run the HPC Clustering step @@ -297,6 +303,7 @@ def main(): opts.point_path, opts.mfc_exe, opts.amst_exe, + opts.amst_approx_bound, opts.clustering_exe, evaluator, opts.ygm_cluster_eval, @@ -307,17 +314,20 @@ def main(): opts.input_dnnd_ds_path) print(f"Generated batch script: {job_script}") + job_submission_cmd = f"sbatch {opts.sbatch_opts} {job_script}" # Write job execution commands log with open(f'{job_dir}/info.txt', 'w') as f: f.write(f"Command executed by user:\n") f.write(' '.join(sys.argv)) f.write('\n\n') - f.write(f"To submit the job, run:\nsbatch {opts.sbatch_opts} {job_script}\n") + f.write(f"To submit the job, run:\n{job_submission_cmd}\n") # Submit the job if opts.submit_job: print(f"Submit job: {job_script}") - os.system(f"sbatch {opts.sbatch_opts} {job_script}") + os.system(job_submission_cmd) + else: + print(f"To submit the job, run:\n{job_submission_cmd}\n") if __name__ == '__main__': diff --git a/src/mst/build_amst.cpp b/src/mst/build_amst.cpp index 95f43b0..73f6d02 100644 --- a/src/mst/build_amst.cpp +++ b/src/mst/build_amst.cpp @@ -15,15 +15,15 @@ #include #include -#include #include +#include -#include "approx_mst_builder.hpp" #include "../common.hpp" +#include "approx_mst_builder.hpp" namespace cls = clams; -using id_t = cls::id_t; +using id_t = cls::id_t; using distance_t = cls::distance_t; using edge_t = std::tuple; @@ -45,8 +45,8 @@ void usage(ygm::comm &comm) { void parse_cmd_line(int argc, char **argv, ygm::comm &comm, float &approx_bound, std::vector &txt_input_filenames, - std::string &pm_input_path, - std::string &pm_output_filename) { + std::string &pm_input_path, + std::string &pm_output_filename) { if (comm.rank0()) { std::cout << "CMD line:"; for (int i = 0; i < argc; ++i) { @@ -55,9 +55,9 @@ void parse_cmd_line(int argc, char **argv, ygm::comm &comm, float &approx_bound, std::cout << std::endl; } - int c; + int c; bool inserting_input_filenames = false; - bool prn_help = false; + bool prn_help = false; while (true) { while ((c = getopt(argc, argv, "+e:i:d:p:h ")) != -1) { inserting_input_filenames = false; @@ -113,7 +113,7 @@ void parse_cmd_line(int argc, char **argv, ygm::comm &comm, float &approx_bound, } std::vector read_dnnd_output(const std::vector &filenames, - ygm::comm &c) { + ygm::comm &c) { std::vector to_return; ygm::container::bag filenames_bag(c); @@ -138,8 +138,8 @@ std::vector read_dnnd_output(const std::vector &filenames, std::stringstream neighbors_ss(neighbors_str); std::stringstream distances_ss(distances_str); - id_t src; - id_t ngbr; + id_t src; + id_t ngbr; distance_t dist; neighbors_ss >> src; @@ -159,7 +159,7 @@ std::vector read_dnnd_output(const std::vector &filenames, } std::vector read_pm_knng_output(std::string &dstore_path, - ygm::comm &comm) { + ygm::comm &comm) { comm.cout0() << "Reading distributed PM kNNG data from: " << dstore_path << std::endl; @@ -168,7 +168,7 @@ std::vector read_pm_knng_output(std::string &dstore_path, const auto &knng = pm_knng.get_knng(); std::vector to_return; - std::size_t local_num_neighbors = 0; + std::size_t local_num_neighbors = 0; for (const auto &[source, neighbors] : knng) { (void)source; local_num_neighbors += neighbors.size(); @@ -229,7 +229,7 @@ void pivot_sort(std::vector &in_vec, ygm::comm &world) { return a.second < b.second; }; - const size_t samples_per_pivot = 40; + const size_t samples_per_pivot = 40; std::vector to_sort; to_sort.reserve(in_vec.size() * 1.1f); @@ -238,14 +238,14 @@ void pivot_sort(std::vector &in_vec, ygm::comm &world) { // of duplicate items std::vector> samples; std::vector> pivots; - static auto &s_samples = samples; - static auto &s_to_sort = to_sort; + static auto &s_samples = samples; + static auto &s_to_sort = to_sort; samples.reserve(world.size() * samples_per_pivot); // std::default_random_engine rng; - size_t my_prefix = ygm::prefix_sum(in_vec.size(), world); + size_t my_prefix = ygm::prefix_sum(in_vec.size(), world); size_t global_size = ygm::sum(in_vec.size(), world); std::uniform_int_distribution uintdist{0, global_size - 1}; @@ -275,9 +275,9 @@ void pivot_sort(std::vector &in_vec, ygm::comm &world) { // // Partition using pivots for (size_t i = 0; i < in_vec.size(); ++i) { - auto itr = std::lower_bound(pivots.begin(), pivots.end(), - std::make_pair(in_vec[i], my_prefix + i), - edge_index_pair_comp_lambda); + auto itr = std::lower_bound(pivots.begin(), pivots.end(), + std::make_pair(in_vec[i], my_prefix + i), + edge_index_pair_comp_lambda); size_t owner = std::distance(pivots.begin(), itr); world.async( @@ -319,7 +319,7 @@ void pivot_sort(std::vector &in_vec, ygm::comm &world) { in_vec.swap(to_sort); } -void write_output_single_file(const std::string &output_filename, +void write_output_single_file(const std::string &output_filename, const std::vector &edges, ygm::comm &c) { MPI_Comm mpi_comm = c.get_mpi_comm(); @@ -348,14 +348,14 @@ void write_output_single_file(const std::string &output_filename, c.cout0("Dumped AMST edges to: ", output_filename); } -void write_output_single_pm(const std::string &output_filename, +void write_output_single_pm(const std::string &output_filename, const std::vector &edges, ygm::comm &c) { const size_t num_edges = c.all_reduce_sum(edges.size()); MPI_Comm mpi_comm = c.get_mpi_comm(); if (c.rank0()) { metall::manager manager(metall::create_only, output_filename); - auto *amst_edges = manager.construct( + auto *amst_edges = manager.construct( metall::unique_instance)(manager.get_allocator()); amst_edges->reserve(num_edges); @@ -385,18 +385,18 @@ int main(int argc, char **argv) { ygm::comm world(&argc, &argv); std::vector txt_input_filenames; - std::string pm_input_path; - std::string pm_output_filename; - float amst_approx_bound{2.0}; + std::string pm_input_path; + std::string pm_output_filename; + float amst_approx_bound{1.1}; parse_cmd_line(argc, argv, world, amst_approx_bound, txt_input_filenames, pm_input_path, pm_output_filename); world.cout0("Reading edges"); ygm::utility::timer read_timer; - auto edges = (txt_input_filenames.size() > 0) - ? read_dnnd_output(txt_input_filenames, world) - : read_pm_knng_output(pm_input_path, world); + auto edges = (txt_input_filenames.size() > 0) + ? read_dnnd_output(txt_input_filenames, world) + : read_pm_knng_output(pm_input_path, world); world.cout0("Edge reading time (s): ", read_timer.elapsed()); auto amst_edges = approx_mst(edges, amst_approx_bound, world); From a8e620494c3781ca70a8629ce37708c3f9dcf186 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 7 Aug 2026 16:47:36 -0700 Subject: [PATCH 02/21] Use MFC by default --- script/benchmark/run_clams_bench.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index c810d4d..498a3c4 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -114,7 +114,7 @@ def parse_options(): default=f'{cwd}/src/knng/build_knng', help='Path to the DNND executable.') parser.add_argument('-M', '--mfc_exe', - default=f'{cwd}/src/mfc/connect_ccs_random_dist', + default=f'{cwd}/src/mfc/connect_mfc', help='Path to the MFC executable.') parser.add_argument('-A', '--amst_exe', default=f'{cwd}/src/mst/build_amst', From 805652b86cd22aa927441621c66e7cd8baac6bf4 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 7 Aug 2026 17:15:17 -0700 Subject: [PATCH 03/21] Brush up scripts --- script/benchmark/clustering_utilities.py | 2 +- script/benchmark/run_clams_bench.py | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index abb873b..d2cf2de 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -27,7 +27,7 @@ def eval_clusters(cluster_labels, true_labels): cluster_labels[clustered_points]) # sil = silhouette_score(raw_data[clustered_points], cluster_labels[clustered_points]) pct_clustered = (np.sum(clustered_points) / cluster_labels.shape[0]) - print(f"Clustered Points: {pct_clustered * 100:.2f}%") + print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") else: ari = adjusted_rand_score(true_labels, cluster_labels) ami = adjusted_mutual_info_score(true_labels, cluster_labels) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index 498a3c4..da950c2 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -173,9 +173,12 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, add_cmd(f'mkdir -p {work_dir}', job_script, True, False) # Run the DNND step + job_script.write("echo\n") job_script.write("date\n") if len(input_dnnd_ds_path) == 0: + job_script.write(f"echo ================================\n") job_script.write(f"echo \"Building KNNG\"\n") + job_script.write(f"echo ================================\n") dnnd_ds_path = f"{work_dir}/dnnd_pm_datastore" dnnd_batch_size = 2 ** 25 verbose_flag = '-v' if verbose else '' @@ -187,33 +190,43 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, dnnd_ds_path = input_dnnd_ds_path # Connect the CCs + job_script.write("echo\n") job_script.write("date\n") - job_script.write( - f"echo \"Randomly connect components\"\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"Connect components\"\n") + job_script.write(f"echo ================================\n") mfc_command = f"{mfc_exe} -d {dnnd_ds_path} -f {distance_func}" add_srun_cmd(num_tasks_per_node, mfc_command, job_script) # Convert to core distance # TODO: Implement if False and min_samples > 0: + job_script.write("echo\n") job_script.write("date\n") - job_script.write( - f"echo \"Convert to core distance kNNG\"\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo Convert to core distance kNNG\n") + job_script.write(f"echo ================================\n") knng_coredist_dir = f"{work_dir}/knng_coredist/" add_cmd(f'mkdir -p {knng_coredist_dir}', job_script) conv2coredist_cmd = f"./src/conv_knng_to_core_dist -i {dnnd_ds_path} -o {knng_coredist_dir}/knng.txt -m {min_samples}" add_cmd(conv2coredist_cmd, job_script) # Run the AMST step + job_script.write("echo\n") job_script.write("date\n") + job_script.write(f"echo ================================\n") job_script.write(f"echo \"Running AMST\"\n") + job_script.write(f"echo ================================\n") amst_ds_path = f"{work_dir}/amst_pm_datastore" amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path} -e {amst_approx_bound}" add_srun_cmd(num_tasks_per_node, amst_command, job_script) # Run the HPC Clustering step + job_script.write("echo\n") job_script.write("date\n") - job_script.write(f"echo \"Running HPC Clustering\"\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"Running CLAMS-HDBSCAN\"\n") + job_script.write(f"echo ================================\n") for try_no, set_cmd in enumerate(min_cluster_size_set_cmnds): add_cmd(set_cmd, job_script, False, False) From 6932cc6dbfe3178f65b204bf7e7e97eca8a2fcf4 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 7 Aug 2026 18:40:47 -0700 Subject: [PATCH 04/21] Update bench scripts --- script/benchmark/bench_utilities.py | 13 +++- script/benchmark/run_clams_bench.py | 112 +++++++++++++++------------- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/script/benchmark/bench_utilities.py b/script/benchmark/bench_utilities.py index 47ee707..4d16045 100644 --- a/script/benchmark/bench_utilities.py +++ b/script/benchmark/bench_utilities.py @@ -84,6 +84,9 @@ def grep_file(file, text_pattern): lines = f.readlines() return [line for line in lines if text_pattern in line] +# Parse a range string and return a list of integers +# e.g., "1-5" -> [1, 2, 3, 4, 5] +# e.g., "1,3,5" -> [1, 3, 5] def parse_range(range_str): if range_str is None: return [None] @@ -92,4 +95,12 @@ def parse_range(range_str): start, end = range_str.split('-') return list(range(int(start), int(end) + 1)) else: - return [int(x) for x in range_str.split(',')] \ No newline at end of file + return [int(x) for x in range_str.split(',')] + +# Parse a list of floats from a string and return a list of floats +# e.g., "1.0,2.5,3.14" -> [1.0, 2.5, 3.14] +def parse_float_list(float_list_str): + if float_list_str is None: + return [None] + + return [float(x) for x in float_list_str.split(',')] \ No newline at end of file diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index da950c2..7fd3770 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -61,15 +61,16 @@ def parse_options(): # Approximate Minimum Spanning Tree (AMST) # -a: approximate bound, default 1.1 - parser.add_argument('-a', '--amst_approx_bound', type=float, default=1.1, - help='Approximation bound for AMST. Default is 1.1.') + parser.add_argument('-a', '--amst_approx_bound', default='1.1', + dest='amst_approx_bound_range', action='store', type=str, + help='Approximation bound for AMST. Single value or comma separated list of values (e.g., 1.1,1.2,1.4). Default is 1.1.') # For HDBSCAN # Min cluster size, conmma separated list of min cluster sizes, or range of min cluster sizes parser.add_argument('-m', '--min_cluster_size', default='5', dest='min_cluster_size_range', action='store', type=str, - help='Minimum cluster size. Comma separated list of values or range of values (e.g., 5,10,15 or 5-15)') + help='Minimum cluster size. Single value or, comma separated list of values or range of values (e.g., 5,10,15 or 5-15)') # Min samples parser.add_argument('-s', '--min_samples', type=int, default=-1, dest='min_samples', @@ -153,7 +154,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, dnnd_exe, nng_k, distance_func, points_file_format, point_path, mfc_exe, - amst_exe, amst_approx_bound, + amst_exe, amst_approx_bound_list, clustering_exe, evaluator, ygm_cluster_eval, verbose, @@ -193,7 +194,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, job_script.write("echo\n") job_script.write("date\n") job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Connect components\"\n") + job_script.write(f"echo \"Running MFS\"\n") job_script.write(f"echo ================================\n") mfc_command = f"{mfc_exe} -d {dnnd_ds_path} -f {distance_func}" add_srun_cmd(num_tasks_per_node, mfc_command, job_script) @@ -211,53 +212,58 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, conv2coredist_cmd = f"./src/conv_knng_to_core_dist -i {dnnd_ds_path} -o {knng_coredist_dir}/knng.txt -m {min_samples}" add_cmd(conv2coredist_cmd, job_script) - # Run the AMST step - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Running AMST\"\n") - job_script.write(f"echo ================================\n") - amst_ds_path = f"{work_dir}/amst_pm_datastore" - amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path} -e {amst_approx_bound}" - add_srun_cmd(num_tasks_per_node, amst_command, job_script) - - # Run the HPC Clustering step - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Running CLAMS-HDBSCAN\"\n") - job_script.write(f"echo ================================\n") - for try_no, set_cmd in enumerate(min_cluster_size_set_cmnds): - add_cmd(set_cmd, job_script, False, False) - - job_script.write( - f"echo \"Min clustering size ${{MIN_CLUSTER_SIZE}}\"\n") - cluster_label_file = f"{work_dir}/cluster_labels-try{try_no}.txt" - cluster_tree_file = f"{work_dir}/cluster_tree-try{try_no}.txt" - hpc_clustering_command = (f"{clustering_exe} -i {amst_ds_path} -M " - f" -m ${{MIN_CLUSTER_SIZE}} " - f" -o {cluster_label_file} " - f" -c {cluster_tree_file} " - f" -P ") - add_cmd(hpc_clustering_command, job_script) - - # Run the evaluation step - if ground_truth_path: - if ygm_cluster_eval: - job_script.write( - f"echo \"Evaluating Clustering using YGM\"\n") - if verbose: - evaluation_command = f"{evaluator} -v -g {ground_truth_path} {cluster_label_file}" + try_no = 0 + for amst_approx_bound in amst_approx_bound_list: + # Run the AMST step + job_script.write("echo\n") + job_script.write("date\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"Running AMST, approx bound = {amst_approx_bound}\"\n") + job_script.write(f"echo ================================\n") + amst_ds_path = f"{work_dir}/amst_pm_datastore_a{amst_approx_bound}" + amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path} -e {amst_approx_bound}" + add_srun_cmd(num_tasks_per_node, amst_command, job_script) + + # Run the HPC Clustering step + for set_cmd in min_cluster_size_set_cmnds: + # Set the min cluster size environment variable for this run + add_cmd(set_cmd, job_script, False, False) + + job_script.write("echo\n") + job_script.write("date\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"Running CLAMS-HDBSCAN, min cluster size = ${{MIN_CLUSTER_SIZE}}\"\n") + job_script.write(f"echo ================================\n") + + job_script.write( + f"echo \"Min cluster size ${{MIN_CLUSTER_SIZE}}\"\n") + cluster_label_file = f"{work_dir}/cluster_labels_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}.txt" + cluster_tree_file = f"{work_dir}/cluster_tree_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}.txt" + hpc_clustering_command = (f"{clustering_exe} -i {amst_ds_path} -M " + f" -m ${{MIN_CLUSTER_SIZE}} " + f" -o {cluster_label_file} " + f" -c {cluster_tree_file} " + f" -P ") + add_cmd(hpc_clustering_command, job_script) + + # Run the evaluation step + if ground_truth_path: + if ygm_cluster_eval: + job_script.write( + f"echo \"Evaluating Clustering using YGM\"\n") + if verbose: + evaluation_command = f"{evaluator} -v -g {ground_truth_path} {cluster_label_file}" + else: + evaluation_command = f"{evaluator} -g {ground_truth_path} {cluster_label_file}" + add_srun_cmd(num_tasks_per_node, evaluation_command, + job_script) else: - evaluation_command = f"{evaluator} -g {ground_truth_path} {cluster_label_file}" - add_srun_cmd(num_tasks_per_node, evaluation_command, - job_script) - else: - job_script.write( - f"echo \"Evaluating Clustering using python script\"\n") - evaluation_command = f"python3 {evaluator} -c {cluster_label_file} -g {ground_truth_path}" - add_cmd(evaluation_command, job_script) - job_script.write(f"echo \"\" \n") + job_script.write( + f"echo \"Evaluating Clustering using python script\"\n") + evaluation_command = f"python3 {evaluator} -c {cluster_label_file} -g {ground_truth_path}" + add_cmd(evaluation_command, job_script) + job_script.write(f"echo \"\" \n") + try_no += 1 # If the file was not created, return an error if not os.path.exists(job_script_path): @@ -306,6 +312,8 @@ def main(): min_cluster_size_set_cmnds = [f'MIN_CLUSTER_SIZE={x}' for x in min_cluster_size_list] + amst_approx_bound_list = parse_float_list(opts.amst_approx_bound_range) + # Generate a benchmark batch script job_script = gen_clams_bench_script(job_name, job_dir, work_dir, opts.num_nodes, @@ -316,7 +324,7 @@ def main(): opts.point_path, opts.mfc_exe, opts.amst_exe, - opts.amst_approx_bound, + amst_approx_bound_list, opts.clustering_exe, evaluator, opts.ygm_cluster_eval, From 3ad72d64c94788307641a8cc0de01bc9ab92502a Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Sat, 8 Aug 2026 02:16:12 -0700 Subject: [PATCH 05/21] Update bench scripts --- script/benchmark/run_clams_bench.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index 7fd3770..42a9348 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -54,16 +54,21 @@ def parse_options(): 'a KNNG value.') parser.add_argument('--neodnnd', action='store_true', help='Use NEO-DNND (build_knng_neo) instead of build_knng.') + parser.add_argument('--neodnnd_threads', type=int, default=2, + help='Number of threads to use for NEO-DNND. Ignored if --neodnnd is not specified.') parser.add_argument('-G', '--input_dnnd_ds_path', default='', help='Path to an already constructed DNND PM datastore. If specified, skip the DNND step.') + # KNNG backup + parser.add_argument('-R', '--backup_knng', action='store_true', + help='Backup the KNNG datastore after DNND step.') + # Approximate Minimum Spanning Tree (AMST) - # -a: approximate bound, default 1.1 parser.add_argument('-a', '--amst_approx_bound', default='1.1', dest='amst_approx_bound_range', action='store', type=str, - help='Approximation bound for AMST. Single value or comma separated list of values (e.g., 1.1,1.2,1.4). Default is 1.1.') + help='Approximation bound for AMST. Single value or comma separated list of values (e.g., 1.1,1.2,1.4).') # For HDBSCAN # Min cluster size, conmma separated list of min cluster sizes, or range of min cluster sizes @@ -153,6 +158,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, num_nodes, num_tasks_per_node, dnnd_exe, nng_k, distance_func, points_file_format, point_path, + backup_knng, mfc_exe, amst_exe, amst_approx_bound_list, clustering_exe, @@ -185,6 +191,13 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, verbose_flag = '-v' if verbose else '' dnnd_command = f"{dnnd_exe} {verbose_flag} -k {nng_k} -f {distance_func} -o {dnnd_ds_path} -b {dnnd_batch_size} -p {points_file_format} {point_path}" add_srun_cmd(num_tasks_per_node, dnnd_command, job_script) + if backup_knng: + dnnd_ds_path_backup = f"{dnnd_ds_path}_backup" + job_script.write(f"echo\n") + job_script.write("date\n") + job_script.write(f"echo \"Backing up KNNG datastore\"\n") + backup_knng_command = f"cp -r {dnnd_ds_path} {dnnd_ds_path_backup}" + add_cmd(backup_knng_command, job_script) else: job_script.write( f"Using existing DNND datastore at {input_dnnd_ds_path}\n") @@ -302,6 +315,8 @@ def main(): default_dnnd_exe = f'{os.getcwd()}/src/knng/build_knng' if dnnd_exe == default_dnnd_exe: dnnd_exe = f'{os.getcwd()}/src/knng/build_knng_neo' + # This is not the best way to set the number of threads for NEO-DNND, but it is a simple way to do it for now. + dnnd_exe = f'{dnnd_exe} -T {opts.neodnnd_threads}' if opts.ygm_cluster_eval: evaluator = opts.ygm_evaluator_exe @@ -322,6 +337,7 @@ def main(): opts.distance_func, opts.points_file_format, opts.point_path, + opts.backup_knng, opts.mfc_exe, opts.amst_exe, amst_approx_bound_list, From e10e513ff4afd657054ac134053c2f46d3db9838 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Mon, 10 Aug 2026 12:30:38 -0700 Subject: [PATCH 06/21] Minor code brush ups --- src/common.hpp | 24 ++++++++++++------------ src/mst/build_amst.cpp | 2 +- src/mst/run_kruskal_mst.cpp | 20 +++++++++++++------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/common.hpp b/src/common.hpp index 7e01cd4..b75efc6 100644 --- a/src/common.hpp +++ b/src/common.hpp @@ -1,7 +1,6 @@ // Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS // Project Developers. See the top-level COPYRIGHT file for details. - #pragma once #include @@ -55,7 +54,7 @@ inline std::vector find_files( /// \brief Read edges. /// \param path A path to an edge file or a directory that contains edge files. inline void read_edges(const std::filesystem::path &path, - weighted_edge_list_t &edges) { + weighted_edge_list_t &edges) { const auto files = find_files(path); for (const auto &file : files) { std::ifstream ifs(file); @@ -66,8 +65,8 @@ inline void read_edges(const std::filesystem::path &path, std::string line; while (true) { - id_t src; - id_t dst; + id_t src; + id_t dst; distance_t dist; ifs >> src >> dst >> dist; if (ifs.eof()) { @@ -78,12 +77,13 @@ inline void read_edges(const std::filesystem::path &path, } } -inline void read_knn_edges(const std::vector &knng_files, - weighted_edge_list_t &edges) { +inline void read_knng_edges( + const std::vector &knng_files, + weighted_edge_list_t &edges) { std::size_t num_edges = 0; OMP_DIRECTIVE(parallel for reduction(+ : num_edges)) for (std::size_t fno = 0; fno < knng_files.size(); ++fno) { - const auto &file = knng_files[fno]; + const auto &file = knng_files[fno]; std::ifstream ifs(file); if (!ifs.is_open()) { std::cerr << "Cannot open file: " << file << std::endl; @@ -97,7 +97,7 @@ inline void read_knn_edges(const std::vector &knng_files, break; // End of file } std::istringstream iss(line); - id_t buf; + id_t buf; while (iss >> buf) { ++num_edges; } @@ -112,7 +112,7 @@ inline void read_knn_edges(const std::vector &knng_files, std::atomic cnt_edges{0}; OMP_DIRECTIVE(parallel for) for (std::size_t fno = 0; fno < knng_files.size(); ++fno) { - const auto &file = knng_files[fno]; + const auto &file = knng_files[fno]; std::ifstream ifs(file); if (!ifs.is_open()) { std::cerr << "Cannot open file: " << file << std::endl; @@ -125,7 +125,7 @@ inline void read_knn_edges(const std::vector &knng_files, { std::getline(ifs, line); std::istringstream iss(line); - id_t buf; + id_t buf; while (iss >> buf) { ids.push_back(buf); } @@ -135,7 +135,7 @@ inline void read_knn_edges(const std::vector &knng_files, { std::getline(ifs, line); std::istringstream iss(line); - distance_t buf; + distance_t buf; while (iss >> buf) { dists.push_back(buf); } @@ -154,7 +154,7 @@ inline void read_knn_edges(const std::vector &knng_files, const id_t src = ids[0]; for (std::size_t i = 1; i < ids.size(); ++i) { const auto index = cnt_edges.fetch_add(1); - edges[index] = weighted_edge_t{src, ids[i], dists[i]}; + edges[index] = weighted_edge_t{src, ids[i], dists[i]}; } } } diff --git a/src/mst/build_amst.cpp b/src/mst/build_amst.cpp index 73f6d02..be40d97 100644 --- a/src/mst/build_amst.cpp +++ b/src/mst/build_amst.cpp @@ -143,7 +143,7 @@ std::vector read_dnnd_output(const std::vector &filenames, distance_t dist; neighbors_ss >> src; - distances_ss >> dist; + distances_ss >> dist; // Dummy value, not used while (neighbors_ss >> ngbr) { distances_ss >> dist; diff --git a/src/mst/run_kruskal_mst.cpp b/src/mst/run_kruskal_mst.cpp index cebf442..0a7a5ca 100644 --- a/src/mst/run_kruskal_mst.cpp +++ b/src/mst/run_kruskal_mst.cpp @@ -1,7 +1,6 @@ // Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS // Project Developers. See the top-level COPYRIGHT file for details. - // Find the MST using Kruskal's algorithm // If GCC is used, @@ -11,11 +10,11 @@ #warning "No parallelism library is used." #endif +#include #include #include #include #include -#include #include #include @@ -27,14 +26,13 @@ namespace omp = metall::utility::omp; bool parse_option(int argc, char *argv[], std::filesystem::path &knng_dir, std::filesystem::path &output_dir) { - if (argc != 3) { std::cerr << "Usage: " << argv[0] << " " << std::endl; return false; } - knng_dir = std::filesystem::path(argv[1]); + knng_dir = std::filesystem::path(argv[1]); output_dir = std::filesystem::path(argv[2]); return true; @@ -60,7 +58,7 @@ int main(int argc, char *argv[]) { clams::weighted_edge_list_t edges; spdlog::info("Read KNNG edges"); - clams::read_knn_edges(knng_files, edges); + clams::read_knng_edges(knng_files, edges); spdlog::info("#of edges {}", edges.size()); spdlog::info("Sort edges"); @@ -76,7 +74,7 @@ int main(int argc, char *argv[]) { spdlog::info("Finished sorting edges"); std::unordered_map parent_tbl; - clams::weighted_edge_list_t mst_edges; + clams::weighted_edge_list_t mst_edges; spdlog::info("Start Kruskal's algorithm"); for (const auto &edge : edges) { const auto src = edge.ids[0]; @@ -103,9 +101,17 @@ int main(int argc, char *argv[]) { } spdlog::info("Finished Kruskal's algorithm"); - std::filesystem::create_directories(output_dir); + // Show the sum of distances of the MST edges + { + double total_distance = 0.0; + for (const auto &edge : mst_edges) { + total_distance += edge.distance; + } + spdlog::info("Total distance of MST edges: {}", total_distance); + } spdlog::info("Write MST edges int {}", output_dir.string()); + std::filesystem::create_directories(output_dir); OMP_DIRECTIVE(parallel) { std::string path = output_dir / From 5c17de2efefccb0dfc55caa90af223732f5efa02 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Mon, 10 Aug 2026 17:07:54 -0700 Subject: [PATCH 07/21] Update bench script --- script/benchmark/run_clams_bench.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index 42a9348..f6953a9 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -56,6 +56,10 @@ def parse_options(): help='Use NEO-DNND (build_knng_neo) instead of build_knng.') parser.add_argument('--neodnnd_threads', type=int, default=2, help='Number of threads to use for NEO-DNND. Ignored if --neodnnd is not specified.') + parser.add_argument('--nng_r', type=float, default=0.5, + help='The r (sampling) parameter for KNNG construction.') + parser.add_argument('--nng_delta', type=float, default=0.0001, + help='The delta parameter (termination condition) for KNNG construction.') parser.add_argument('-G', '--input_dnnd_ds_path', default='', @@ -158,6 +162,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, num_nodes, num_tasks_per_node, dnnd_exe, nng_k, distance_func, points_file_format, point_path, + nng_r, nng_delta, backup_knng, mfc_exe, amst_exe, amst_approx_bound_list, @@ -189,7 +194,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, dnnd_ds_path = f"{work_dir}/dnnd_pm_datastore" dnnd_batch_size = 2 ** 25 verbose_flag = '-v' if verbose else '' - dnnd_command = f"{dnnd_exe} {verbose_flag} -k {nng_k} -f {distance_func} -o {dnnd_ds_path} -b {dnnd_batch_size} -p {points_file_format} {point_path}" + dnnd_command = f"{dnnd_exe} {verbose_flag} -k {nng_k} -r {nng_r} -d {nng_delta} -f {distance_func} -o {dnnd_ds_path} -b {dnnd_batch_size} -p {points_file_format} {point_path}" add_srun_cmd(num_tasks_per_node, dnnd_command, job_script) if backup_knng: dnnd_ds_path_backup = f"{dnnd_ds_path}_backup" @@ -337,6 +342,8 @@ def main(): opts.distance_func, opts.points_file_format, opts.point_path, + opts.nng_r, + opts.nng_delta, opts.backup_knng, opts.mfc_exe, opts.amst_exe, From 8189ff9d9d66f597e0db9cf9d23ad2f52f5aa286 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 13 Aug 2026 07:02:40 -0700 Subject: [PATCH 08/21] Assign clusters to noise points --- script/benchmark/clustering_utilities.py | 30 ++- .../benchmark/evaluate_clustering_quality.py | 8 +- script/benchmark/run_clams_bench.py | 86 +++++-- src/clustering/CMakeLists.txt | 5 + src/clustering/cluster_noise_points.cpp | 218 ++++++++++++++++++ 5 files changed, 320 insertions(+), 27 deletions(-) create mode 100644 src/clustering/cluster_noise_points.cpp diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index d2cf2de..e72f8be 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -10,24 +10,35 @@ # Modified from: # https://gist.github.com/lmcinnes/24ed5c22c80125be5133811d677eae7b -def eval_clusters(cluster_labels, true_labels): +def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points=False): + max_cluster_id = max(np.max(cluster_labels), np.max(true_labels)) + if np.any(true_labels < 0): print("Ground truth labels contain noise points") pct_clustered_gt = (np.sum(true_labels >= 0) / cluster_labels.shape[0]) print(f"GT clustered Points: {pct_clustered_gt * 100:.2f}%") print( "Assigning a singleton cluster to each noise point in the ground truth labels") - true_labels = assign_singleton_cluster_to_noise_point(true_labels) + true_labels = assign_singleton_cluster_to_noise_points(true_labels, max_cluster_id) + max_cluster_id = max(max_cluster_id, np.max(true_labels)) if np.any(cluster_labels < 0): # Has noise points clustered_points = (cluster_labels >= 0) - ari = adjusted_rand_score(true_labels[clustered_points], - cluster_labels[clustered_points]) - ami = adjusted_mutual_info_score(true_labels[clustered_points], - cluster_labels[clustered_points]) - # sil = silhouette_score(raw_data[clustered_points], cluster_labels[clustered_points]) pct_clustered = (np.sum(clustered_points) / cluster_labels.shape[0]) print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") + + if singleton_cluster_to_noise_points: + print( + "Assigning a singleton cluster to each noise point in the clustering result") + cluster_labels = assign_singleton_cluster_to_noise_points(cluster_labels, max_cluster_id) + ari = adjusted_rand_score(true_labels, cluster_labels) + ami = adjusted_mutual_info_score(true_labels, cluster_labels) + else: + ari = adjusted_rand_score(true_labels[clustered_points], + cluster_labels[clustered_points]) + ami = adjusted_mutual_info_score(true_labels[clustered_points], + cluster_labels[clustered_points]) + # sil = silhouette_score(raw_data[clustered_points], cluster_labels[clustered_points]) else: ari = adjusted_rand_score(true_labels, cluster_labels) ami = adjusted_mutual_info_score(true_labels, cluster_labels) @@ -39,15 +50,14 @@ def eval_clusters(cluster_labels, true_labels): # Assign a cluster ID to each noise point -def assign_singleton_cluster_to_noise_point(cluster_labels): +def assign_singleton_cluster_to_noise_points(cluster_labels, noise_id_offset): new_labels = cluster_labels.copy() - max_cluster_id = np.max(cluster_labels) cnt_noise = 0 for i, label in enumerate(cluster_labels): if label == -1: cnt_noise += 1 - new_labels[i] = max_cluster_id + cnt_noise + new_labels[i] = cnt_noise + noise_id_offset return new_labels diff --git a/script/benchmark/evaluate_clustering_quality.py b/script/benchmark/evaluate_clustering_quality.py index 81bef75..0cc5813 100644 --- a/script/benchmark/evaluate_clustering_quality.py +++ b/script/benchmark/evaluate_clustering_quality.py @@ -51,7 +51,8 @@ def parse_options(): dest='gt_path', required=True, action='store', type=str, help='Path to file or directory containing ground truth labels') - + parser.add_argument('-s', '--singleton_noise_points', action='store_true', + help='Assign a singleton cluster to each noise point in the clustering result.') args = parser.parse_args() return args @@ -63,7 +64,10 @@ def main(): true_labels = read_label_data(opt.gt_path) try: - eval_clusters(cluster_labels, true_labels) + eval_clusters(cluster_labels, true_labels, False) + contains_noise_points = np.any(cluster_labels == -1) + if opt.singleton_noise_points and contains_noise_points: + eval_clusters(cluster_labels, true_labels, True) except Exception as e: print(f"Error: {e}") exit(1) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index f6953a9..eff7811 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -92,6 +92,8 @@ def parse_options(): # Use the -y flag to use a YGM calculation instead parser.add_argument('-y', '--ygm_cluster_eval', action='store_true', help='Use YGM cluster evaluation calculation.') + parser.add_argument('-z', '--dummy_cluster_id', action='store_true', + help='Assign a singleton cluster to each noise point in the clustering result.') # For output parser.add_argument('-o', '--output_root_dir', @@ -138,6 +140,9 @@ def parse_options(): parser.add_argument('-Y', '--ygm_evaluator_exe', default=f'{cwd}/tpls/partition-comparison/build/src/clustering_metrics', help='Path to the YGM clustering evaluation executable.') + parser.add_argument('--noise_point_assigner_exe', + default=f'{cwd}/src/clustering/cluster_noise_points', + help='Path to the noise point assigner executable.') # Etc parser.add_argument('-v', '--verbose', action='store_true', @@ -152,6 +157,42 @@ def generate_job_name(): return f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}" +def add_clustering_evaluation(job_script, cluster_label_file, amst_ds_path, + ground_truth_path, evaluator, + noise_point_assigner_exe, ygm_cluster_eval, + num_tasks_per_node, verbose, + singleton_cluster_to_noise_points): + job_script.write("echo\n") + job_script.write("date\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"Evaluating Clustering\"\n") + job_script.write(f"echo ================================\n") + + if ygm_cluster_eval: + evaluated_label_file = f"{cluster_label_file}.noise_assigned" + assignment_command = (f"{noise_point_assigner_exe} -M " + f"-m {amst_ds_path} " + f"-c {cluster_label_file} " + f"-o {evaluated_label_file}") + add_cmd(assignment_command, job_script) + + job_script.write("echo \"Evaluating Clustering using YGM\"\n") + verbose_flag = " -v" if verbose else "" + evaluation_command = (f"{evaluator}{verbose_flag} " + f"-g {ground_truth_path} " + f"{evaluated_label_file}") + add_srun_cmd(num_tasks_per_node, evaluation_command, job_script) + else: + job_script.write( + "echo \"Evaluating Clustering using python script\"\n") + evaluation_command = (f"python3 {evaluator} " + f"-c {cluster_label_file} " + f"-g {ground_truth_path}") + if singleton_cluster_to_noise_points: + evaluation_command += " -s" + add_cmd(evaluation_command, job_script) + + # Function to generate the batch script for running a benchmark # # Each line in min_cluster_size_set_cmnds is a shell commands to @@ -170,9 +211,11 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, evaluator, ygm_cluster_eval, verbose, ground_truth_path, + singleton_cluster_to_noise_points, min_cluster_size_set_cmnds, min_samples, - input_dnnd_ds_path=''): + noise_point_assigner_exe, + input_dnnd_ds_path='',): create_dir(job_dir) job_script_path = f'{job_dir}/job.sh' @@ -266,20 +309,31 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, # Run the evaluation step if ground_truth_path: - if ygm_cluster_eval: - job_script.write( - f"echo \"Evaluating Clustering using YGM\"\n") - if verbose: - evaluation_command = f"{evaluator} -v -g {ground_truth_path} {cluster_label_file}" - else: - evaluation_command = f"{evaluator} -g {ground_truth_path} {cluster_label_file}" - add_srun_cmd(num_tasks_per_node, evaluation_command, - job_script) - else: - job_script.write( - f"echo \"Evaluating Clustering using python script\"\n") - evaluation_command = f"python3 {evaluator} -c {cluster_label_file} -g {ground_truth_path}" - add_cmd(evaluation_command, job_script) + add_clustering_evaluation( + job_script, cluster_label_file, amst_ds_path, + ground_truth_path, evaluator, noise_point_assigner_exe, + ygm_cluster_eval, num_tasks_per_node, verbose, + singleton_cluster_to_noise_points) + + job_script.write("echo\n") + job_script.write("date\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"Assign clusters to noise points\"\n") + job_script.write(f"echo ================================\n") + # Remove the .txt extension and add .noise_assigned.txt + cluster_label_file_no_noise = f"{cluster_label_file[:-4]}.noise_assigned.txt" + cluster_assign_command = (f"{noise_point_assigner_exe} -M " + f"-m {amst_ds_path} " + f"-c {cluster_label_file} " + f"-o {cluster_label_file_no_noise}") + add_cmd(cluster_assign_command, job_script) + add_clustering_evaluation( + job_script, cluster_label_file_no_noise, amst_ds_path, + ground_truth_path, evaluator, noise_point_assigner_exe, + ygm_cluster_eval, num_tasks_per_node, verbose, + singleton_cluster_to_noise_points) + + job_script.write(f"echo \"\" \n") try_no += 1 @@ -353,8 +407,10 @@ def main(): opts.ygm_cluster_eval, opts.verbose, opts.ground_truth_path, + opts.dummy_cluster_id, min_cluster_size_set_cmnds, opts.min_samples, + opts.noise_point_assigner_exe, opts.input_dnnd_ds_path) print(f"Generated batch script: {job_script}") diff --git a/src/clustering/CMakeLists.txt b/src/clustering/CMakeLists.txt index 34039cb..b604f17 100644 --- a/src/clustering/CMakeLists.txt +++ b/src/clustering/CMakeLists.txt @@ -6,3 +6,8 @@ add_basic_executable(run_hdbscan_clustering run_hdbscan_clustering.cpp) setup_omp_target(run_hdbscan_clustering) setup_metall_target(run_hdbscan_clustering) setup_spdlog_target(run_hdbscan_clustering) + +add_basic_executable(cluster_noise_points cluster_noise_points.cpp) +setup_omp_target(cluster_noise_points) +setup_metall_target(cluster_noise_points) +setup_spdlog_target(cluster_noise_points) \ No newline at end of file diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp new file mode 100644 index 0000000..5842981 --- /dev/null +++ b/src/clustering/cluster_noise_points.cpp @@ -0,0 +1,218 @@ +// Assign cluster IDs to noise points by traversing the MST edges. +// Traverse the MST edges from each noise point in BFS manner until a point that +// belongs to a cluster is found. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../common.hpp" + +using namespace clams; + +template +using map_t = boost::unordered::unordered_flat_map; + +static constexpr id_t k_noise_cluster_id = static_cast(-1); + +struct option { + std::filesystem::path mst_edges_path; + bool metall_mst{false}; + std::filesystem::path cluster_ids_input_path; + std::filesystem::path cluster_ids_out_path; +}; + +void show_help() { + std::cout + << "<>\n" + "Required arguments:\n" + " -m path to a file or a directory that contains input MST.\n" + " -c path to input cluster IDs.\n" + " -o path to output cluster IDs.\n" + "Optional arguments:\n" + " -M If specified, input is a Metall datastore.\n" + " -h Show help." + << std::endl; +} + +void parse_option(int argc, char* argv[], option& opt) { + int opt_char; + while ((opt_char = getopt(argc, argv, "m:c:o:Mh")) != -1) { + switch (opt_char) { + case 'm': + opt.mst_edges_path = optarg; + break; + case 'c': + opt.cluster_ids_input_path = optarg; + break; + case 'o': + opt.cluster_ids_out_path = optarg; + break; + case 'M': + opt.metall_mst = true; + break; + case 'h': + show_help(); + std::exit(EXIT_SUCCESS); + default: + show_help(); + std::exit(EXIT_FAILURE); + } + } +} + +void read_cluster_ids(const std::filesystem::path& input_path, + map_t& point_cluster_map) { + spdlog::info("Reading cluster IDs from {}", input_path.string()); + std::ifstream ifs(input_path); + if (!ifs) { + std::cerr << "Failed to open " << input_path << std::endl; + std::abort(); + } + + std::string line; + while (std::getline(ifs, line)) { + if (line.empty() || line[0] == '#') { + continue; // Skip empty lines and comments + } + std::istringstream iss(line); + id_t point_id, cluster_id; + if (!(iss >> point_id >> cluster_id)) { + std::cerr << "Error parsing line: " << line << std::endl; + std::abort(); + } + point_cluster_map[point_id] = cluster_id; + } +} + +void dump_point_cluster_ids(const map_t& cluster_id, + const std::filesystem::path& output_path) { + std::ofstream ofs(output_path); + if (!ofs) { + std::cerr << "Failed to open " << output_path << std::endl; + std::abort(); + } + + for (const auto& [i, final_cluster_id] : cluster_id) { + ofs << i << "\t" << final_cluster_id; + ofs << "\n"; + } + ofs.close(); + if (!ofs) { + std::cerr << "Failed to write to " << output_path << std::endl; + std::abort(); + } +} + +int main(int argc, char* argv[]) { + option opt; + parse_option(argc, argv, opt); + + map_t> mst; + if (opt.metall_mst) { + spdlog::info("Attaching MST in Metall datastore"); + metall::manager metall_manager(metall::open_read_only, opt.mst_edges_path); + auto* input_mst_edges = + metall_manager.find(metall::unique_instance) + .first; + if (!input_mst_edges) { + spdlog::critical("Failed to find MST edges in Metall datastore at {}", + opt.mst_edges_path.string()); + std::abort(); + } + spdlog::info("Copying MST edges from Metall datastore"); + for (const auto& edge : *input_mst_edges) { + mst[edge.ids[0]].push_back(edge.ids[1]); + mst[edge.ids[1]].push_back(edge.ids[0]); + } + spdlog::info("#of MST edges: {}", mst.size()); + } else { + spdlog::info("Reading MST edges"); + weighted_edge_list_t input_mst_edges; + read_edges(opt.mst_edges_path, input_mst_edges); + assert(opt.min_cluster_size > 1); // Current implementation restriction. + spdlog::info("#of MST edges: {}", input_mst_edges.size()); + for (const auto& edge : input_mst_edges) { + mst[edge.ids[0]].push_back(edge.ids[1]); + mst[edge.ids[1]].push_back(edge.ids[0]); + } + } + + if (mst.empty()) { + spdlog::warn("No MST edges found in the input file or directory: {}", + opt.mst_edges_path.string()); + return EXIT_SUCCESS; + } + + map_t point_cluster_map; + read_cluster_ids(opt.cluster_ids_input_path, point_cluster_map); + spdlog::info("Read {} point cluster IDs from {}", point_cluster_map.size(), + opt.cluster_ids_input_path.string()); + + std::vector point_ids; + point_ids.reserve(point_cluster_map.size()); + for (const auto& [point_id, cluster_id] : point_cluster_map) { + point_ids.push_back(point_id); + } + + spdlog::info( + "Assigning cluster IDs to noise points by traversing the MST edges"); + std::size_t n_noise_points = 0; + std::size_t n_assigned_points = 0; + OMP_DIRECTIVE(parallel for reduction(+ : n_noise_points, n_assigned_points)) + for (size_t i = 0; i < point_ids.size(); ++i) { + const auto point_id = point_ids.at(i); + if (point_cluster_map.at(point_id) != k_noise_cluster_id) { + continue; // Already assigned to a cluster + } + ++n_noise_points; + + std::deque bfs_queue; + bfs_queue.push_back(point_id); + bool found_cluster = false; + + while (!bfs_queue.empty() && !found_cluster) { + const auto current_point_id = bfs_queue.front(); + bfs_queue.pop_front(); + + for (const auto neighbor_id : mst.at(current_point_id)) { + if (point_cluster_map.at(neighbor_id) != k_noise_cluster_id) { + point_cluster_map[point_id] = point_cluster_map.at(neighbor_id); + found_cluster = true; + ++n_assigned_points; + break; + } else { + bfs_queue.push_back(neighbor_id); + } + } + } + + if (!found_cluster) { + spdlog::warn("Point {} could not be assigned to any cluster.", point_id); + } + } + spdlog::info("Finished assigning cluster IDs to noise points"); + spdlog::info("Number of noise points: {}", n_noise_points); + spdlog::info("Number of noise points assigned to clusters: {}", + n_assigned_points); + + dump_point_cluster_ids(point_cluster_map, opt.cluster_ids_out_path); + spdlog::info("Dumped point cluster IDs to {}", + opt.cluster_ids_out_path.string()); + + return EXIT_SUCCESS; +} From 495a90385896df021ebb64be7e172b808d6bc943 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 13 Aug 2026 07:03:08 -0700 Subject: [PATCH 09/21] Add copy_pm_datastore --- src/tools/CMakeLists.txt | 6 ++- src/tools/copy_pm_datastore.cpp | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/tools/copy_pm_datastore.cpp diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 0a5e994..68e2516 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -1,6 +1,8 @@ # Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS # Project Developers. See the top-level COPYRIGHT file for details. - add_basic_executable(conv_knng_to_mreach_dist conv_knng_to_mreach_dist.cpp) -setup_saltatlas_target(conv_knng_to_mreach_dist) \ No newline at end of file +setup_saltatlas_target(conv_knng_to_mreach_dist) + +add_basic_executable(copy_pm_datastore copy_pm_datastore.cpp) +setup_saltatlas_target(copy_pm_datastore) \ No newline at end of file diff --git a/src/tools/copy_pm_datastore.cpp b/src/tools/copy_pm_datastore.cpp new file mode 100644 index 0000000..2ff01cd --- /dev/null +++ b/src/tools/copy_pm_datastore.cpp @@ -0,0 +1,78 @@ +// Copyright 2026 Lawrence Livermore National Security, LLC and other Metall +// Project Developers. See the top-level COPYRIGHT file for details. +// +// SPDX-License-Identifier: (Apache-2.0 OR MIT) + +// This program copies a Metall datastore created by the metall_mpi_adaptor +// utility to a new location. It is useful for copying a large datastore because +// it can copy the datastore in parallel using multiple MPI processes. + +#include + +#include +#include +#include + +#include + +#include + +namespace { + +bool parse_options(int argc, char **argv, std::filesystem::path &datastore_path, + std::filesystem::path ©_path, bool &overwrite) { + overwrite = false; + int option; + while ((option = ::getopt(argc, argv, "s:t:o")) != -1) { + switch (option) { + case 's': + datastore_path = optarg; + break; + case 't': + copy_path = optarg; + break; + case 'o': + overwrite = true; + break; + default: + return false; + } + } + + return !datastore_path.empty() && !copy_path.empty() && optind == argc; +} + +} // namespace + +int main(int argc, char **argv) { + ygm::comm comm(&argc, &argv); + { + std::filesystem::path datastore_path; + std::filesystem::path copy_path; + bool overwrite = false; + + if (!parse_options(argc, argv, datastore_path, copy_path, overwrite)) { + comm.cerr0() << "Usage: " << argv[0] + << " -s -t " + " [-o (overwrite)]" + << std::endl; + return EXIT_FAILURE; + } + + comm.cout0() << "Copying a Metall datastore from " << datastore_path + << " to " << copy_path << ", overwrite=" << overwrite + << std::endl; + + const bool ret = metall::utility::metall_mpi_adaptor::copy( + datastore_path, copy_path, comm.get_mpi_comm(), overwrite); + comm.cf_barrier(); + + if (ret) { + comm.cout0() << "Done." << std::endl; + } else { + comm.cerr0() << "Failed to copy the Metall datastore." << std::endl; + } + } + + return 0; +} From a7f510db98a7b42e91ed829fb9df80f4c2f1748c Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 13 Aug 2026 07:16:24 -0700 Subject: [PATCH 10/21] Fix build error in cluster_noise_points.cpp --- src/clustering/cluster_noise_points.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp index 5842981..b36ab14 100644 --- a/src/clustering/cluster_noise_points.cpp +++ b/src/clustering/cluster_noise_points.cpp @@ -144,7 +144,6 @@ int main(int argc, char* argv[]) { spdlog::info("Reading MST edges"); weighted_edge_list_t input_mst_edges; read_edges(opt.mst_edges_path, input_mst_edges); - assert(opt.min_cluster_size > 1); // Current implementation restriction. spdlog::info("#of MST edges: {}", input_mst_edges.size()); for (const auto& edge : input_mst_edges) { mst[edge.ids[0]].push_back(edge.ids[1]); From 726511fbd3cdb8f58d8d3436111f179840e0f02b Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 13 Aug 2026 09:33:47 -0700 Subject: [PATCH 11/21] Improve run_clams_bench.py --- script/benchmark/run_clams_bench.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index eff7811..13b416a 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -159,7 +159,7 @@ def generate_job_name(): def add_clustering_evaluation(job_script, cluster_label_file, amst_ds_path, ground_truth_path, evaluator, - noise_point_assigner_exe, ygm_cluster_eval, + ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points): job_script.write("echo\n") @@ -169,18 +169,11 @@ def add_clustering_evaluation(job_script, cluster_label_file, amst_ds_path, job_script.write(f"echo ================================\n") if ygm_cluster_eval: - evaluated_label_file = f"{cluster_label_file}.noise_assigned" - assignment_command = (f"{noise_point_assigner_exe} -M " - f"-m {amst_ds_path} " - f"-c {cluster_label_file} " - f"-o {evaluated_label_file}") - add_cmd(assignment_command, job_script) - job_script.write("echo \"Evaluating Clustering using YGM\"\n") verbose_flag = " -v" if verbose else "" - evaluation_command = (f"{evaluator}{verbose_flag} " + evaluation_command = (f"{evaluator} {verbose_flag} " f"-g {ground_truth_path} " - f"{evaluated_label_file}") + f"{cluster_label_file}") add_srun_cmd(num_tasks_per_node, evaluation_command, job_script) else: job_script.write( @@ -311,7 +304,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, if ground_truth_path: add_clustering_evaluation( job_script, cluster_label_file, amst_ds_path, - ground_truth_path, evaluator, noise_point_assigner_exe, + ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points) @@ -327,9 +320,11 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, f"-c {cluster_label_file} " f"-o {cluster_label_file_no_noise}") add_cmd(cluster_assign_command, job_script) + + add_clustering_evaluation( job_script, cluster_label_file_no_noise, amst_ds_path, - ground_truth_path, evaluator, noise_point_assigner_exe, + ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points) From 756c207604b0b79cadbabfc605e9e8f852920f1d Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 3 Sep 2026 20:44:50 -0700 Subject: [PATCH 12/21] Enhance clustering evaluation and correlation analysis - Updated eval_clusters function to include an option to ignore true noise points. - Added new tool for evaluating correlation between kNNG and clusters. --- script/benchmark/clustering_utilities.py | 16 +- .../benchmark/evaluate_clustering_quality.py | 12 +- script/benchmark/run_hdbscan_bench.py | 2 +- src/clustering/cluster_noise_points.cpp | 60 ++----- src/common.hpp | 60 ++++++- src/tools/CMakeLists.txt | 8 +- ...evaluate_correlation_knng_and_clusters.cpp | 156 ++++++++++++++++++ 7 files changed, 251 insertions(+), 63 deletions(-) create mode 100644 src/tools/evaluate_correlation_knng_and_clusters.cpp diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index e72f8be..2dbc633 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -10,17 +10,23 @@ # Modified from: # https://gist.github.com/lmcinnes/24ed5c22c80125be5133811d677eae7b -def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points=False): +def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points=False, ignore_true_noise_points=False): max_cluster_id = max(np.max(cluster_labels), np.max(true_labels)) if np.any(true_labels < 0): print("Ground truth labels contain noise points") pct_clustered_gt = (np.sum(true_labels >= 0) / cluster_labels.shape[0]) print(f"GT clustered Points: {pct_clustered_gt * 100:.2f}%") - print( - "Assigning a singleton cluster to each noise point in the ground truth labels") - true_labels = assign_singleton_cluster_to_noise_points(true_labels, max_cluster_id) - max_cluster_id = max(max_cluster_id, np.max(true_labels)) + + if ignore_true_noise_points: + print("Remove true noise points from the evaluation") + print(f"Before filtering: {len(true_labels)} points") + mask = true_labels >= 0 + true_labels = true_labels[mask] + cluster_labels = cluster_labels[mask] + max_cluster_id = max(np.max(cluster_labels), np.max(true_labels)) + print(f"After filtering: {len(true_labels)} points") + if np.any(cluster_labels < 0): # Has noise points clustered_points = (cluster_labels >= 0) diff --git a/script/benchmark/evaluate_clustering_quality.py b/script/benchmark/evaluate_clustering_quality.py index 0cc5813..6dc700a 100644 --- a/script/benchmark/evaluate_clustering_quality.py +++ b/script/benchmark/evaluate_clustering_quality.py @@ -41,7 +41,7 @@ def parse_options(): parser = argparse.ArgumentParser( - description='Evaluate kNN index') + description='Evaluate clustering quality') parser.add_argument('-c', '--cluster', dest='cluster_path', @@ -52,7 +52,10 @@ def parse_options(): required=True, action='store', type=str, help='Path to file or directory containing ground truth labels') parser.add_argument('-s', '--singleton_noise_points', action='store_true', - help='Assign a singleton cluster to each noise point in the clustering result.') + help='Assign a singleton cluster to each noise point in the clustering labels to evaluate.') + parser.add_argument('-T', '--ignore_true_noise_points', action='store_true', + help='Remove true noise points from the evaluation.') + args = parser.parse_args() return args @@ -64,10 +67,7 @@ def main(): true_labels = read_label_data(opt.gt_path) try: - eval_clusters(cluster_labels, true_labels, False) - contains_noise_points = np.any(cluster_labels == -1) - if opt.singleton_noise_points and contains_noise_points: - eval_clusters(cluster_labels, true_labels, True) + eval_clusters(cluster_labels, true_labels, opt.singleton_noise_points, opt.ignore_true_noise_points) except Exception as e: print(f"Error: {e}") exit(1) diff --git a/script/benchmark/run_hdbscan_bench.py b/script/benchmark/run_hdbscan_bench.py index d2454de..88b40d0 100644 --- a/script/benchmark/run_hdbscan_bench.py +++ b/script/benchmark/run_hdbscan_bench.py @@ -13,7 +13,7 @@ import argparse from clustering_utilities import * from script.benchmark.bench_utilities import * -from hdbscan.run_hdbscan import run_hdbscan +from script.benchmark.hdbscan.run_hdbscan import run_hdbscan def parse_options(): diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp index b36ab14..faa18db 100644 --- a/src/clustering/cluster_noise_points.cpp +++ b/src/clustering/cluster_noise_points.cpp @@ -27,8 +27,6 @@ using namespace clams; template using map_t = boost::unordered::unordered_flat_map; -static constexpr id_t k_noise_cluster_id = static_cast(-1); - struct option { std::filesystem::path mst_edges_path; bool metall_mst{false}; @@ -75,49 +73,6 @@ void parse_option(int argc, char* argv[], option& opt) { } } -void read_cluster_ids(const std::filesystem::path& input_path, - map_t& point_cluster_map) { - spdlog::info("Reading cluster IDs from {}", input_path.string()); - std::ifstream ifs(input_path); - if (!ifs) { - std::cerr << "Failed to open " << input_path << std::endl; - std::abort(); - } - - std::string line; - while (std::getline(ifs, line)) { - if (line.empty() || line[0] == '#') { - continue; // Skip empty lines and comments - } - std::istringstream iss(line); - id_t point_id, cluster_id; - if (!(iss >> point_id >> cluster_id)) { - std::cerr << "Error parsing line: " << line << std::endl; - std::abort(); - } - point_cluster_map[point_id] = cluster_id; - } -} - -void dump_point_cluster_ids(const map_t& cluster_id, - const std::filesystem::path& output_path) { - std::ofstream ofs(output_path); - if (!ofs) { - std::cerr << "Failed to open " << output_path << std::endl; - std::abort(); - } - - for (const auto& [i, final_cluster_id] : cluster_id) { - ofs << i << "\t" << final_cluster_id; - ofs << "\n"; - } - ofs.close(); - if (!ofs) { - std::cerr << "Failed to write to " << output_path << std::endl; - std::abort(); - } -} - int main(int argc, char* argv[]) { option opt; parse_option(argc, argv, opt); @@ -180,8 +135,10 @@ int main(int argc, char* argv[]) { } ++n_noise_points; - std::deque bfs_queue; + std::deque bfs_queue; + map_t visited; bfs_queue.push_back(point_id); + visited[point_id] = true; bool found_cluster = false; while (!bfs_queue.empty() && !found_cluster) { @@ -189,12 +146,16 @@ int main(int argc, char* argv[]) { bfs_queue.pop_front(); for (const auto neighbor_id : mst.at(current_point_id)) { + if (visited.find(neighbor_id) != visited.end()) { + continue; // Already visited, e.g., the node we came from + } if (point_cluster_map.at(neighbor_id) != k_noise_cluster_id) { point_cluster_map[point_id] = point_cluster_map.at(neighbor_id); found_cluster = true; ++n_assigned_points; break; } else { + visited[neighbor_id] = true; bfs_queue.push_back(neighbor_id); } } @@ -205,9 +166,10 @@ int main(int argc, char* argv[]) { } } spdlog::info("Finished assigning cluster IDs to noise points"); - spdlog::info("Number of noise points: {}", n_noise_points); - spdlog::info("Number of noise points assigned to clusters: {}", - n_assigned_points); + spdlog::info("Number of noise points in the original data: {}", + n_noise_points); + spdlog::info("Number of remaining noise points: {}", + n_noise_points - n_assigned_points); dump_point_cluster_ids(point_cluster_map, opt.cluster_ids_out_path); spdlog::info("Dumped point cluster IDs to {}", diff --git a/src/common.hpp b/src/common.hpp index b75efc6..5c21616 100644 --- a/src/common.hpp +++ b/src/common.hpp @@ -22,6 +22,8 @@ namespace clams { +static constexpr id_t k_noise_cluster_id = static_cast(-1); + inline std::vector find_files( const std::filesystem::path &path) { std::vector files; @@ -159,4 +161,60 @@ inline void read_knng_edges( } } } -} // namespace clams \ No newline at end of file + +/// Reads point-to-cluster assignments from `input_path` into +/// `point_cluster_map`. +/// +/// Each nonempty, noncomment input line must contain a point ID followed by its +/// cluster ID, separated by whitespace. Lines whose first character is `#` are +/// ignored. The output maps each point ID to its cluster ID; if an ID occurs +/// more than once, the last assignment wins. +template +void read_cluster_ids(const std::filesystem::path &input_path, + cluster_id_table_t &point_cluster_map) { + using id_t = typename cluster_id_table_t::key_type; + using cluster_id_t = typename cluster_id_table_t::mapped_type; + + std::ifstream ifs(input_path); + if (!ifs) { + std::cerr << "Failed to open " << input_path << std::endl; + std::abort(); + } + + std::string line; + while (std::getline(ifs, line)) { + if (line.empty() || line[0] == '#') { + continue; // Skip empty lines and comments + } + std::istringstream iss(line); + id_t point_id; + cluster_id_t cluster_id; + if (!(iss >> point_id >> cluster_id)) { + std::cerr << "Error parsing line: " << line << std::endl; + std::abort(); + } + point_cluster_map[point_id] = cluster_id; + } +} + +template +void dump_point_cluster_ids(const cluster_id_table_t &cluster_id, + const std::filesystem::path &output_path) { + std::ofstream ofs(output_path); + if (!ofs) { + std::cerr << "Failed to open " << output_path << std::endl; + std::abort(); + } + + for (const auto &[i, final_cluster_id] : cluster_id) { + ofs << i << "\t" << final_cluster_id; + ofs << "\n"; + } + ofs.close(); + if (!ofs) { + std::cerr << "Failed to write to " << output_path << std::endl; + std::abort(); + } +} + +} // namespace clams diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 68e2516..8c9744f 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -5,4 +5,10 @@ add_basic_executable(conv_knng_to_mreach_dist conv_knng_to_mreach_dist.cpp) setup_saltatlas_target(conv_knng_to_mreach_dist) add_basic_executable(copy_pm_datastore copy_pm_datastore.cpp) -setup_saltatlas_target(copy_pm_datastore) \ No newline at end of file +setup_saltatlas_target(copy_pm_datastore) + +add_basic_executable(evaluate_correlation_knng_and_clusters + evaluate_correlation_knng_and_clusters.cpp) +setup_omp_target(evaluate_correlation_knng_and_clusters) +setup_metall_target(evaluate_correlation_knng_and_clusters) +setup_spdlog_target(evaluate_correlation_knng_and_clusters) \ No newline at end of file diff --git a/src/tools/evaluate_correlation_knng_and_clusters.cpp b/src/tools/evaluate_correlation_knng_and_clusters.cpp new file mode 100644 index 0000000..0600bdf --- /dev/null +++ b/src/tools/evaluate_correlation_knng_and_clusters.cpp @@ -0,0 +1,156 @@ +// Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS +// Project Developers. See the top-level COPYRIGHT file for details. + +// Read kNNG and compute how well the kNNG and clusters are correlated. + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../common.hpp" +#include "../details/shm_graph.hpp" + +namespace omp = metall::utility::omp; + +using cluster_id_table_t = boost::unordered::unordered_flat_map; + +// todo: +// add 'max_k' option (-k): the maximum number of neighbors to consider for +// correlation analysis. +bool parse_option(int argc, char *argv[], + std::filesystem::path &input_knng_path, + std::filesystem::path &input_clusters_path, size_t &max_k) { + input_knng_path.clear(); + + int opt; + while ((opt = ::getopt(argc, argv, "g:c:k:")) != -1) { + switch (opt) { + case 'g': { + input_knng_path = std::filesystem::path(optarg); + break; + } + case 'c': { + input_clusters_path = std::filesystem::path(optarg); + break; + } + case 'k': { + max_k = std::stoul(optarg); + break; + } + default: { + std::cerr << "Unknown option: " << opt << std::endl; + return false; + } + } + } + + if (input_knng_path.empty()) { + std::cerr << "No input kNNG path is specified" << std::endl; + return false; + } + + if (input_clusters_path.empty()) { + std::cerr << "No input clusters path is specified" << std::endl; + return false; + } + + return true; +} + +int main(int argc, char *argv[]) { + std::filesystem::path input_knng_path; + std::filesystem::path input_clusters_path; + size_t max_k = 100; // default value + + if (!parse_option(argc, argv, input_knng_path, input_clusters_path, max_k)) { + return EXIT_FAILURE; + } + + const auto knng_files = clams::find_files(input_knng_path); + + clams::shm_graph_t graph; + + std::cout << "Read knng" << std::endl; + clams::read_knng(knng_files, graph); + std::cout << "#of points: " << graph.num_keys() << std::endl; + std::cout << "#of neighbors: " << graph.num_values() << std::endl; + + cluster_id_table_t cluster_id_table; + clams::read_cluster_ids(input_clusters_path, cluster_id_table); + std::cout << "#of points with clusters: " << cluster_id_table.size() + << std::endl; + const auto &cluster_ids = cluster_id_table; + + size_t max_n_neighbors = 0; + std::vector point_ids; + point_ids.reserve(graph.num_keys()); + for (auto itr = graph.keys_begin(); itr != graph.keys_end(); ++itr) { + point_ids.push_back(itr->first); + max_n_neighbors = std::max(max_n_neighbors, graph.num_values(itr->first)); + } + std::cout << "max_n_neighbors: " << max_n_neighbors << std::endl; + + size_t n_neighbors_with_same_cluster = 0; + size_t n_neighbors_with_different_cluster = 0; + size_t n_missing_neighbors = 0; + size_t n_missing_cluster_ids = 0; + size_t n_noise_cluster_points = 0; + std::cout + << "k\tsame_cluster\tdifferent_cluster\tcorrelation_rate(%)\tmissing_" + "neighbors\tmissing_cluster_ids" + << std::endl; + for (size_t k = 0; k < std::min(max_n_neighbors, max_k); ++k) { + OMP_DIRECTIVE(parallel for reduction(+ : n_neighbors_with_same_cluster, n_neighbors_with_different_cluster, n_missing_neighbors, n_missing_cluster_ids)) + for (size_t pi = 0; pi < point_ids.size(); ++pi) { + const auto pid = point_ids[pi]; + if (graph.num_values(pid) <= k) { + ++n_missing_neighbors; + continue; + } + + const auto neighbor_id = (graph.values_begin(pid) + k)->first; + const auto point_cluster_itr = cluster_ids.find(pid); + const auto neighbor_cluster_itr = cluster_ids.find(neighbor_id); + if (point_cluster_itr == cluster_ids.end() || + neighbor_cluster_itr == cluster_ids.end()) { + ++n_missing_cluster_ids; + continue; + } + const auto point_cluster_id = point_cluster_itr->second; + const auto neighbor_cluster_id = neighbor_cluster_itr->second; + if (point_cluster_id == clams::k_noise_cluster_id || + neighbor_cluster_id == clams::k_noise_cluster_id) { + ++n_noise_cluster_points; + continue; + } + + if (point_cluster_itr->second == neighbor_cluster_itr->second) { + ++n_neighbors_with_same_cluster; + } else { + ++n_neighbors_with_different_cluster; + } + } + // Show statistics for each k + const auto n_neighbors_with_clusters = + n_neighbors_with_same_cluster + n_neighbors_with_different_cluster; + const auto correlation_rate = + n_neighbors_with_clusters == 0 + ? 0.0 + : static_cast(n_neighbors_with_same_cluster) / + n_neighbors_with_clusters; + std::cout << k + 1 << "\t" << n_neighbors_with_same_cluster << "\t" + << n_neighbors_with_different_cluster << "\t" + << correlation_rate * 100.0 << "\t" << n_missing_neighbors << "\t" + << n_missing_cluster_ids << std::endl; + } + + return EXIT_SUCCESS; +} From 0c57721e92669327758ecc9e1ebade84d768a490 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 3 Sep 2026 21:58:59 -0700 Subject: [PATCH 13/21] Enhance documentation for kNNG functions and correlation evaluation --- src/common.hpp | 4 ++ src/details/shm_graph.hpp | 37 ++++++++++++------- ...evaluate_correlation_knng_and_clusters.cpp | 8 +++- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/common.hpp b/src/common.hpp index 5c21616..705e429 100644 --- a/src/common.hpp +++ b/src/common.hpp @@ -79,6 +79,10 @@ inline void read_edges(const std::filesystem::path &path, } } +/// \brief Read a k-nearest-neighbor graph (kNNG) from files and store it as +/// an edge list. +/// \param knng_files A list of knng files. +/// \param graph A graph to store the knng. inline void read_knng_edges( const std::vector &knng_files, weighted_edge_list_t &edges) { diff --git a/src/details/shm_graph.hpp b/src/details/shm_graph.hpp index 7aff55e..cc682ac 100644 --- a/src/details/shm_graph.hpp +++ b/src/details/shm_graph.hpp @@ -1,7 +1,6 @@ // Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS // Project Developers. See the top-level COPYRIGHT file for details. - #pragma once #include @@ -12,21 +11,31 @@ #include #include -#include "multithread_adjacency_list.hpp" #include "data_types.hpp" +#include "multithread_adjacency_list.hpp" namespace clams { -using shm_graph_t = multithread_adjacency_list>; +using shm_graph_t = + multithread_adjacency_list>; /// \brief Read knng files. /// \param knng_files A list of knng files. /// \param graph A graph to store the knng. +/// \details Each knng file is expected to have the following format: +/// - Each even numbered line (0-based) contains a source vertex ID followed +/// by its neighbor vertex IDs, separated by whitespace. +/// - Each odd numbered line contains the corresponding distances to the +/// neighbors, separated by whitespace. +/// - The first neighbor ID in each even numbered line is the source vertex ID +/// itself, and the corresponding distance in the odd numbered line is 0.0 +/// (dummy distance). +/// - The number of neighbor IDs in each even numbered line must match the +/// number of distances in the corresponding odd numbered void read_knng(const std::vector &knng_files, - shm_graph_t &graph) { + shm_graph_t &graph) { OMP_DIRECTIVE(parallel for) for (std::size_t fno = 0; fno < knng_files.size(); ++fno) { - const auto &file = knng_files[fno]; + const auto &file = knng_files[fno]; std::ifstream ifs(file); if (!ifs.is_open()) { std::cerr << "Cannot open file: " << file << std::endl; @@ -39,7 +48,7 @@ void read_knng(const std::vector &knng_files, { std::getline(ifs, line); std::istringstream iss(line); - id_t buf; + id_t buf; while (iss >> buf) { ids.push_back(buf); } @@ -49,7 +58,7 @@ void read_knng(const std::vector &knng_files, { std::getline(ifs, line); std::istringstream iss(line); - distance_t buf; + distance_t buf; while (iss >> buf) { dists.push_back(buf); } @@ -92,7 +101,7 @@ void dump_graph(const shm_graph_t &graph, const std::filesystem::path &file) { for (auto eit = graph.values_begin(vid); eit != graph.values_end(vid); ++eit) { ofs << " " << eit->first; - } + } ofs << std::endl; // Distances @@ -100,7 +109,7 @@ void dump_graph(const shm_graph_t &graph, const std::filesystem::path &file) { for (auto eit = graph.values_begin(vid); eit != graph.values_end(vid); ++eit) { ofs << eit->second << " "; - } + } ofs << std::endl; } } @@ -118,10 +127,10 @@ void make_undirected_graph(shm_graph_t &graph) { const auto vid = vertices[i]; for (auto eit = graph.values_begin(vid); eit != graph.values_end(vid); ++eit) { - const auto nid = eit->first; + const auto nid = eit->first; const auto dist = eit->second; r_graph.add(nid, std::make_pair(vid, dist)); - } + } } // Merge the reversed graph to the original graph @@ -134,7 +143,7 @@ void make_undirected_graph(shm_graph_t &graph) { for (auto eit = r_graph.values_begin(vid); eit != r_graph.values_end(vid); ++eit) { graph.add(vid, *eit); - } + } } } -} \ No newline at end of file +} // namespace clams \ No newline at end of file diff --git a/src/tools/evaluate_correlation_knng_and_clusters.cpp b/src/tools/evaluate_correlation_knng_and_clusters.cpp index 0600bdf..35178a4 100644 --- a/src/tools/evaluate_correlation_knng_and_clusters.cpp +++ b/src/tools/evaluate_correlation_knng_and_clusters.cpp @@ -1,7 +1,13 @@ // Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS // Project Developers. See the top-level COPYRIGHT file for details. -// Read kNNG and compute how well the kNNG and clusters are correlated. +// This program evaluates how strongly a k-nearest-neighbor graph (kNNG) agrees +// with clustering result. For each point, it checks whether its nearest k +// neighbors belong to the same cluster as the point itself. It reports the +// correlation rate for each k, which is the fraction of neighbors that are in +// the same cluster as the point. A high correlation rate indicates that the +// clustering result is consistent with the local neighborhood structure of the +// data. #include From b4f47fd1dfa8f190d7c5bec2f7c866baa4e1695a Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Thu, 3 Sep 2026 23:37:18 -0700 Subject: [PATCH 14/21] Refactor clustering evaluation output and streamline echo statements in job script --- script/benchmark/clustering_utilities.py | 16 -------- script/benchmark/run_clams_bench.py | 52 +++++++----------------- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index 2ccf18e..91e85f4 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -33,21 +33,6 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points pct_clustered = (np.sum(clustered_points) / cluster_labels.shape[0]) print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") - if singleton_cluster_to_noise_points: - print( - "Assigning a singleton cluster to each noise point in the clustering result") - cluster_labels = assign_singleton_cluster_to_noise_points(cluster_labels, max_cluster_id) - ari = adjusted_rand_score(true_labels, cluster_labels) - ami = adjusted_mutual_info_score(true_labels, cluster_labels) - else: - ari = adjusted_rand_score(true_labels[clustered_points], - cluster_labels[clustered_points]) - ami = adjusted_mutual_info_score(true_labels[clustered_points], - cluster_labels[clustered_points]) - # sil = silhouette_score(raw_data[clustered_points], cluster_labels[clustered_points]) - pct_clustered = (np.sum(clustered_points) / cluster_labels.shape[0]) - print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") - if singleton_cluster_to_noise_points: print( "Assigning a singleton cluster to each noise point in the clustering result") @@ -71,7 +56,6 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points # Assign a cluster ID to each noise point -def assign_singleton_cluster_to_noise_points(cluster_labels, noise_id_offset): def assign_singleton_cluster_to_noise_points(cluster_labels, noise_id_offset): new_labels = cluster_labels.copy() diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index c67a5c1..9e3d45e 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -159,18 +159,18 @@ def generate_job_name(): time.sleep(2) return f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}" +def echo_stage_name(job_script, stage_name): + job_script.write("echo\n") + job_script.write("date\n") + job_script.write(f"echo ================================\n") + job_script.write(f"echo \"{stage_name}\"\n") + job_script.write(f"echo ================================\n") def add_clustering_evaluation(job_script, cluster_label_file, amst_ds_path, ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points): - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Evaluating Clustering\"\n") - job_script.write(f"echo ================================\n") - if ygm_cluster_eval: job_script.write("echo \"Evaluating Clustering using YGM\"\n") verbose_flag = " -v" if verbose else "" @@ -227,9 +227,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, job_script.write("echo\n") job_script.write("date\n") if len(input_dnnd_ds_path) == 0: - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Building KNNG\"\n") - job_script.write(f"echo ================================\n") + echo_stage_name(job_script, "Building KNNG") dnnd_ds_path = f"{work_dir}/dnnd_pm_datastore" dnnd_batch_size = 2 ** 25 verbose_flag = '-v' if verbose else '' @@ -237,8 +235,6 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, add_srun_cmd(num_tasks_per_node, dnnd_command, job_script) if backup_knng: dnnd_ds_path_backup = f"{dnnd_ds_path}_backup" - job_script.write(f"echo\n") - job_script.write("date\n") job_script.write(f"echo \"Backing up KNNG datastore\"\n") backup_knng_command = f"cp -r {dnnd_ds_path} {dnnd_ds_path_backup}" add_cmd(backup_knng_command, job_script) @@ -248,22 +244,14 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, dnnd_ds_path = input_dnnd_ds_path # Connect the CCs - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Running MFC\"\n") - job_script.write(f"echo ================================\n") + echo_stage_name(job_script, "Connecting the CCs using MFC") mfc_command = f"{mfc_exe} -d {dnnd_ds_path} -f {distance_func}" add_srun_cmd(num_tasks_per_node, mfc_command, job_script) # Convert to core distance # TODO: Implement if False and min_samples > 0: - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo Convert to core distance kNNG\n") - job_script.write(f"echo ================================\n") + echo_stage_name(job_script, "Convert to core distance kNNG") knng_coredist_dir = f"{work_dir}/knng_coredist/" add_cmd(f'mkdir -p {knng_coredist_dir}', job_script) conv2coredist_cmd = f"./src/conv_knng_to_core_dist -i {dnnd_ds_path} -o {knng_coredist_dir}/knng.txt -m {min_samples}" @@ -272,11 +260,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, try_no = 0 for amst_approx_bound in amst_approx_bound_list: # Run the AMST step - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Running AMST, approx bound = {amst_approx_bound}\"\n") - job_script.write(f"echo ================================\n") + echo_stage_name(job_script, f"Running AMST, approx bound = {amst_approx_bound}") amst_ds_path = f"{work_dir}/amst_pm_datastore_a{amst_approx_bound}" amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path} -e {amst_approx_bound}" add_srun_cmd(num_tasks_per_node, amst_command, job_script) @@ -286,12 +270,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, # Set the min cluster size environment variable for this run add_cmd(set_cmd, job_script, False, False) - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Running CLAMS-HDBSCAN, min cluster size = ${{MIN_CLUSTER_SIZE}}\"\n") - job_script.write(f"echo ================================\n") - + echo_stage_name(job_script, f"Running CLAMS-HDBSCAN") job_script.write( f"echo \"Min cluster size ${{MIN_CLUSTER_SIZE}}\"\n") if distributed_hdbscan: @@ -316,17 +295,14 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, # Run the evaluation step if ground_truth_path: + echo_stage_name(job_script, "Evaluating Clustering Results") add_clustering_evaluation( job_script, cluster_label_file, amst_ds_path, ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points) - job_script.write("echo\n") - job_script.write("date\n") - job_script.write(f"echo ================================\n") - job_script.write(f"echo \"Assign clusters to noise points\"\n") - job_script.write(f"echo ================================\n") + echo_stage_name(job_script, "Assign clusters to noise points") # Remove the .txt extension and add .noise_assigned.txt cluster_label_file_no_noise = f"{cluster_label_file[:-4]}.noise_assigned.txt" cluster_assign_command = (f"{noise_point_assigner_exe} -M " @@ -335,7 +311,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, f"-o {cluster_label_file_no_noise}") add_cmd(cluster_assign_command, job_script) - + echo_stage_name(job_script, "Evaluate clustering results after assigning clusters to noise points") add_clustering_evaluation( job_script, cluster_label_file_no_noise, amst_ds_path, ground_truth_path, evaluator, From c2000683987c85d95b94fefa2d367bc43af6d51a Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 4 Sep 2026 01:42:40 -0700 Subject: [PATCH 15/21] Refactor clustering execution logic into a dedicated function for improved readability and maintainability --- script/benchmark/run_clams_bench.py | 115 +++++++++++++++------------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index 9e3d45e..fd9c0bd 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -188,6 +188,62 @@ def add_clustering_evaluation(job_script, cluster_label_file, amst_ds_path, evaluation_command += " -s" add_cmd(evaluation_command, job_script) +def run_clustering(job_script, set_cmd, work_dir, amst_approx_bound, + amst_ds_path, clustering_exe, distributed_hdbscan, + evaluator, ygm_cluster_eval, num_tasks_per_node, + verbose, ground_truth_path, + singleton_cluster_to_noise_points, + noise_point_assigner_exe): + # Set the min cluster size environment variable for this run + add_cmd(set_cmd, job_script, False, False) + + echo_stage_name(job_script, "Running CLAMS-HDBSCAN") + job_script.write(f"echo \"Min cluster size ${{MIN_CLUSTER_SIZE}}\"\n") + if distributed_hdbscan: + cluster_label_file = f"{work_dir}/cluster_labels_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}/" + cluster_tree_file = f"{work_dir}/cluster_tree_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}/" + verbose_flag = '-v' if verbose else '' + hpc_clustering_command = (f"{clustering_exe} {verbose_flag} -i {amst_ds_path} -M " + f" -m ${{MIN_CLUSTER_SIZE}} " + f" -o {cluster_label_file} " + f" -c {cluster_tree_file} " + f" -n {num_tasks_per_node}") + add_srun_cmd(num_tasks_per_node, hpc_clustering_command, job_script) + else: + cluster_label_file = f"{work_dir}/cluster_labels_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}.txt" + cluster_tree_file = f"{work_dir}/cluster_tree_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}.txt" + hpc_clustering_command = (f"{clustering_exe} -i {amst_ds_path} -M " + f" -m ${{MIN_CLUSTER_SIZE}} " + f" -o {cluster_label_file} " + f" -c {cluster_tree_file} " + f" -P ") + add_cmd(hpc_clustering_command, job_script) + + if ground_truth_path: + echo_stage_name(job_script, "Evaluating Clustering Results") + add_clustering_evaluation( + job_script, cluster_label_file, amst_ds_path, ground_truth_path, + evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, + singleton_cluster_to_noise_points) + + echo_stage_name(job_script, "Assign clusters to noise points") + cluster_label_file_no_noise = f"{cluster_label_file[:-4]}.noise_assigned.txt" + cluster_assign_command = (f"{noise_point_assigner_exe} -M " + f"-m {amst_ds_path} " + f"-c {cluster_label_file} " + f"-o {cluster_label_file_no_noise}") + add_cmd(cluster_assign_command, job_script) + + echo_stage_name( + job_script, + "Evaluate clustering results after assigning clusters to noise points") + add_clustering_evaluation( + job_script, cluster_label_file_no_noise, amst_ds_path, + ground_truth_path, evaluator, ygm_cluster_eval, + num_tasks_per_node, verbose, singleton_cluster_to_noise_points) + + job_script.write("echo \"\" \n") + # Function to generate the batch script for running a benchmark # @@ -267,59 +323,12 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, # Run the HPC Clustering step for set_cmd in min_cluster_size_set_cmnds: - # Set the min cluster size environment variable for this run - add_cmd(set_cmd, job_script, False, False) - - echo_stage_name(job_script, f"Running CLAMS-HDBSCAN") - job_script.write( - f"echo \"Min cluster size ${{MIN_CLUSTER_SIZE}}\"\n") - if distributed_hdbscan: - cluster_label_file = f"{work_dir}/cluster_labels_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}/" - cluster_tree_file = f"{work_dir}/cluster_tree_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}/" - verbose_flag = '-v' if verbose else '' - hpc_clustering_command = (f"{clustering_exe} {verbose_flag} -i {amst_ds_path} -M " - f" -m ${{MIN_CLUSTER_SIZE}} " - f" -o {cluster_label_file} " - f" -c {cluster_tree_file} " - f" -n {num_tasks_per_node}") - add_srun_cmd(num_tasks_per_node, hpc_clustering_command, job_script) - else: - cluster_label_file = f"{work_dir}/cluster_labels_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}.txt" - cluster_tree_file = f"{work_dir}/cluster_tree_a{amst_approx_bound}_m${{MIN_CLUSTER_SIZE}}.txt" - hpc_clustering_command = (f"{clustering_exe} -i {amst_ds_path} -M " - f" -m ${{MIN_CLUSTER_SIZE}} " - f" -o {cluster_label_file} " - f" -c {cluster_tree_file} " - f" -P ") - add_cmd(hpc_clustering_command, job_script) - - # Run the evaluation step - if ground_truth_path: - echo_stage_name(job_script, "Evaluating Clustering Results") - add_clustering_evaluation( - job_script, cluster_label_file, amst_ds_path, - ground_truth_path, evaluator, - ygm_cluster_eval, num_tasks_per_node, verbose, - singleton_cluster_to_noise_points) - - echo_stage_name(job_script, "Assign clusters to noise points") - # Remove the .txt extension and add .noise_assigned.txt - cluster_label_file_no_noise = f"{cluster_label_file[:-4]}.noise_assigned.txt" - cluster_assign_command = (f"{noise_point_assigner_exe} -M " - f"-m {amst_ds_path} " - f"-c {cluster_label_file} " - f"-o {cluster_label_file_no_noise}") - add_cmd(cluster_assign_command, job_script) - - echo_stage_name(job_script, "Evaluate clustering results after assigning clusters to noise points") - add_clustering_evaluation( - job_script, cluster_label_file_no_noise, amst_ds_path, - ground_truth_path, evaluator, - ygm_cluster_eval, num_tasks_per_node, verbose, - singleton_cluster_to_noise_points) - - - job_script.write(f"echo \"\" \n") + run_clustering( + job_script, set_cmd, work_dir, amst_approx_bound, + amst_ds_path, clustering_exe, distributed_hdbscan, + evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, + ground_truth_path, singleton_cluster_to_noise_points, + noise_point_assigner_exe) try_no += 1 # If the file was not created, return an error From 2f02c26a2775c8bd3b207747141cfc2ecfc92704 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 4 Sep 2026 02:15:05 -0700 Subject: [PATCH 16/21] Minor bugfix in cluster_noise_points.cpp --- src/clustering/cluster_noise_points.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp index faa18db..1cf891d 100644 --- a/src/clustering/cluster_noise_points.cpp +++ b/src/clustering/cluster_noise_points.cpp @@ -1,3 +1,6 @@ +// Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS +// Project Developers. See the top-level COPYRIGHT file for details. + // Assign cluster IDs to noise points by traversing the MST edges. // Traverse the MST edges from each noise point in BFS manner until a point that // belongs to a cluster is found. @@ -77,7 +80,7 @@ int main(int argc, char* argv[]) { option opt; parse_option(argc, argv, opt); - map_t> mst; + map_t> mst_graph; if (opt.metall_mst) { spdlog::info("Attaching MST in Metall datastore"); metall::manager metall_manager(metall::open_read_only, opt.mst_edges_path); @@ -89,24 +92,24 @@ int main(int argc, char* argv[]) { opt.mst_edges_path.string()); std::abort(); } + spdlog::info("#of MST edges: {}", input_mst_edges.size()); spdlog::info("Copying MST edges from Metall datastore"); for (const auto& edge : *input_mst_edges) { - mst[edge.ids[0]].push_back(edge.ids[1]); - mst[edge.ids[1]].push_back(edge.ids[0]); + mst_graph[edge.ids[0]].push_back(edge.ids[1]); + mst_graph[edge.ids[1]].push_back(edge.ids[0]); } - spdlog::info("#of MST edges: {}", mst.size()); } else { spdlog::info("Reading MST edges"); weighted_edge_list_t input_mst_edges; read_edges(opt.mst_edges_path, input_mst_edges); spdlog::info("#of MST edges: {}", input_mst_edges.size()); for (const auto& edge : input_mst_edges) { - mst[edge.ids[0]].push_back(edge.ids[1]); - mst[edge.ids[1]].push_back(edge.ids[0]); + mst_graph[edge.ids[0]].push_back(edge.ids[1]); + mst_graph[edge.ids[1]].push_back(edge.ids[0]); } } - if (mst.empty()) { + if (mst_graph.empty()) { spdlog::warn("No MST edges found in the input file or directory: {}", opt.mst_edges_path.string()); return EXIT_SUCCESS; @@ -145,7 +148,7 @@ int main(int argc, char* argv[]) { const auto current_point_id = bfs_queue.front(); bfs_queue.pop_front(); - for (const auto neighbor_id : mst.at(current_point_id)) { + for (const auto neighbor_id : mst_graph.at(current_point_id)) { if (visited.find(neighbor_id) != visited.end()) { continue; // Already visited, e.g., the node we came from } From c030037bf1a537aeb19e347ea59296f2e8261337 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 4 Sep 2026 02:36:18 -0700 Subject: [PATCH 17/21] Minor bugfix in cluster_noise_points.cpp --- src/clustering/cluster_noise_points.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp index 1cf891d..8a9f11e 100644 --- a/src/clustering/cluster_noise_points.cpp +++ b/src/clustering/cluster_noise_points.cpp @@ -92,7 +92,7 @@ int main(int argc, char* argv[]) { opt.mst_edges_path.string()); std::abort(); } - spdlog::info("#of MST edges: {}", input_mst_edges.size()); + spdlog::info("#of MST edges: {}", input_mst_edges->size()); spdlog::info("Copying MST edges from Metall datastore"); for (const auto& edge : *input_mst_edges) { mst_graph[edge.ids[0]].push_back(edge.ids[1]); @@ -117,7 +117,7 @@ int main(int argc, char* argv[]) { map_t point_cluster_map; read_cluster_ids(opt.cluster_ids_input_path, point_cluster_map); - spdlog::info("Read {} point cluster IDs from {}", point_cluster_map.size(), + spdlog::info("Read {} points' cluster IDs from {}", point_cluster_map.size(), opt.cluster_ids_input_path.string()); std::vector point_ids; From 4dda3cae9405fd18ff44b6325db71b4e6575705d Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 4 Sep 2026 03:09:28 -0700 Subject: [PATCH 18/21] Brush up benchmark scripts --- script/benchmark/clustering_utilities.py | 29 ++++++++++++------------ src/clustering/cluster_noise_points.cpp | 4 ++++ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index 91e85f4..8ef7b79 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -16,23 +16,18 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points if np.any(true_labels < 0): print("Ground truth labels contain noise points") pct_clustered_gt = (np.sum(true_labels >= 0) / cluster_labels.shape[0]) - print(f"GT clustered Points: {pct_clustered_gt * 100:.2f}%") + print(f"Ground truth cluster coverage (%): {pct_clustered_gt * 100:.2f}%") if ignore_true_noise_points: - print("Remove true noise points from the evaluation") - print(f"Before filtering: {len(true_labels)} points") + print("Remove noise points in the ground truth from the evaluation") + print(f"Before filtering: {len(true_labels)} ground truth points") mask = true_labels >= 0 true_labels = true_labels[mask] cluster_labels = cluster_labels[mask] max_cluster_id = max(np.max(cluster_labels), np.max(true_labels)) - print(f"After filtering: {len(true_labels)} points") - + print(f"After filtering: {len(true_labels)} ground truth points") if np.any(cluster_labels < 0): # Has noise points - clustered_points = (cluster_labels >= 0) - pct_clustered = (np.sum(clustered_points) / cluster_labels.shape[0]) - print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") - if singleton_cluster_to_noise_points: print( "Assigning a singleton cluster to each noise point in the clustering result") @@ -40,11 +35,15 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points ari = adjusted_rand_score(true_labels, cluster_labels) ami = adjusted_mutual_info_score(true_labels, cluster_labels) else: - ari = adjusted_rand_score(true_labels[clustered_points], - cluster_labels[clustered_points]) - ami = adjusted_mutual_info_score(true_labels[clustered_points], - cluster_labels[clustered_points]) - # sil = silhouette_score(raw_data[clustered_points], cluster_labels[clustered_points]) + print("Noise points are ignored in the evaluation") + non_noise_points = (cluster_labels >= 0) + pct_clustered = (np.sum(non_noise_points) / cluster_labels.shape[0]) + print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") + ari = adjusted_rand_score(true_labels[non_noise_points], + cluster_labels[non_noise_points]) + ami = adjusted_mutual_info_score(true_labels[non_noise_points], + cluster_labels[non_noise_points]) + # sil = silhouette_score(raw_data[non_noise_points], cluster_labels[non_noise_points]) else: ari = adjusted_rand_score(true_labels, cluster_labels) ami = adjusted_mutual_info_score(true_labels, cluster_labels) @@ -66,6 +65,8 @@ def assign_singleton_cluster_to_noise_points(cluster_labels, noise_id_offset): new_labels[i] = cnt_noise + noise_id_offset new_labels[i] = cnt_noise + noise_id_offset + print(f"Assigned singleton clusters to {cnt_noise} noise points") + return new_labels diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp index 8a9f11e..bf7c0a8 100644 --- a/src/clustering/cluster_noise_points.cpp +++ b/src/clustering/cluster_noise_points.cpp @@ -130,6 +130,9 @@ int main(int argc, char* argv[]) { "Assigning cluster IDs to noise points by traversing the MST edges"); std::size_t n_noise_points = 0; std::size_t n_assigned_points = 0; + // NOTE: this algorithm is not determinstic because threads update the shared + // point_cluster_map concurrently. + // We employ this algorithm because it is simple and fast. OMP_DIRECTIVE(parallel for reduction(+ : n_noise_points, n_assigned_points)) for (size_t i = 0; i < point_ids.size(); ++i) { const auto point_id = point_ids.at(i); @@ -153,6 +156,7 @@ int main(int argc, char* argv[]) { continue; // Already visited, e.g., the node we came from } if (point_cluster_map.at(neighbor_id) != k_noise_cluster_id) { + // Found a neighbor that belongs to a cluster point_cluster_map[point_id] = point_cluster_map.at(neighbor_id); found_cluster = true; ++n_assigned_points; From 15acaa44984df3672f1e53ca0477f04c46ab5412 Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 4 Sep 2026 07:00:24 -0700 Subject: [PATCH 19/21] Brush up benchmark scripts --- script/benchmark/clustering_utilities.py | 23 +++--- .../extract_clams_clustering_qualities.sh | 49 ++++++++++++ .../extract_clams_clustering_time.sh | 79 +++++++++++++++++++ .../extract_hpc_clustering_bench_result.sh | 19 ----- script/benchmark/run_clams_bench.py | 34 ++++++-- src/clustering/cluster_noise_points.cpp | 8 +- 6 files changed, 175 insertions(+), 37 deletions(-) create mode 100755 script/benchmark/extract_clams_clustering_qualities.sh create mode 100755 script/benchmark/extract_clams_clustering_time.sh delete mode 100644 script/benchmark/extract_hpc_clustering_bench_result.sh diff --git a/script/benchmark/clustering_utilities.py b/script/benchmark/clustering_utilities.py index 8ef7b79..6cb80ae 100644 --- a/script/benchmark/clustering_utilities.py +++ b/script/benchmark/clustering_utilities.py @@ -11,6 +11,8 @@ # Modified from: # https://gist.github.com/lmcinnes/24ed5c22c80125be5133811d677eae7b def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points=False, ignore_true_noise_points=False): + print(f"Assigning singleton clusters to noise points: {singleton_cluster_to_noise_points}") + print(f"Ignore true noise points in the ground truth: {ignore_true_noise_points}") max_cluster_id = max(np.max(cluster_labels), np.max(true_labels)) if np.any(true_labels < 0): @@ -27,7 +29,11 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points max_cluster_id = max(np.max(cluster_labels), np.max(true_labels)) print(f"After filtering: {len(true_labels)} ground truth points") - if np.any(cluster_labels < 0): # Has noise 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}") + + if len(non_noise_points_mask) < 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") @@ -36,19 +42,16 @@ def eval_clusters(cluster_labels, true_labels, singleton_cluster_to_noise_points ami = adjusted_mutual_info_score(true_labels, cluster_labels) else: print("Noise points are ignored in the evaluation") - non_noise_points = (cluster_labels >= 0) - pct_clustered = (np.sum(non_noise_points) / cluster_labels.shape[0]) - print(f"Cluster coverage (%): {pct_clustered * 100:.2f}") - ari = adjusted_rand_score(true_labels[non_noise_points], - cluster_labels[non_noise_points]) - ami = adjusted_mutual_info_score(true_labels[non_noise_points], - cluster_labels[non_noise_points]) - # sil = silhouette_score(raw_data[non_noise_points], cluster_labels[non_noise_points]) + ari = adjusted_rand_score(true_labels[non_noise_points_mask], + cluster_labels[non_noise_points_mask]) + ami = adjusted_mutual_info_score(true_labels[non_noise_points_mask], + cluster_labels[non_noise_points_mask]) + # sil = silhouette_score(raw_data[non_noise_points_mask], cluster_labels[non_noise_points_mask]) else: + print(f"No noise points in the clustering result") ari = adjusted_rand_score(true_labels, cluster_labels) ami = adjusted_mutual_info_score(true_labels, cluster_labels) # sil = silhouette_score(raw_data, cluster_labels) - print(f"No noise points in the clustering result") print(f"ARI: {ari:.4f}") print(f"AMI: {ami:.4f}") diff --git a/script/benchmark/extract_clams_clustering_qualities.sh b/script/benchmark/extract_clams_clustering_qualities.sh new file mode 100755 index 0000000..e044d42 --- /dev/null +++ b/script/benchmark/extract_clams_clustering_qualities.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS +# Project Developers. See the top-level COPYRIGHT file for details. +# +# Extracts clustering parameters and results (ARI/AMI) from ClaMS benchmark +# log files (e.g., out.log). Values are tracked as a running state while +# scanning the log top to bottom, so parameter lines that appear less often +# than ARI/AMI lines are repeated/shared across all following ARI/AMI rows +# until the next occurrence of that parameter updates the state. +# +# Usage: extract_clustering_results.sh [ ...] + +set -euo pipefail + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [ ...]" >&2 + exit 1 +fi + +printf "kNNG_k,min_cluster_size,final_clusters,singleton_to_noise,cluster_coverage,ARI,AMI,file\n" + +for file in "$@"; do + awk -v fname="$file" ' + # Return the last whitespace/colon separated token on the line, i.e. its value + function lastfield(s, n, arr) { + n = split(s, arr, /[[:space:]:]+/) + return arr[n] + } + + BEGIN { + k = "NA"; mcs = "NA"; fc = "NA"; singleton = "NA"; coverage = "NA"; have_ari = 0; ari = "NA" + } + + /kNNG k:/ || /^[[:space:]]*k:[[:space:]]*[0-9]+[[:space:]]*$/ { k = lastfield($0); next } + /Min cluster size/ { mcs = lastfield($0); next } + /Evaluating Clustering Results, no noise points =/ { singleton = lastfield($0); next } + /#of final clusters/ { fc = lastfield($0); next } + /Cluster coverage \(%\)/ { coverage = lastfield($0); next } + /ARI/ { ari = lastfield($0); have_ari = 1; next } + /AMI/ { + if (have_ari) { + ami = lastfield($0) + printf "%s,%s,%s,%s,%s,%s,%s,%s\n", k, mcs, fc, singleton, coverage, ari, ami, fname + have_ari = 0 + } + next + } + ' "$file" +done diff --git a/script/benchmark/extract_clams_clustering_time.sh b/script/benchmark/extract_clams_clustering_time.sh new file mode 100755 index 0000000..6cc017f --- /dev/null +++ b/script/benchmark/extract_clams_clustering_time.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Copyright 2023-2026 Lawrence Livermore National Security, LLC and other ClaMS +# Project Developers. See the top-level COPYRIGHT file for details. +# +# Extracts clustering execution times from ClaMS benchmark log files +# (e.g., out.log). Each stage is delimited by a ": " line +# and a matching "Finished : " line; the elapsed time +# between the two is reported in seconds. Only the first occurrence of each +# stage pair is used (e.g., "Running CLAMS-HDBSCAN" and +# "Assign clusters to noise points" can repeat per min-cluster-size value). +# +# Usage: extract_clams_clustering_time.sh [ ...] + +set -euo pipefail + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [ ...]" >&2 + exit 1 +fi + +printf "kNNG k,nodes,tasks/node,kNNG (s),MFC (s),AMST (s),CLAMS-HDBSCAN (s),Assigning noise points (s),file\n" + +for file in "$@"; do + awk -v fname="$file" ' + # Convert a "YYYY/MM/DD HH:MM:SS" timestamp found at the end of the line to epoch seconds + function to_epoch(line, ts, n, a) { + if (!match(line, /[0-9]{4}\/[0-9]{2}\/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/)) return -1 + ts = substr(line, RSTART, RLENGTH) + n = split(ts, a, /[\/ :]+/) + return mktime(a[1] " " a[2] " " a[3] " " a[4] " " a[5] " " a[6]) + } + + # Return the last whitespace/colon separated token on the line, i.e. its value + function lastfield(s, n, arr) { + n = split(s, arr, /[[:space:]:]+/) + return arr[n] + } + + function elapsed(start, finish) { + if (start == -1 || finish == -1) return "NA" + return finish - start + } + + BEGIN { + k = "NA"; nodes = "NA"; tasks = "NA" + knng_start = -1; knng_end = -1 + mfc_start = -1; mfc_end = -1 + amst_start = -1; amst_end = -1 + hdbscan_start = -1; hdbscan_end = -1 + noise_start = -1; noise_end = -1 + } + + /^kNNG k:/ && k == "NA" { k = lastfield($0) } + /^Compute nodes:/ && nodes == "NA" { nodes = lastfield($0) } + /^Tasks per node:/ && tasks == "NA" { tasks = lastfield($0) } + + /^Building KNNG:/ && knng_start == -1 { knng_start = to_epoch($0) } + /^Finished Building KNNG:/ && knng_start != -1 && knng_end == -1 { knng_end = to_epoch($0) } + + /^Connecting the CCs using MFC:/ && mfc_start == -1 { mfc_start = to_epoch($0) } + /^Finished Connecting the CCs using MFC:/ && mfc_start != -1 && mfc_end == -1 { mfc_end = to_epoch($0) } + + /^Running AMST, approx bound/ && amst_start == -1 { amst_start = to_epoch($0) } + /^Finished Running AMST, approx bound/ && amst_start != -1 && amst_end == -1 { amst_end = to_epoch($0) } + + /^Running CLAMS-HDBSCAN:/ && hdbscan_start == -1 { hdbscan_start = to_epoch($0) } + /^Finished Running CLAMS-HDBSCAN:/ && hdbscan_start != -1 && hdbscan_end == -1 { hdbscan_end = to_epoch($0) } + + /^Assign clusters to noise points:/ && noise_start == -1 { noise_start = to_epoch($0) } + /^Finished Assign clusters to noise points:/ && noise_start != -1 && noise_end == -1 { noise_end = to_epoch($0) } + + END { + printf "%s,%s,%s,%s,%s,%s,%s,%s,%s\n", k, nodes, tasks, \ + elapsed(knng_start, knng_end), elapsed(mfc_start, mfc_end), \ + elapsed(amst_start, amst_end), elapsed(hdbscan_start, hdbscan_end), \ + elapsed(noise_start, noise_end), fname + } + ' "$file" +done diff --git a/script/benchmark/extract_hpc_clustering_bench_result.sh b/script/benchmark/extract_hpc_clustering_bench_result.sh deleted file mode 100644 index b9a0f1c..0000000 --- a/script/benchmark/extract_hpc_clustering_bench_result.sh +++ /dev/null @@ -1,19 +0,0 @@ -JOBS=(job_20240925_085603 job_20240925_085709) - -# Execute a command and print the execution result without the newline -function exe() { - ret=$("$@") - echo -n $ret -} - -printf "Min Cluster Size, Clustered %%, ARI, AMI\n" -for job in "${JOBS[@]}"; do - echo "$job" - for i in {0..4}; do - echo -n $(cat bench_outputs/${job}/job_${i}/out.log | grep "Minimum cluster size: " | awk '{print $7}'); printf ",\t" - echo -n $(cat bench_outputs/${job}/job_${i}/out.log | grep % | awk '{print $4}'); printf ",\t" - echo -n $(cat bench_outputs/${job}/job_${i}/out.log | grep ARI | awk '{print $2}'); printf ",\t" - echo -n $(cat bench_outputs/${job}/job_${i}/out.log | grep AMI | awk '{print $2}') - echo "" - done -done \ No newline at end of file diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index fd9c0bd..dd0b964 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -56,6 +56,8 @@ def parse_options(): help='Use NEO-DNND (build_knng_neo) instead of build_knng.') parser.add_argument('--neodnnd_threads', type=int, default=2, help='Number of threads to use for NEO-DNND. Ignored if --neodnnd is not specified.') + parser.add_argument('--neodnnd_replicate_rate', type=float, default=0.0, + help='The replicate rate for NEO-DNND. Ignored if --neodnnd is not specified. See NEO-DNND\'s documentation for details.') parser.add_argument('--nng_r', type=float, default=0.5, help='The r (sampling) parameter for KNNG construction.') parser.add_argument('--nng_delta', type=float, default=0.0001, @@ -159,12 +161,19 @@ def generate_job_name(): time.sleep(2) return f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}" +stage_name_stack = [] # Stack to store stage names for logging def echo_stage_name(job_script, stage_name): job_script.write("echo\n") - job_script.write("date\n") job_script.write(f"echo ================================\n") - job_script.write(f"echo \"{stage_name}\"\n") + job_script.write(f"echo \"{stage_name}\": $(date \"+%Y/%m/%d %H:%M:%S\")\n") job_script.write(f"echo ================================\n") + stage_name_stack.append(stage_name) # Add the stage name to the queue + +def finish_stage(job_script): + if stage_name_stack: + stage_name = stage_name_stack.pop() # Get the last stage name from the stack + job_script.write(f"echo \"Finished {stage_name}: $(date \"+%Y/%m/%d %H:%M:%S\")\"\n") + job_script.write("echo\n") def add_clustering_evaluation(job_script, cluster_label_file, amst_ds_path, ground_truth_path, evaluator, @@ -218,13 +227,15 @@ def run_clustering(job_script, set_cmd, work_dir, amst_approx_bound, f" -c {cluster_tree_file} " f" -P ") add_cmd(hpc_clustering_command, job_script) + finish_stage(job_script) if ground_truth_path: - echo_stage_name(job_script, "Evaluating Clustering Results") + echo_stage_name(job_script, "Evaluating Clustering Results, no noise points = False") add_clustering_evaluation( job_script, cluster_label_file, amst_ds_path, ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points) + finish_stage(job_script) echo_stage_name(job_script, "Assign clusters to noise points") cluster_label_file_no_noise = f"{cluster_label_file[:-4]}.noise_assigned.txt" @@ -233,14 +244,16 @@ def run_clustering(job_script, set_cmd, work_dir, amst_approx_bound, f"-c {cluster_label_file} " f"-o {cluster_label_file_no_noise}") add_cmd(cluster_assign_command, job_script) + finish_stage(job_script) echo_stage_name( job_script, - "Evaluate clustering results after assigning clusters to noise points") + "Evaluate clustering results, no noise points = True") add_clustering_evaluation( job_script, cluster_label_file_no_noise, amst_ds_path, ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, singleton_cluster_to_noise_points) + finish_stage(job_script) job_script.write("echo \"\" \n") @@ -276,12 +289,13 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, # Set up the batch script header set_up_batch_header(job_script, job_name, job_dir, num_nodes) + job_script.write(f"echo \"Compute nodes: {num_nodes}\"\n") + job_script.write(f"echo \"Tasks per node: {num_tasks_per_node}\"\n") add_cmd(f'mkdir -p {work_dir}', job_script, True, False) # Run the DNND step - job_script.write("echo\n") - job_script.write("date\n") + job_script.write(f"echo \"kNNG k: {nng_k}\"\n") if len(input_dnnd_ds_path) == 0: echo_stage_name(job_script, "Building KNNG") dnnd_ds_path = f"{work_dir}/dnnd_pm_datastore" @@ -289,6 +303,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, verbose_flag = '-v' if verbose else '' dnnd_command = f"{dnnd_exe} {verbose_flag} -k {nng_k} -r {nng_r} -d {nng_delta} -f {distance_func} -o {dnnd_ds_path} -b {dnnd_batch_size} -p {points_file_format} {point_path}" add_srun_cmd(num_tasks_per_node, dnnd_command, job_script) + finish_stage(job_script) if backup_knng: dnnd_ds_path_backup = f"{dnnd_ds_path}_backup" job_script.write(f"echo \"Backing up KNNG datastore\"\n") @@ -303,6 +318,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, echo_stage_name(job_script, "Connecting the CCs using MFC") mfc_command = f"{mfc_exe} -d {dnnd_ds_path} -f {distance_func}" add_srun_cmd(num_tasks_per_node, mfc_command, job_script) + finish_stage(job_script) # Convert to core distance # TODO: Implement @@ -312,6 +328,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, add_cmd(f'mkdir -p {knng_coredist_dir}', job_script) conv2coredist_cmd = f"./src/conv_knng_to_core_dist -i {dnnd_ds_path} -o {knng_coredist_dir}/knng.txt -m {min_samples}" add_cmd(conv2coredist_cmd, job_script) + finish_stage(job_script) try_no = 0 for amst_approx_bound in amst_approx_bound_list: @@ -320,6 +337,7 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, amst_ds_path = f"{work_dir}/amst_pm_datastore_a{amst_approx_bound}" amst_command = f"{amst_exe} -d {dnnd_ds_path} -p {amst_ds_path} -e {amst_approx_bound}" add_srun_cmd(num_tasks_per_node, amst_command, job_script) + finish_stage(job_script) # Run the HPC Clustering step for set_cmd in min_cluster_size_set_cmnds: @@ -331,6 +349,8 @@ def gen_clams_bench_script(job_name, job_dir, work_dir, noise_point_assigner_exe) try_no += 1 + finish_stage(job_script) + # If the file was not created, return an error if not os.path.exists(job_script_path): print(f"Error: Could not create the shell script {job_script_path}") @@ -369,7 +389,7 @@ def main(): if dnnd_exe == default_dnnd_exe: dnnd_exe = f'{os.getcwd()}/src/knng/build_knng_neo' # This is not the best way to set the number of threads for NEO-DNND, but it is a simple way to do it for now. - dnnd_exe = f'{dnnd_exe} -T {opts.neodnnd_threads}' + dnnd_exe = f'{dnnd_exe} -T {opts.neodnnd_threads} -R {opts.neodnnd_replicate_rate}' # Select HDBSCAN executable # If --distributed_hdbscan is sepecified and the user did not override --clustering_exe, diff --git a/src/clustering/cluster_noise_points.cpp b/src/clustering/cluster_noise_points.cpp index bf7c0a8..316268a 100644 --- a/src/clustering/cluster_noise_points.cpp +++ b/src/clustering/cluster_noise_points.cpp @@ -128,6 +128,10 @@ int main(int argc, char* argv[]) { spdlog::info( "Assigning cluster IDs to noise points by traversing the MST edges"); + // Start a timer to measure the time taken for assigning cluster IDs to noise + // points + auto kernel_timer = spdlog::stopwatch(); + std::size_t n_noise_points = 0; std::size_t n_assigned_points = 0; // NOTE: this algorithm is not determinstic because threads update the shared @@ -172,7 +176,9 @@ int main(int argc, char* argv[]) { spdlog::warn("Point {} could not be assigned to any cluster.", point_id); } } - spdlog::info("Finished assigning cluster IDs to noise points"); + const auto kernel_elapsed_time = kernel_timer.elapsed(); + spdlog::info("Finished assigning cluster IDs to noise points {}s", + kernel_elapsed_time.count()); spdlog::info("Number of noise points in the original data: {}", n_noise_points); spdlog::info("Number of remaining noise points: {}", From b0b78d9f6251c3d5c4d865e4269d6201cda5a21b Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Fri, 4 Sep 2026 08:07:12 -0700 Subject: [PATCH 20/21] Brush up benchmark scripts --- script/benchmark/extract_clams_clustering_qualities.sh | 8 ++++---- script/benchmark/run_clams_bench.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/script/benchmark/extract_clams_clustering_qualities.sh b/script/benchmark/extract_clams_clustering_qualities.sh index e044d42..74ff9d9 100755 --- a/script/benchmark/extract_clams_clustering_qualities.sh +++ b/script/benchmark/extract_clams_clustering_qualities.sh @@ -17,7 +17,7 @@ if [[ $# -eq 0 ]]; then exit 1 fi -printf "kNNG_k,min_cluster_size,final_clusters,singleton_to_noise,cluster_coverage,ARI,AMI,file\n" +printf "kNNG k,Min Cluster Size,Found Clusters,MST clustering,Cluster coverage,ARI,AMI,file\n" for file in "$@"; do awk -v fname="$file" ' @@ -28,19 +28,19 @@ for file in "$@"; do } BEGIN { - k = "NA"; mcs = "NA"; fc = "NA"; singleton = "NA"; coverage = "NA"; have_ari = 0; ari = "NA" + k = "NA"; mcs = "NA"; fc = "NA"; mst_clustering = "NA"; coverage = "NA"; have_ari = 0; ari = "NA" } /kNNG k:/ || /^[[:space:]]*k:[[:space:]]*[0-9]+[[:space:]]*$/ { k = lastfield($0); next } /Min cluster size/ { mcs = lastfield($0); next } - /Evaluating Clustering Results, no noise points =/ { singleton = lastfield($0); next } + /MST-based cluster guess/ { mst_clustering = lastfield($0); next } /#of final clusters/ { fc = lastfield($0); next } /Cluster coverage \(%\)/ { coverage = lastfield($0); next } /ARI/ { ari = lastfield($0); have_ari = 1; next } /AMI/ { if (have_ari) { ami = lastfield($0) - printf "%s,%s,%s,%s,%s,%s,%s,%s\n", k, mcs, fc, singleton, coverage, ari, ami, fname + printf "%s,%s,%s,%s,%s,%s,%s,%s\n", k, mcs, fc, mst_clustering, coverage, ari, ami, fname have_ari = 0 } next diff --git a/script/benchmark/run_clams_bench.py b/script/benchmark/run_clams_bench.py index dd0b964..66696fd 100644 --- a/script/benchmark/run_clams_bench.py +++ b/script/benchmark/run_clams_bench.py @@ -230,7 +230,8 @@ def run_clustering(job_script, set_cmd, work_dir, amst_approx_bound, finish_stage(job_script) if ground_truth_path: - echo_stage_name(job_script, "Evaluating Clustering Results, no noise points = False") + echo_stage_name(job_script, "Evaluating Clustering Results") + job_script.write(f"echo \"MST-based cluster guess: False\"\n") add_clustering_evaluation( job_script, cluster_label_file, amst_ds_path, ground_truth_path, evaluator, ygm_cluster_eval, num_tasks_per_node, verbose, @@ -246,9 +247,8 @@ def run_clustering(job_script, set_cmd, work_dir, amst_approx_bound, add_cmd(cluster_assign_command, job_script) finish_stage(job_script) - echo_stage_name( - job_script, - "Evaluate clustering results, no noise points = True") + echo_stage_name(job_script, "Evaluate clustering results") + job_script.write(f"echo \"MST-based cluster guess: True\"\n") add_clustering_evaluation( job_script, cluster_label_file_no_noise, amst_ds_path, ground_truth_path, evaluator, ygm_cluster_eval, From 17ec57456960b6bb76763c91f68b070824e25c1e Mon Sep 17 00:00:00 2001 From: Keita Iwabuchi Date: Sat, 5 Sep 2026 04:52:56 -0700 Subject: [PATCH 21/21] Update output header in extract_clams_clustering_time.sh --- script/benchmark/extract_clams_clustering_time.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" '