From a33d47e3df5116b0460a3e130df5806b7ba0d3ef Mon Sep 17 00:00:00 2001 From: wuyazuholo Date: Sun, 16 Nov 2025 05:20:23 -0700 Subject: [PATCH 01/33] degree and betweenness --- CMakeLists.txt | 15 +- cpp_easygraph/CMakeLists.txt | 15 +- cpp_easygraph/cpp_easygraph.cpp | 3 + .../functions/centrality/betweenness.cpp | 291 ++++++++++++++---- .../functions/centrality/centrality.h | 6 +- cpp_easygraph/functions/centrality/degree.cpp | 96 ++++++ easygraph/functions/centrality/degree.py | 3 + 7 files changed, 358 insertions(+), 71 deletions(-) create mode 100644 cpp_easygraph/functions/centrality/degree.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 20a3809b..bb35232e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,22 +2,25 @@ cmake_minimum_required(VERSION 3.23) project(easygraph) +find_package(OpenMP QUIET) + +if (OpenMP_FOUND) + message(STATUS "OpenMP found, enabling parallel acceleration.") + add_compile_options(${OpenMP_CXX_FLAGS}) +else() + message(STATUS "OpenMP not found, building in single-thread mode.") +endif() + option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) add_subdirectory(cpp_easygraph) if (EASYGRAPH_ENABLE_GPU) - message("easygraph gpu module is enabled") - add_subdirectory(gpu_easygraph) - target_include_directories(cpp_easygraph PRIVATE gpu_easygraph ) - else() - message("easygraph gpu module is disabled") - endif() \ No newline at end of file diff --git a/cpp_easygraph/CMakeLists.txt b/cpp_easygraph/CMakeLists.txt index 2a43c776..5352190f 100644 --- a/cpp_easygraph/CMakeLists.txt +++ b/cpp_easygraph/CMakeLists.txt @@ -2,6 +2,9 @@ cmake_minimum_required(VERSION 3.23) project(cpp_easygraph) set(CMAKE_CXX_STANDARD 11) + +find_package(OpenMP REQUIRED) + file(GLOB SOURCES classes/*.cpp common/*.cpp @@ -38,7 +41,11 @@ else() endif() -set_target_properties(cpp_easygraph PROPERTIES - LINK_SEARCH_START_STATIC ON - LINK_SEARCH_END_STATIC ON -) \ No newline at end of file + +target_link_libraries(cpp_easygraph PRIVATE OpenMP::OpenMP_CXX) + + +#set_target_properties(cpp_easygraph PROPERTIES +# LINK_SEARCH_START_STATIC ON +# LINK_SEARCH_END_STATIC ON +#) \ No newline at end of file diff --git a/cpp_easygraph/cpp_easygraph.cpp b/cpp_easygraph/cpp_easygraph.cpp index f5570d6e..fc945577 100644 --- a/cpp_easygraph/cpp_easygraph.cpp +++ b/cpp_easygraph/cpp_easygraph.cpp @@ -75,6 +75,9 @@ PYBIND11_MODULE(cpp_easygraph, m) { .def_property("pred", &DiGraph::get_pred,nullptr) .def("generate_linkgraph", &DiGraph_generate_linkgraph,py::arg("weight") = "weight"); + m.def("cpp_degree_centrality", °ree_centrality, py::arg("G")); + m.def("cpp_in_degree_centrality", &in_degree_centrality, py::arg("G")); + m.def("cpp_out_degree_centrality", &out_degree_centrality, py::arg("G")); m.def("cpp_closeness_centrality", &closeness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(), py::arg("sources") = py::none()); m.def("cpp_betweenness_centrality", &betweenness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(),py::arg("sources") = py::none(), py::arg("normalized") = py::bool_(true), py::arg("endpoints") = py::bool_(false)); m.def("cpp_katz_centrality", &cpp_katz_centrality, py::arg("G"), py::arg("alpha") = 0.1, py::arg("beta") = 1.0, py::arg("max_iter") = 1000, py::arg("tol") = 1e-6, py::arg("normalized") = true); diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index 5eafc3d7..1330869e 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -1,3 +1,6 @@ +#include +#include +#include #include "centrality.h" #ifdef EASYGRAPH_ENABLE_GPU @@ -9,43 +12,134 @@ #include "../../classes/linkgraph.h" #include "../../classes/segment_tree.cpp" +void betweenness_bfs_worker( + const Graph_L& G_l, const int &S, std::vector& bc, double cutoff, int endpoints_, + std::queue& queue, + std::vector& dis, + std::vector& head_path, + std::vector& St, + std::vector& count_path, + std::vector& delta, + std::vector& E_path +) { + const int dis_inf = 0x3f3f3f3f; + int N = G_l.n; + int edge_number_path = 0; + int cnt_St = 0; + + queue = {}; + std::fill(dis.begin(), dis.end(), INT_MAX); + std::fill(head_path.begin(), head_path.end(), 0); + std::fill(count_path.begin(), count_path.end(), 0); + std::fill(delta.begin(), delta.end(), 0.0); + + head_path[S] = 0; + dis[S] = 0; + count_path[S] = 1; + queue.push(S); + + int cutoff_int = (cutoff < 0) ? -1 : static_cast(cutoff); + + while(!queue.empty()) { + int u = queue.front(); + queue.pop(); + + if (cutoff_int >= 0 && dis[u] > cutoff_int){ + break; + } + St[cnt_St++] = u; -void betweenness_dijkstra(const Graph_L& G_l, const int &S, std::vector& bc, double cutoff, Segment_tree_zkw& segment_tree_zkw, int endpoints_) { + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + for(int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + int new_dis = dis[u] + 1; + + if (dis[v] > new_dis) { + if (cutoff_int >= 0 && new_dis > cutoff_int) { + continue; + } + dis[v] = new_dis; + queue.push(v); + count_path[v] = count_path[u]; + head_path[v] = 0; + E_path[++edge_number_path].next = head_path[v]; + E_path[edge_number_path].to = u; + head_path[v] = edge_number_path; + } + else if (dis[v] == new_dis) { + count_path[v] += count_path[u]; + E_path[++edge_number_path].next = head_path[v]; + E_path[edge_number_path].to = u; + head_path[v] = edge_number_path; + } + } + } + + if (endpoints_) { + bc[S] += cnt_St - 1; + } + while (cnt_St > 0) { + int u = St[--cnt_St]; + double coeff = (1.0 + delta[u]) / count_path[u]; + for(int p = head_path[u]; p; p = E_path[p].next){ + delta[E_path[p].to] += count_path[E_path[p].to] * coeff; + } + + if (u != S) + bc[u] += delta[u] + endpoints_; + } +} + +void betweenness_dijkstra_worker( + const Graph_L& G_l, const int &S, std::vector& bc, double cutoff, + Segment_tree_zkw& segment_tree_zkw, + std::vector& dis, + std::vector& head_path, + std::vector& St, + std::vector& count_path, + std::vector& delta, + std::vector& E_path, + int endpoints_ +) { const int dis_inf = 0x3f3f3f3f; int N = G_l.n; int edge_number_path = 0; + int cnt_St = 0; + segment_tree_zkw.init(N); - std::vector dis(N+1, INT_MAX); - std::vector head_path(N+1, 0); - const std::vector& head = G_l.head; - const std::vector& E = G_l.edges; - int edges_num = E.size(); - std::vector St(N+1, 0); - std::vector count_path(N+1, 0); - std::vector delta(N+1, 0); - std::vector E_path(edges_num+1); + std::fill(dis.begin(), dis.end(), INT_MAX); + std::fill(head_path.begin(), head_path.end(), 0); + std::fill(count_path.begin(), count_path.end(), 0); + std::fill(delta.begin(), delta.end(), 0.0); + head_path[S] = 0; - dis[S] = 0; - count_path[S] = 1; + dis[S] = 0; + count_path[S] = 1; segment_tree_zkw.change(S, 0); - int cnt_St = 0; while(segment_tree_zkw.t[1] != dis_inf) { int u = segment_tree_zkw.num[1]; if(u==0) break; segment_tree_zkw.change(u, dis_inf); + if (cutoff >= 0 && dis[u] > cutoff){ - continue; + continue; } St[cnt_St++] = u; + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + for(int p = head[u]; p != -1; p = E[p].next) { int v = E[p].to; if(cutoff >= 0 && (dis[u] + E[p].w) > cutoff){ continue; } if (dis[v] > dis[u] + E[p].w) { - dis[v] = dis[u] + E[p].w; - segment_tree_zkw.change(v, dis[v]); + dis[v] = dis[u] + E[p].w; + segment_tree_zkw.change(v, dis[v]); count_path[v] = count_path[u]; head_path[v] = 0; E_path[++edge_number_path].next = head_path[v]; @@ -57,16 +151,16 @@ void betweenness_dijkstra(const Graph_L& G_l, const int &S, std::vector& E_path[++edge_number_path].next = head_path[v]; E_path[edge_number_path].to = u; head_path[v] = edge_number_path; - } } } + if (endpoints_) { bc[S] += cnt_St - 1; } while (cnt_St > 0) { int u = St[--cnt_St]; - float coeff = (1.0 + delta[u]) / count_path[u]; + double coeff = (1.0 + delta[u]) / count_path[u]; for(int p = head_path[u]; p; p = E_path[p].next){ delta[E_path[p].to] += count_path[E_path[p].to] * coeff; } @@ -74,13 +168,11 @@ void betweenness_dijkstra(const Graph_L& G_l, const int &S, std::vector& if (u != S) bc[u] += delta[u] + endpoints_; } - } - - static double calc_scale(int len_V, int is_directed, int normalized, int endpoints) { double scale = 1.0; + if (normalized) { if (endpoints) { if (len_V < 2) { @@ -88,10 +180,12 @@ static double calc_scale(int len_V, int is_directed, int normalized, int endpoin } else { scale = 1.0 / (double(len_V) * (len_V - 1)); } - } else if (len_V <= 2) { - scale = 1.0; } else { - scale = 1.0 / ((double(len_V) - 1) * (len_V - 2)); + if (len_V <= 2) { + scale = 1.0; + } else { + scale = 1.0 / ((double(len_V) - 1) * (len_V - 2)); + } } } else { if (!is_directed) { @@ -100,13 +194,12 @@ static double calc_scale(int len_V, int is_directed, int normalized, int endpoin scale = 1.0; } } + return scale; } - - -static py::object invoke_cpp_betweenness_centrality(py::object G, py::object weight, - py::object cutoff, py::object sources, +static py::object invoke_cpp_betweenness_centrality(py::object G, py::object weight, + py::object cutoff, py::object sources, py::object normalized, py::object endpoints){ Graph& G_ = G.cast(); int cutoff_ = -1; @@ -118,7 +211,13 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei int normalized_ = normalized.cast(); int endpoints_ = endpoints.cast(); double scale = calc_scale(N, is_directed, normalized_, endpoints_); - std::string weight_key = weight_to_string(weight); + + bool use_weights = !weight.is_none(); + std::string weight_key = ""; + if (use_weights) { + weight_key = weight_to_string(weight); + } + Graph_L G_l; if(G_.linkgraph_dirty){ G_l = graph_to_linkgraph(G_, is_directed, weight_key, false, false); @@ -128,32 +227,113 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei else{ G_l = G_.linkgraph_structure; } - Segment_tree_zkw segment_tree_zkw(N); - std::vector bc(N+1, 0); + + int edges_num = G_l.edges.size(); + + std::vector bc(N + 1, 0.0); + double* bc_ptr = bc.data(); + std::vector BC; + + int num_threads = 1; + #ifdef _OPENMP + num_threads = omp_get_max_threads(); + #endif + + std::vector> dis_all(num_threads, std::vector(N + 1)); + std::vector> head_path_all(num_threads, std::vector(N + 1)); + std::vector> St_all(num_threads, std::vector(N + 1)); + std::vector> count_path_all(num_threads, std::vector(N + 1)); + std::vector> delta_all(num_threads, std::vector(N + 1)); + + std::vector> E_path_all(num_threads, std::vector(edges_num + 1)); + + std::vector segment_tree_all; + for (int i = 0; i < num_threads; ++i) { + segment_tree_all.emplace_back(N); + } + std::vector> queue_all(num_threads); + if(!sources.is_none()){ py::list sources_list = py::list(sources); int sources_list_len = py::len(sources_list); - for(register int i = 0; i < sources_list_len; i++){ + + std::vector sources_vec; + sources_vec.reserve(sources_list_len); + for(int i = 0; i < sources_list_len; i++){ if(G_.node_to_id.attr("get")(sources_list[i],py::none()).is_none()){ printf("The node should exist in the graph!"); return py::none(); } - node_t source_id = G_.node_to_id.attr("get")(sources_list[i]).cast(); - betweenness_dijkstra(G_l, source_id, bc, cutoff_, segment_tree_zkw, endpoints_); + sources_vec.push_back(G_.node_to_id.attr("get")(sources_list[i]).cast()); } - for(int i = 1; i <= N; i++){ - BC.push_back(scale * bc[i]); + + #ifdef _OPENMP + #pragma omp parallel for reduction(+:bc_ptr[0:N+1]) schedule(dynamic) + #endif + for(int i = 0; i < sources_list_len; i++){ + node_t source_id = sources_vec[i]; + + #ifdef _OPENMP + int tid = omp_get_thread_num(); + #else + int tid = 0; + #endif + + std::vector& dis = dis_all[tid]; + std::vector& head_path = head_path_all[tid]; + std::vector& St = St_all[tid]; + std::vector& count_path = count_path_all[tid]; + std::vector& delta = delta_all[tid]; + std::vector& E_path = E_path_all[tid]; + Segment_tree_zkw& segment_tree_zkw = segment_tree_all[tid]; + std::queue& queue = queue_all[tid]; + + if (use_weights) { + betweenness_dijkstra_worker(G_l, source_id, bc, cutoff_, + segment_tree_zkw, dis, head_path, St, count_path, delta, E_path, + endpoints_); + } else { + betweenness_bfs_worker(G_l, source_id, bc, cutoff_, endpoints_, + queue, dis, head_path, St, count_path, delta, E_path); + } } } else{ + #ifdef _OPENMP + #pragma omp parallel for reduction(+:bc_ptr[0:N+1]) schedule(dynamic) + #endif for (int i = 1; i <= N; ++i){ - betweenness_dijkstra(G_l, i, bc, cutoff_,segment_tree_zkw, endpoints_); - } - for(int i = 1; i <= N; i++){ - BC.push_back(scale * bc[i]); + + #ifdef _OPENMP + int tid = omp_get_thread_num(); + #else + int tid = 0; + #endif + + std::vector& dis = dis_all[tid]; + std::vector& head_path = head_path_all[tid]; + std::vector& St = St_all[tid]; + std::vector& count_path = count_path_all[tid]; + std::vector& delta = delta_all[tid]; + std::vector& E_path = E_path_all[tid]; + Segment_tree_zkw& segment_tree_zkw = segment_tree_all[tid]; + std::queue& queue = queue_all[tid]; + + if (use_weights) { + betweenness_dijkstra_worker(G_l, i, bc, cutoff_, + segment_tree_zkw, dis, head_path, St, count_path, delta, E_path, + endpoints_); + } else { + betweenness_bfs_worker(G_l, i, bc, cutoff_, endpoints_, + queue, dis, head_path, St, count_path, delta, E_path); + } } } + + for(int i = 1; i <= N; i++){ + BC.push_back(scale * bc[i]); + } py::array::ShapeContainer ret_shape{(int)BC.size()}; py::array_t ret(ret_shape, BC.data()); @@ -163,8 +343,8 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei #ifdef EASYGRAPH_ENABLE_GPU -static py::object invoke_gpu_betweenness_centrality(py::object G, py::object weight, - py::object py_sources, py::object normalized, py::object endpoints) { +static py::object invoke_gpu_betweenness_centrality(py::object G, py::object weight, +py::object py_sources, py::object normalized, py::object endpoints) { Graph& G_ = G.cast(); if (weight.is_none()) { G_.gen_CSR(); @@ -174,30 +354,27 @@ static py::object invoke_gpu_betweenness_centrality(py::object G, py::object wei auto csr_graph = G_.csr_graph; std::vector& E = csr_graph->E; std::vector& V = csr_graph->V; - std::vector *W_p = weight.is_none() ? &(csr_graph->unweighted_W) - : csr_graph->W_map.find(weight_to_string(weight))->second.get(); + std::vector *W_p = weight.is_none() ? &(csr_graph->unweighted_W) + : csr_graph->W_map.find(weight_to_string(weight))->second.get(); auto sources = G_.gen_CSR_sources(py_sources); std::vector BC; bool is_directed = G.attr("is_directed")().cast(); - int gpu_r = gpu_easygraph::betweenness_centrality(V, E, *W_p, *sources, - is_directed, normalized.cast(), - endpoints.cast(), BC); - + int gpu_r = gpu_easygraph::betweenness_centrality(V, E, *W_p, *sources, + is_directed, normalized.cast(), + endpoints.cast(), BC); if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { // the code below will throw an exception py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); } - py::array::ShapeContainer ret_shape{(int)BC.size()}; py::array_t ret(ret_shape, BC.data()); - return ret; } #endif -py::object betweenness_centrality(py::object G, py::object weight, py::object cutoff, py::object sources, - py::object normalized, py::object endpoints) { +py::object betweenness_centrality(py::object G, py::object weight, py::object cutoff, py::object sources, +py::object normalized, py::object endpoints) { #ifdef EASYGRAPH_ENABLE_GPU return invoke_gpu_betweenness_centrality(G, weight, sources, normalized, endpoints); #else @@ -212,7 +389,6 @@ py::object betweenness_centrality(py::object G, py::object weight, py::object cu // std::vector dis(N+1, INFINITY); // std::vector vis(N+1, false); // std::vector head_path(N+1, 0); - // const std::vector& head = G_l.head; // const std::vector& E = G_l.edges; // int edges_num = E.size(); @@ -220,10 +396,9 @@ py::object betweenness_centrality(py::object G, py::object weight, py::object cu // std::vector count_path(N+1, 0); // std::vector delta(N+1, 0); // std::vector E_path(edges_num+1); - // head_path[S] = 0; -// dis[S] = 0; -// count_path[S] = 1; +// dis[S] = 0; +// count_path[S] = 1; // q.push(compare_node(S, 0)); // int cnt_St = 0; // while(!q.empty()) { @@ -250,14 +425,12 @@ py::object betweenness_centrality(py::object G, py::object weight, py::object cu // E_path[++edge_number_path].next = head_path[v]; // E_path[edge_number_path].to = u; // head_path[v] = edge_number_path; - // } // else if (dis[v] == dis[u] + E[p].w) { // count_path[v] += count_path[u]; // E_path[++edge_number_path].next = head_path[v]; // E_path[edge_number_path].to = u; // head_path[v] = edge_number_path; - // } // } // } @@ -267,9 +440,7 @@ py::object betweenness_centrality(py::object G, py::object weight, py::object cu // for(int p = head_path[u]; p; p = E_path[p].next){ // delta[E_path[p].to] += count_path[E_path[p].to] * coeff; // } - // if (u != S) // bc[u] += delta[u]; // } // } - diff --git a/cpp_easygraph/functions/centrality/centrality.h b/cpp_easygraph/functions/centrality/centrality.h index 7040618a..b947c205 100644 --- a/cpp_easygraph/functions/centrality/centrality.h +++ b/cpp_easygraph/functions/centrality/centrality.h @@ -12,4 +12,8 @@ py::object cpp_katz_centrality( py::object py_max_iter, py::object py_tol, py::object py_normalized -); \ No newline at end of file +); + +py::object degree_centrality(py::object G); +py::object in_degree_centrality(py::object G); +py::object out_degree_centrality(py::object G); \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/degree.cpp b/cpp_easygraph/functions/centrality/degree.cpp new file mode 100644 index 00000000..83a8fdcd --- /dev/null +++ b/cpp_easygraph/functions/centrality/degree.cpp @@ -0,0 +1,96 @@ +#include "centrality.h" + +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +namespace py = pybind11; + +py::object degree_centrality( + py::object G +) { + Graph* graph = G.cast(); + py::dict centrality_map = py::dict(); + py::object nodes = graph->get_nodes(); + int n = py::len(nodes); + if (n <= 1) { + for (const auto& node_handle : nodes) { + centrality_map[node_handle] = 0.0; + } + return centrality_map; + } + + double scale = 1.0 / (n - 1); + + std::string class_name = G.attr("__class__").attr("__name__").cast(); + + if (class_name == "DiGraphC") { + // 有向图 (DiGraph) + DiGraph* digraph = G.cast(); + py::object adj = digraph->get_adj(); + py::object pred = digraph->get_pred(); + + for (const auto& node_handle : nodes) { + int out_deg = py::len(adj[node_handle]); + int in_deg = py::len(pred[node_handle]); + centrality_map[node_handle] = (double)(out_deg + in_deg) * scale; + } + } else { + py::object adj = graph->get_adj(); + for (const auto& node_handle : nodes) { + int degree = py::len(adj[node_handle]); + centrality_map[node_handle] = (double)degree * scale; + } + } + return centrality_map; +} + + +py::object in_degree_centrality( + py::object G +) { + DiGraph* graph = G.cast(); + py::dict centrality_map = py::dict(); + + py::object nodes = graph->get_nodes(); + int n = py::len(nodes); + + if (n <= 1) { + return centrality_map; + } + + double scale = 1.0 / (n - 1); + + py::object pred = graph->get_pred(); + + for (const auto& node_handle : nodes) { + int in_degree = py::len(pred[node_handle]); + centrality_map[node_handle] = in_degree * scale; + } + return centrality_map; +} + + +py::object out_degree_centrality( + py::object G +) { + Graph* graph = G.cast(); + py::dict centrality_map = py::dict(); + + py::object nodes = graph->get_nodes(); + int n = py::len(nodes); + + if (n <= 1) { + return centrality_map; + } + double scale = 1.0 / (n - 1); + + py::object adj = graph->get_adj(); + + for (const auto& node_handle : nodes) { + int out_degree = py::len(adj[node_handle]); + centrality_map[node_handle] = out_degree * scale; + } + return centrality_map; +} \ No newline at end of file diff --git a/easygraph/functions/centrality/degree.py b/easygraph/functions/centrality/degree.py index 6b97a792..9d3d0b29 100644 --- a/easygraph/functions/centrality/degree.py +++ b/easygraph/functions/centrality/degree.py @@ -5,6 +5,7 @@ @not_implemented_for("multigraph") +@hybrid("cpp_degree_centrality") def degree_centrality(G): """Compute the degree centrality for nodes in a bipartite network. @@ -36,6 +37,7 @@ def degree_centrality(G): @not_implemented_for("multigraph") @only_implemented_for_Directed_graph +@hybrid("cpp_in_degree_centrality") def in_degree_centrality(G): """Compute the in-degree centrality for nodes. @@ -80,6 +82,7 @@ def in_degree_centrality(G): @not_implemented_for("multigraph") @only_implemented_for_Directed_graph +@hybrid("cpp_out_degree_centrality") def out_degree_centrality(G): """Compute the out-degree centrality for nodes. From f094fc48b75791f481baa822e37da1a4bf52b0b4 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 16 Nov 2025 23:00:46 -0700 Subject: [PATCH 02/33] betweenness --- cpp_easygraph/functions/centrality/betweenness.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index 1013a0b0..27ba18de 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -78,6 +78,7 @@ void betweenness_bfs_worker( } if (endpoints_) { + #pragma omp atomic update bc[S] += cnt_St - 1; } while (cnt_St > 0) { @@ -88,6 +89,7 @@ void betweenness_bfs_worker( } if (u != S) + #pragma omp atomic update bc[u] += delta[u] + endpoints_; } } @@ -160,6 +162,7 @@ void betweenness_dijkstra_worker( } if (endpoints_) { + #pragma omp atomic update bc[S] += cnt_St - 1; } while (cnt_St > 0) { @@ -170,6 +173,7 @@ void betweenness_dijkstra_worker( } if (u != S) + #pragma omp atomic update bc[u] += delta[u] + endpoints_; } } @@ -345,7 +349,6 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei return ret; } - #ifdef EASYGRAPH_ENABLE_GPU static py::object invoke_gpu_betweenness_centrality(py::object G, py::object weight, py::object py_sources, py::object normalized, py::object endpoints) { From 52df86071d608b7ceae24f85d793c7681268a0d3 Mon Sep 17 00:00:00 2001 From: sama Date: Mon, 17 Nov 2025 01:32:07 -0700 Subject: [PATCH 03/33] betweenness openmp --- .../functions/centrality/betweenness.cpp | 317 ++++++++---------- 1 file changed, 142 insertions(+), 175 deletions(-) diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index 27ba18de..cf889402 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -1,8 +1,12 @@ -#include +#include +#include #include #include -#include "centrality.h" +#include +#include +#include +#include "centrality.h" #ifdef EASYGRAPH_ENABLE_GPU #include #endif @@ -12,63 +16,49 @@ #include "../../classes/linkgraph.h" #include "../../classes/segment_tree.cpp" +namespace py = pybind11; + void betweenness_bfs_worker( - const Graph_L& G_l, const int &S, std::vector& bc, double cutoff, int endpoints_, - std::queue& queue, - std::vector& dis, - std::vector& head_path, - std::vector& St, - std::vector& count_path, - std::vector& delta, - std::vector& E_path + const Graph_L& G_l, const int& S, std::vector& bc, int cutoff, int endpoints_, + std::vector& q, std::vector& dis, std::vector& head_path, std::vector& St, + std::vector& count_path, std::vector& delta, std::vector& E_path, + std::vector& stamp, int& cur_stamp ) { - const int dis_inf = 0x3f3f3f3f; int N = G_l.n; int edge_number_path = 0; int cnt_St = 0; - - queue = {}; - std::fill(dis.begin(), dis.end(), INT_MAX); - std::fill(head_path.begin(), head_path.end(), 0); - std::fill(count_path.begin(), count_path.end(), 0); - std::fill(delta.begin(), delta.end(), 0.0); - - head_path[S] = 0; + ++cur_stamp; + if ((int)q.size() < N + 1) q.resize(N + 1); + int front = 0, back = 0; + int cutoff_int = (cutoff < 0) ? -1 : cutoff; + stamp[S] = cur_stamp; dis[S] = 0; - count_path[S] = 1; - queue.push(S); - - int cutoff_int = (cutoff < 0) ? -1 : static_cast(cutoff); - - while(!queue.empty()) { - int u = queue.front(); - queue.pop(); - - if (cutoff_int >= 0 && dis[u] > cutoff_int){ - break; - } - St[cnt_St++] = u; - - const std::vector& head = G_l.head; - const std::vector& E = G_l.edges; - - for(int p = head[u]; p != -1; p = E[p].next) { + count_path[S] = 1; + delta[S] = 0.0; + head_path[S] = 0; + q[back++] = S; + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + while (front < back) { + int u = q[front++]; + int du = dis[u]; + if (cutoff_int >= 0 && du > cutoff_int) break; + St[cnt_St++] = u; + for (int p = head[u]; p != -1; p = E[p].next) { int v = E[p].to; - int new_dis = dis[u] + 1; - - if (dis[v] > new_dis) { - if (cutoff_int >= 0 && new_dis > cutoff_int) { - continue; - } - dis[v] = new_dis; - queue.push(v); + int new_dis = du + 1; + if (cutoff_int >= 0 && new_dis > cutoff_int) continue; + if (stamp[v] != cur_stamp) { + stamp[v] = cur_stamp; + dis[v] = new_dis; count_path[v] = count_path[u]; + delta[v] = 0.0; head_path[v] = 0; + q[back++] = v; E_path[++edge_number_path].next = head_path[v]; E_path[edge_number_path].to = u; head_path[v] = edge_number_path; - } - else if (dis[v] == new_dis) { + } else if (dis[v] == new_dis) { count_path[v] += count_path[u]; E_path[++edge_number_path].next = head_path[v]; E_path[edge_number_path].to = u; @@ -76,83 +66,61 @@ void betweenness_bfs_worker( } } } - - if (endpoints_) { - #pragma omp atomic update - bc[S] += cnt_St - 1; - } + if (endpoints_) bc[S] += cnt_St - 1; while (cnt_St > 0) { int u = St[--cnt_St]; - double coeff = (1.0 + delta[u]) / count_path[u]; - for(int p = head_path[u]; p; p = E_path[p].next){ - delta[E_path[p].to] += count_path[E_path[p].to] * coeff; + double cu = count_path[u]; + if (cu != 0) { + double coeff = (1.0 + delta[u]) / cu; + for (int p = head_path[u]; p; p = E_path[p].next) { + int w = E_path[p].to; + delta[w] += count_path[w] * coeff; + } } - - if (u != S) - #pragma omp atomic update - bc[u] += delta[u] + endpoints_; + if (u != S) bc[u] += delta[u] + endpoints_; } } void betweenness_dijkstra_worker( - const Graph_L& G_l, const int &S, std::vector& bc, double cutoff, - Segment_tree_zkw& segment_tree_zkw, - std::vector& dis, - std::vector& head_path, - std::vector& St, - std::vector& count_path, - std::vector& delta, - std::vector& E_path, - int endpoints_ + const Graph_L& G_l, const int& S, std::vector& bc, double cutoff, + Segment_tree_zkw& segment_tree_zkw, std::vector& dis, std::vector& head_path, + std::vector& St, std::vector& count_path, std::vector& delta, + std::vector& E_path, int endpoints_ ) { const int dis_inf = 0x3f3f3f3f; int N = G_l.n; int edge_number_path = 0; int cnt_St = 0; - segment_tree_zkw.init(N); - std::fill(dis.begin(), dis.end(), INT_MAX); + std::fill(dis.begin(), dis.end(), dis_inf); std::fill(head_path.begin(), head_path.end(), 0); std::fill(count_path.begin(), count_path.end(), 0); std::fill(delta.begin(), delta.end(), 0.0); - - head_path[S] = 0; - dis[S] = 0; - count_path[S] = 1; dis[S] = 0; count_path[S] = 1; segment_tree_zkw.change(S, 0); - - while(segment_tree_zkw.t[1] != dis_inf) { + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + while (segment_tree_zkw.t[1] != dis_inf) { int u = segment_tree_zkw.num[1]; - if(u==0) break; + if (u == 0) break; segment_tree_zkw.change(u, dis_inf); - - if (cutoff >= 0 && dis[u] > cutoff){ - continue; - } - St[cnt_St++] = u; - - const std::vector& head = G_l.head; - const std::vector& E = G_l.edges; - - for(int p = head[u]; p != -1; p = E[p].next) { + if (cutoff >= 0 && dis[u] > cutoff) continue; + St[cnt_St++] = u; + for (int p = head[u]; p != -1; p = E[p].next) { int v = E[p].to; - if(cutoff >= 0 && (dis[u] + E[p].w) > cutoff){ - continue; - } - if (dis[v] > dis[u] + E[p].w) { - dis[v] = dis[u] + E[p].w; - segment_tree_zkw.change(v, dis[v]); - dis[v] = dis[u] + E[p].w; - segment_tree_zkw.change(v, dis[v]); + int w = E[p].w; + int nd = dis[u] + w; + if (cutoff >= 0 && nd > cutoff) continue; + if (dis[v] > nd) { + dis[v] = nd; + segment_tree_zkw.change(v, nd); count_path[v] = count_path[u]; head_path[v] = 0; E_path[++edge_number_path].next = head_path[v]; E_path[edge_number_path].to = u; head_path[v] = edge_number_path; - } - else if (dis[v] == dis[u] + E[p].w) { + } else if (dis[v] == nd) { count_path[v] += count_path[u]; E_path[++edge_number_path].next = head_path[v]; E_path[edge_number_path].to = u; @@ -160,27 +128,23 @@ void betweenness_dijkstra_worker( } } } - - if (endpoints_) { - #pragma omp atomic update - bc[S] += cnt_St - 1; - } + if (endpoints_) bc[S] += cnt_St - 1; while (cnt_St > 0) { int u = St[--cnt_St]; - double coeff = (1.0 + delta[u]) / count_path[u]; - for(int p = head_path[u]; p; p = E_path[p].next){ - delta[E_path[p].to] += count_path[E_path[p].to] * coeff; + double cu = count_path[u]; + if (cu != 0) { + double coeff = (1.0 + delta[u]) / cu; + for (int p = head_path[u]; p; p = E_path[p].next) { + int w = E_path[p].to; + delta[w] += count_path[w] * coeff; + } } - - if (u != S) - #pragma omp atomic update - bc[u] += delta[u] + endpoints_; + if (u != S) bc[u] += delta[u] + endpoints_; } } static double calc_scale(int len_V, int is_directed, int normalized, int endpoints) { double scale = 1.0; - if (normalized) { if (endpoints) { if (len_V < 2) { @@ -202,16 +166,16 @@ static double calc_scale(int len_V, int is_directed, int normalized, int endpoin scale = 1.0; } } - return scale; } -static py::object invoke_cpp_betweenness_centrality(py::object G, py::object weight, - py::object cutoff, py::object sources, - py::object normalized, py::object endpoints){ +static py::object invoke_cpp_betweenness_centrality( + py::object G, py::object weight, py::object cutoff, py::object sources, + py::object normalized, py::object endpoints +) { Graph& G_ = G.cast(); int cutoff_ = -1; - if (!cutoff.is_none()){ + if (!cutoff.is_none()) { cutoff_ = cutoff.cast(); } int N = G_.node.size(); @@ -219,75 +183,66 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei int normalized_ = normalized.cast(); int endpoints_ = endpoints.cast(); double scale = calc_scale(N, is_directed, normalized_, endpoints_); - bool use_weights = !weight.is_none(); std::string weight_key = ""; if (use_weights) { - weight_key = weight_to_string(weight); + weight_key = weight_to_string(weight); } - Graph_L G_l; - if(G_.linkgraph_dirty){ + if (G_.linkgraph_dirty) { G_l = graph_to_linkgraph(G_, is_directed, weight_key, false, false); - G_.linkgraph_structure=G_l; + G_.linkgraph_structure = G_l; G_.linkgraph_dirty = false; - } - else{ + } else { G_l = G_.linkgraph_structure; } - - int edges_num = G_l.edges.size(); - + int edges_num = G_l.edges.size(); std::vector bc(N + 1, 0.0); double* bc_ptr = bc.data(); - std::vector BC; - int num_threads = 1; - #ifdef _OPENMP +#ifdef _OPENMP num_threads = omp_get_max_threads(); - #endif - +#endif std::vector> dis_all(num_threads, std::vector(N + 1)); std::vector> head_path_all(num_threads, std::vector(N + 1)); std::vector> St_all(num_threads, std::vector(N + 1)); std::vector> count_path_all(num_threads, std::vector(N + 1)); std::vector> delta_all(num_threads, std::vector(N + 1)); - std::vector> E_path_all(num_threads, std::vector(edges_num + 1)); - std::vector segment_tree_all; for (int i = 0; i < num_threads; ++i) { segment_tree_all.emplace_back(N); } - std::vector> queue_all(num_threads); + std::vector> queue_all(num_threads, std::vector(N + 1)); + std::vector> stamp_all(num_threads, std::vector(N + 1, 0)); + std::vector cur_stamp_all(num_threads, 0); + + std::vector> bc_local_all(num_threads, std::vector(N + 1, 0.0)); - if(!sources.is_none()){ + if (!sources.is_none()) { py::list sources_list = py::list(sources); int sources_list_len = py::len(sources_list); - std::vector sources_vec; sources_vec.reserve(sources_list_len); - for(int i = 0; i < sources_list_len; i++){ - if(G_.node_to_id.attr("get")(sources_list[i],py::none()).is_none()){ + for (int i = 0; i < sources_list_len; i++) { + if (G_.node_to_id.attr("get")(sources_list[i], py::none()).is_none()) { printf("The node should exist in the graph!"); return py::none(); } sources_vec.push_back(G_.node_to_id.attr("get")(sources_list[i]).cast()); } - - #ifdef _OPENMP - #pragma omp parallel for reduction(+:bc_ptr[0:N+1]) schedule(dynamic) - #endif - for(int i = 0; i < sources_list_len; i++){ +#ifdef _OPENMP +#pragma omp parallel for schedule(dynamic) +#endif + for (int i = 0; i < sources_list_len; i++) { node_t source_id = sources_vec[i]; - - #ifdef _OPENMP +#ifdef _OPENMP int tid = omp_get_thread_num(); - #else +#else int tid = 0; - #endif - +#endif + std::vector& bc_local = bc_local_all[tid]; std::vector& dis = dis_all[tid]; std::vector& head_path = head_path_all[tid]; std::vector& St = St_all[tid]; @@ -295,30 +250,32 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei std::vector& delta = delta_all[tid]; std::vector& E_path = E_path_all[tid]; Segment_tree_zkw& segment_tree_zkw = segment_tree_all[tid]; - std::queue& queue = queue_all[tid]; - + std::vector& q = queue_all[tid]; + std::vector& stamp = stamp_all[tid]; + int& cur_stamp = cur_stamp_all[tid]; if (use_weights) { - betweenness_dijkstra_worker(G_l, source_id, bc, cutoff_, - segment_tree_zkw, dis, head_path, St, count_path, delta, E_path, - endpoints_); + betweenness_dijkstra_worker( + G_l, source_id, bc_local, cutoff_, segment_tree_zkw, dis, head_path, + St, count_path, delta, E_path, endpoints_ + ); } else { - betweenness_bfs_worker(G_l, source_id, bc, cutoff_, endpoints_, - queue, dis, head_path, St, count_path, delta, E_path); + betweenness_bfs_worker( + G_l, source_id, bc_local, cutoff_, endpoints_, q, dis, head_path, + St, count_path, delta, E_path, stamp, cur_stamp + ); } } - } - else{ - #ifdef _OPENMP - #pragma omp parallel for reduction(+:bc_ptr[0:N+1]) schedule(dynamic) - #endif - for (int i = 1; i <= N; ++i){ - - #ifdef _OPENMP + } else { +#ifdef _OPENMP +#pragma omp parallel for schedule(dynamic) +#endif + for (int i = 1; i <= N; ++i) { +#ifdef _OPENMP int tid = omp_get_thread_num(); - #else +#else int tid = 0; - #endif - +#endif + std::vector& bc_local = bc_local_all[tid]; std::vector& dis = dis_all[tid]; std::vector& head_path = head_path_all[tid]; std::vector& St = St_all[tid]; @@ -326,26 +283,36 @@ static py::object invoke_cpp_betweenness_centrality(py::object G, py::object wei std::vector& delta = delta_all[tid]; std::vector& E_path = E_path_all[tid]; Segment_tree_zkw& segment_tree_zkw = segment_tree_all[tid]; - std::queue& queue = queue_all[tid]; - + std::vector& q = queue_all[tid]; + std::vector& stamp = stamp_all[tid]; + int& cur_stamp = cur_stamp_all[tid]; if (use_weights) { - betweenness_dijkstra_worker(G_l, i, bc, cutoff_, - segment_tree_zkw, dis, head_path, St, count_path, delta, E_path, - endpoints_); + betweenness_dijkstra_worker( + G_l, i, bc_local, cutoff_, segment_tree_zkw, dis, head_path, + St, count_path, delta, E_path, endpoints_ + ); } else { - betweenness_bfs_worker(G_l, i, bc, cutoff_, endpoints_, - queue, dis, head_path, St, count_path, delta, E_path); + betweenness_bfs_worker( + G_l, i, bc_local, cutoff_, endpoints_, q, dis, head_path, + St, count_path, delta, E_path, stamp, cur_stamp + ); } } } +#ifdef _OPENMP + for(int tid = 0; tid < num_threads; ++tid){ + std::vector& bc_local = bc_local_all[tid]; + for(int j = 0; j <= N; ++j){ + bc[j] += bc_local[j]; + } + } +#endif - for(int i = 1; i <= N; i++){ + for (int i = 1; i <= N; i++) { BC.push_back(scale * bc[i]); } - py::array::ShapeContainer ret_shape{(int)BC.size()}; py::array_t ret(ret_shape, BC.data()); - return ret; } From 735e40ee3b5420b5d33671dd854ae067019f3f71 Mon Sep 17 00:00:00 2001 From: sama Date: Tue, 18 Nov 2025 23:09:13 -0700 Subject: [PATCH 04/33] betweenness --- cpp_easygraph/functions/centrality/betweenness.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index cf889402..73cf687d 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -300,12 +300,12 @@ static py::object invoke_cpp_betweenness_centrality( } } #ifdef _OPENMP - for(int tid = 0; tid < num_threads; ++tid){ - std::vector& bc_local = bc_local_all[tid]; - for(int j = 0; j <= N; ++j){ - bc[j] += bc_local[j]; - } - } + #pragma omp parallel for schedule(static) + for (int j = 0; j <= N; ++j) { + double s = 0.0; + for (int tid = 0; tid < num_threads; ++tid) s += bc_local_all[tid][j]; + bc[j] += s; +} #endif for (int i = 1; i <= N; i++) { @@ -316,6 +316,7 @@ static py::object invoke_cpp_betweenness_centrality( return ret; } + #ifdef EASYGRAPH_ENABLE_GPU static py::object invoke_gpu_betweenness_centrality(py::object G, py::object weight, py::object py_sources, py::object normalized, py::object endpoints) { From d58752331f477c677da2aee20a8af117918c8449 Mon Sep 17 00:00:00 2001 From: sama Date: Fri, 21 Nov 2025 02:33:53 -0700 Subject: [PATCH 05/33] use binary heap --- .../functions/centrality/betweenness.cpp | 163 +++++++++++------- 1 file changed, 103 insertions(+), 60 deletions(-) diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index 73cf687d..3ceb0a38 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -14,7 +14,6 @@ #include "../../classes/graph.h" #include "../../common/utils.h" #include "../../classes/linkgraph.h" -#include "../../classes/segment_tree.cpp" namespace py = pybind11; @@ -28,26 +27,34 @@ void betweenness_bfs_worker( int edge_number_path = 0; int cnt_St = 0; ++cur_stamp; - if ((int)q.size() < N + 1) q.resize(N + 1); + if ((int)q.size() < N + 1) + q.resize(N + 1); int front = 0, back = 0; int cutoff_int = (cutoff < 0) ? -1 : cutoff; + stamp[S] = cur_stamp; dis[S] = 0; count_path[S] = 1; delta[S] = 0.0; head_path[S] = 0; q[back++] = S; + const std::vector& head = G_l.head; const std::vector& E = G_l.edges; + while (front < back) { int u = q[front++]; int du = dis[u]; - if (cutoff_int >= 0 && du > cutoff_int) break; + if (cutoff_int >= 0 && du > cutoff_int) + break; St[cnt_St++] = u; + for (int p = head[u]; p != -1; p = E[p].next) { int v = E[p].to; int new_dis = du + 1; - if (cutoff_int >= 0 && new_dis > cutoff_int) continue; + if (cutoff_int >= 0 && new_dis > cutoff_int) + continue; + if (stamp[v] != cur_stamp) { stamp[v] = cur_stamp; dis[v] = new_dis; @@ -66,7 +73,10 @@ void betweenness_bfs_worker( } } } - if (endpoints_) bc[S] += cnt_St - 1; + + if (endpoints_) + bc[S] += cnt_St - 1; + while (cnt_St > 0) { int u = St[--cnt_St]; double cu = count_path[u]; @@ -77,49 +87,71 @@ void betweenness_bfs_worker( delta[w] += count_path[w] * coeff; } } - if (u != S) bc[u] += delta[u] + endpoints_; + if (u != S) + bc[u] += delta[u] + endpoints_; } } void betweenness_dijkstra_worker( const Graph_L& G_l, const int& S, std::vector& bc, double cutoff, - Segment_tree_zkw& segment_tree_zkw, std::vector& dis, std::vector& head_path, + std::vector& dis, std::vector& head_path, std::vector& St, std::vector& count_path, std::vector& delta, - std::vector& E_path, int endpoints_ + std::vector& E_path, int endpoints_, + std::vector& stamp, int& cur_stamp ) { const int dis_inf = 0x3f3f3f3f; + int N = G_l.n; int edge_number_path = 0; int cnt_St = 0; - segment_tree_zkw.init(N); - std::fill(dis.begin(), dis.end(), dis_inf); - std::fill(head_path.begin(), head_path.end(), 0); - std::fill(count_path.begin(), count_path.end(), 0); - std::fill(delta.begin(), delta.end(), 0.0); + ++cur_stamp; + + stamp[S] = cur_stamp; dis[S] = 0; count_path[S] = 1; - segment_tree_zkw.change(S, 0); + delta[S] = 0.0; + head_path[S] = 0; + + std::priority_queue, std::vector>, std::greater>> pq; + pq.push({0, S}); + const std::vector& head = G_l.head; const std::vector& E = G_l.edges; - while (segment_tree_zkw.t[1] != dis_inf) { - int u = segment_tree_zkw.num[1]; - if (u == 0) break; - segment_tree_zkw.change(u, dis_inf); - if (cutoff >= 0 && dis[u] > cutoff) continue; + + while (!pq.empty()) { + std::pair top = pq.top(); + pq.pop(); + int d = top.first; + int u = top.second; + + if (d > dis[u]) continue; + + if (cutoff >= 0 && d > cutoff) continue; + St[cnt_St++] = u; + for (int p = head[u]; p != -1; p = E[p].next) { int v = E[p].to; int w = E[p].w; int nd = dis[u] + w; + if (cutoff >= 0 && nd > cutoff) continue; - if (dis[v] > nd) { + + bool first_visit = (stamp[v] != cur_stamp); + + if (first_visit || dis[v] > nd) { + if (first_visit) { + stamp[v] = cur_stamp; + delta[v] = 0.0; + } dis[v] = nd; - segment_tree_zkw.change(v, nd); count_path[v] = count_path[u]; head_path[v] = 0; E_path[++edge_number_path].next = head_path[v]; E_path[edge_number_path].to = u; head_path[v] = edge_number_path; + + pq.push({nd, v}); } else if (dis[v] == nd) { count_path[v] += count_path[u]; E_path[++edge_number_path].next = head_path[v]; @@ -128,7 +160,10 @@ void betweenness_dijkstra_worker( } } } - if (endpoints_) bc[S] += cnt_St - 1; + + if (endpoints_) + bc[S] += cnt_St - 1; + while (cnt_St > 0) { int u = St[--cnt_St]; double cu = count_path[u]; @@ -139,7 +174,8 @@ void betweenness_dijkstra_worker( delta[w] += count_path[w] * coeff; } } - if (u != S) bc[u] += delta[u] + endpoints_; + if (u != S) + bc[u] += delta[u] + endpoints_; } } @@ -188,6 +224,7 @@ static py::object invoke_cpp_betweenness_centrality( if (use_weights) { weight_key = weight_to_string(weight); } + Graph_L G_l; if (G_.linkgraph_dirty) { G_l = graph_to_linkgraph(G_, is_directed, weight_key, false, false); @@ -196,30 +233,28 @@ static py::object invoke_cpp_betweenness_centrality( } else { G_l = G_.linkgraph_structure; } + int edges_num = G_l.edges.size(); std::vector bc(N + 1, 0.0); - double* bc_ptr = bc.data(); std::vector BC; int num_threads = 1; #ifdef _OPENMP num_threads = omp_get_max_threads(); #endif + std::vector> dis_all(num_threads, std::vector(N + 1)); std::vector> head_path_all(num_threads, std::vector(N + 1)); std::vector> St_all(num_threads, std::vector(N + 1)); std::vector> count_path_all(num_threads, std::vector(N + 1)); std::vector> delta_all(num_threads, std::vector(N + 1)); std::vector> E_path_all(num_threads, std::vector(edges_num + 1)); - std::vector segment_tree_all; - for (int i = 0; i < num_threads; ++i) { - segment_tree_all.emplace_back(N); - } + std::vector> queue_all(num_threads, std::vector(N + 1)); std::vector> stamp_all(num_threads, std::vector(N + 1, 0)); std::vector cur_stamp_all(num_threads, 0); std::vector> bc_local_all(num_threads, std::vector(N + 1, 0.0)); - + if (!sources.is_none()) { py::list sources_list = py::list(sources); int sources_list_len = py::len(sources_list); @@ -232,6 +267,7 @@ static py::object invoke_cpp_betweenness_centrality( } sources_vec.push_back(G_.node_to_id.attr("get")(sources_list[i]).cast()); } + #ifdef _OPENMP #pragma omp parallel for schedule(dynamic) #endif @@ -242,21 +278,21 @@ static py::object invoke_cpp_betweenness_centrality( #else int tid = 0; #endif - std::vector& bc_local = bc_local_all[tid]; - std::vector& dis = dis_all[tid]; - std::vector& head_path = head_path_all[tid]; - std::vector& St = St_all[tid]; - std::vector& count_path = count_path_all[tid]; - std::vector& delta = delta_all[tid]; - std::vector& E_path = E_path_all[tid]; - Segment_tree_zkw& segment_tree_zkw = segment_tree_all[tid]; - std::vector& q = queue_all[tid]; - std::vector& stamp = stamp_all[tid]; + auto& bc_local = bc_local_all[tid]; + auto& dis = dis_all[tid]; + auto& head_path = head_path_all[tid]; + auto& St = St_all[tid]; + auto& count_path = count_path_all[tid]; + auto& delta = delta_all[tid]; + auto& E_path = E_path_all[tid]; + auto& q = queue_all[tid]; + auto& stamp = stamp_all[tid]; int& cur_stamp = cur_stamp_all[tid]; + if (use_weights) { betweenness_dijkstra_worker( - G_l, source_id, bc_local, cutoff_, segment_tree_zkw, dis, head_path, - St, count_path, delta, E_path, endpoints_ + G_l, source_id, bc_local, cutoff_, dis, head_path, + St, count_path, delta, E_path, endpoints_, stamp, cur_stamp ); } else { betweenness_bfs_worker( @@ -275,21 +311,21 @@ static py::object invoke_cpp_betweenness_centrality( #else int tid = 0; #endif - std::vector& bc_local = bc_local_all[tid]; - std::vector& dis = dis_all[tid]; - std::vector& head_path = head_path_all[tid]; - std::vector& St = St_all[tid]; - std::vector& count_path = count_path_all[tid]; - std::vector& delta = delta_all[tid]; - std::vector& E_path = E_path_all[tid]; - Segment_tree_zkw& segment_tree_zkw = segment_tree_all[tid]; - std::vector& q = queue_all[tid]; - std::vector& stamp = stamp_all[tid]; + auto& bc_local = bc_local_all[tid]; + auto& dis = dis_all[tid]; + auto& head_path = head_path_all[tid]; + auto& St = St_all[tid]; + auto& count_path = count_path_all[tid]; + auto& delta = delta_all[tid]; + auto& E_path = E_path_all[tid]; + auto& q = queue_all[tid]; + auto& stamp = stamp_all[tid]; int& cur_stamp = cur_stamp_all[tid]; + if (use_weights) { betweenness_dijkstra_worker( - G_l, i, bc_local, cutoff_, segment_tree_zkw, dis, head_path, - St, count_path, delta, E_path, endpoints_ + G_l, i, bc_local, cutoff_, dis, head_path, + St, count_path, delta, E_path, endpoints_, stamp, cur_stamp ); } else { betweenness_bfs_worker( @@ -299,24 +335,31 @@ static py::object invoke_cpp_betweenness_centrality( } } } + #ifdef _OPENMP - #pragma omp parallel for schedule(static) - for (int j = 0; j <= N; ++j) { +#pragma omp parallel for schedule(static) + for (int j = 1; j <= N; ++j) { double s = 0.0; - for (int tid = 0; tid < num_threads; ++tid) s += bc_local_all[tid][j]; - bc[j] += s; -} + for (int tid = 0; tid < num_threads; ++tid) + s += bc_local_all[tid][j]; + bc[j] += s; + } +#else + for (int j = 1; j <= N; ++j) { + bc[j] += bc_local_all[0][j]; + } #endif - + + BC.reserve(N); for (int i = 1; i <= N; i++) { BC.push_back(scale * bc[i]); } + py::array::ShapeContainer ret_shape{(int)BC.size()}; py::array_t ret(ret_shape, BC.data()); return ret; } - #ifdef EASYGRAPH_ENABLE_GPU static py::object invoke_gpu_betweenness_centrality(py::object G, py::object weight, py::object py_sources, py::object normalized, py::object endpoints) { From 90af1ad70a0e00f0a8341d976c97b547e5de4313 Mon Sep 17 00:00:00 2001 From: sama Date: Wed, 3 Dec 2025 20:28:02 -0700 Subject: [PATCH 06/33] omp --- cpp_easygraph/functions/pagerank/pagerank.cpp | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index 0c3d5479..8394f9af 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -1,19 +1,19 @@ +#include +#include +#include +#include #include "pagerank.h" #include "../../classes/directed_graph.h" #include "../../common/utils.h" #include "../../classes/linkgraph.h" -#include "time.h" struct Page { - Page(){} + Page(){} Page(const double &_newPR, const double &_oldPR) {newPR = _newPR; oldPR = _oldPR;} double newPR, oldPR; }; -// outDegree -// get_edge_from_node - py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6) { bool is_directed = G.attr("is_directed")().cast(); @@ -23,10 +23,10 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } DiGraph& G_ = G.cast(); int N = G_.node.size(); - // Graph_L G_l = graph_to_linkgraph(G_, is_directed, "", true); + Graph_L G_l; if(G_.linkgraph_dirty){ - G_l = graph_to_linkgraph(G_, is_directed, "", true); + G_l = graph_to_linkgraph(G_, is_directed, "", true, false); G_.linkgraph_structure=G_l; G_.linkgraph_dirty = false; } @@ -38,30 +38,43 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub std::vector outDegree = G_l.degree; std::vector head = G_l.head; - std::vectorpage(N+1); + std::vector page(N+1); + + #pragma omp parallel for for (int i = 1; i < N + 1; ++i) { page[i] = Page(0, 1.0/N); } - int cnt = 0; //统计迭代几轮 - int shouldStop = 0; //根据oldPR与newPR的差值 判断是否停止迭代 - + int cnt = 0; + int shouldStop = 0; while(!shouldStop) { shouldStop = 1; double res = 0; + + #pragma omp parallel for reduction(+:res) for(int i = 1; i < N+1; ++i) { if (outDegree[i] == 0) { res += page[i].oldPR; - continue; } + } + + #pragma omp parallel for schedule(dynamic, 128) + for(int i = 1; i < N+1; ++i) { + if (outDegree[i] == 0) continue; + double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; + for(int p = head[i]; p != -1; p = E[p].next){ + #pragma omp atomic page[E[p].to].newPR += tmpPR; } } + double sum = 0; + + #pragma omp parallel for reduction(+:sum) for(int i = 1; i < N+1; ++i) { page[i].newPR += (1 - alpha) / N + res / N * alpha; @@ -84,4 +97,4 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } return res_lst; -} +} \ No newline at end of file From 3989756b6442cbb4ec4bd7a00f7c972a2abe2310 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 7 Dec 2025 19:40:01 -0700 Subject: [PATCH 07/33] undirected graph --- cpp_easygraph/functions/pagerank/pagerank.cpp | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index 8394f9af..04422ca3 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -4,6 +4,7 @@ #include #include "pagerank.h" #include "../../classes/directed_graph.h" +#include "../../classes/graph.h" #include "../../common/utils.h" #include "../../classes/linkgraph.h" @@ -17,26 +18,33 @@ struct Page { py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6) { bool is_directed = G.attr("is_directed")().cast(); - if (is_directed == false) { - printf("PageRank is designed for directed graphs.\n"); - return py::dict(); - } - DiGraph& G_ = G.cast(); - int N = G_.node.size(); - Graph_L G_l; - if(G_.linkgraph_dirty){ - G_l = graph_to_linkgraph(G_, is_directed, "", true, false); - G_.linkgraph_structure=G_l; - G_.linkgraph_dirty = false; - } - else{ - G_l = G_.linkgraph_structure; + Graph_L* G_l_ptr = nullptr; + int N = 0; + + if (is_directed) { + DiGraph& G_ = G.cast(); + N = G_.node.size(); + + if(G_.linkgraph_dirty){ + G_.linkgraph_structure = graph_to_linkgraph(G_, true, "", true, false); + G_.linkgraph_dirty = false; + } + G_l_ptr = &G_.linkgraph_structure; + } else { + Graph& G_ = G.cast(); + N = G_.node.size(); + + if(G_.linkgraph_dirty){ + G_.linkgraph_structure = graph_to_linkgraph(G_, false, "", true, false); + G_.linkgraph_dirty = false; + } + G_l_ptr = &G_.linkgraph_structure; } - std::vector& E = G_l.edges; - std::vector outDegree = G_l.degree; - std::vector head = G_l.head; + std::vector& E = G_l_ptr->edges; + std::vector& outDegree = G_l_ptr->degree; + std::vector& head = G_l_ptr->head; std::vector page(N+1); From f92e54995453ea0067f34bb99bf6f85e230cb328 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 7 Dec 2025 22:27:30 -0700 Subject: [PATCH 08/33] GNN part --- easygraph/classes/graph.py | 98 +++++ easygraph/nn/__init__.py | 6 + easygraph/nn/convs/__init__.py | 4 + easygraph/nn/convs/graphs/__init__.py | 5 + easygraph/nn/convs/graphs/gat_conv.py | 71 ++++ easygraph/nn/convs/graphs/gcn_conv.py | 197 ++++++++++ easygraph/nn/convs/graphs/gcnii_conv.py | 51 +++ easygraph/nn/convs/graphs/gin_conv.py | 32 ++ easygraph/nn/convs/graphs/sage_conv.py | 186 +++++++++ easygraph/nn/tests/GAT_TESTs/result.out | 178 +++++++++ easygraph/nn/tests/GAT_TESTs/test_gatconv.py | 261 ++++++++++++ .../nn/tests/GAT_TESTs/test_gatconv_cogdl.py | 229 +++++++++++ .../nn/tests/GAT_TESTs/test_gatconv_dgl.py | 286 ++++++++++++++ .../GAT_TESTs/test_gatconv_egsampling_dc.py | 322 +++++++++++++++ .../nn/tests/GAT_TESTs/test_gatconv_pyg.py | 275 +++++++++++++ easygraph/nn/tests/GCN_TESTs/result_gcn.out | 216 ++++++++++ easygraph/nn/tests/GCN_TESTs/test.py | 70 ++++ easygraph/nn/tests/GCN_TESTs/test_GP.py | 143 +++++++ easygraph/nn/tests/GCN_TESTs/test_NDP.py | 123 ++++++ easygraph/nn/tests/GCN_TESTs/test_gcnconv.py | 372 ++++++++++++++++++ .../nn/tests/GCN_TESTs/test_gcnconv_cogdl.py | 346 ++++++++++++++++ .../nn/tests/GCN_TESTs/test_gcnconv_copy.py | 275 +++++++++++++ .../nn/tests/GCN_TESTs/test_gcnconv_dgl.py | 233 +++++++++++ .../GCN_TESTs/test_gcnconv_egs_dc_multi.py | 215 ++++++++++ .../GCN_TESTs/test_gcnconv_egsampling.py | 272 +++++++++++++ .../GCN_TESTs/test_gcnconv_egsampling_dc.py | 314 +++++++++++++++ .../nn/tests/GCN_TESTs/test_gcnconv_multi.py | 218 ++++++++++ .../nn/tests/GCN_TESTs/test_gcnconv_pyg.py | 256 ++++++++++++ .../tests/GCN_TESTs/test_gcnconv_pyg_multi.py | 216 ++++++++++ .../nn/tests/GCN_TESTs/test_mix_Precision.py | 95 +++++ easygraph/nn/tests/GCN_TESTs/txtReader.py | 42 ++ .../data/OGBN_MAG/mag.zip} | 0 .../data/OGBN_MAG/mag/raw/mag.zip} | 0 .../nn/tests/autodl-tmp/data/mag/raw/mag.zip | 0 easygraph/nn/tests/dataset_info.py | 127 ++++++ easygraph/nn/tests/draw.py | 194 +++++++++ easygraph/nn/tests/network_scatter_NC10.png | Bin 0 -> 174379 bytes 37 files changed, 5928 insertions(+) create mode 100644 easygraph/nn/convs/graphs/__init__.py create mode 100644 easygraph/nn/convs/graphs/gat_conv.py create mode 100644 easygraph/nn/convs/graphs/gcn_conv.py create mode 100644 easygraph/nn/convs/graphs/gcnii_conv.py create mode 100644 easygraph/nn/convs/graphs/gin_conv.py create mode 100644 easygraph/nn/convs/graphs/sage_conv.py create mode 100644 easygraph/nn/tests/GAT_TESTs/result.out create mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv.py create mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py create mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py create mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py create mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py create mode 100644 easygraph/nn/tests/GCN_TESTs/result_gcn.out create mode 100644 easygraph/nn/tests/GCN_TESTs/test.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_GP.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_NDP.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py create mode 100644 easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py create mode 100644 easygraph/nn/tests/GCN_TESTs/txtReader.py rename easygraph/nn/tests/{test_gatconv.py => autodl-tmp/data/OGBN_MAG/mag.zip} (100%) rename easygraph/nn/tests/{test_gcnconv.py => autodl-tmp/data/OGBN_MAG/mag/raw/mag.zip} (100%) create mode 100644 easygraph/nn/tests/autodl-tmp/data/mag/raw/mag.zip create mode 100644 easygraph/nn/tests/dataset_info.py create mode 100644 easygraph/nn/tests/draw.py create mode 100644 easygraph/nn/tests/network_scatter_NC10.png diff --git a/easygraph/classes/graph.py b/easygraph/classes/graph.py index 07caabde..e7235a49 100644 --- a/easygraph/classes/graph.py +++ b/easygraph/classes/graph.py @@ -495,6 +495,104 @@ def L_GCN(self): ) return self.cache["L_GCN"] + @property + def edge_index(self): + import torch + if "edge_index" not in self.cache: + edge_list = [(u, v) for u, neighbors in self._adj.items() for v in neighbors] + self_loops = [(u, u) for u in self._adj.keys()] + edge_list += self_loops + edge_index = torch.tensor(edge_list, dtype=torch.long, device=self.device).t().contiguous() + self.cache["edge_index"] = edge_index + return self.cache["edge_index"] + + @property + def norm_info(self): + import torch + + if "norm_info" not in self.cache: + edge_index = self.edge_index + row, col = edge_index + deg_dict = self.degree() + deg = torch.tensor([deg_dict[i] for i in range(len(self.nodes))], dtype=torch.float32) + # deg = self.degree_tensor(col, len(self.nodes), dtype=torch.float32) + deg_inv_sqrt = deg.pow(-0.5) + deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0 + norm = deg_inv_sqrt[row] * deg_inv_sqrt[col] + self.cache["norm_info"] = (row, col, norm) + return self.cache["norm_info"] + + @property + def adj_t(self): + from torch_sparse import SparseTensor + if "adj_t" not in self.cache: + row, col, norm = self.norm_info + N = len(self.nodes) + + self.cache["adj_t"] = SparseTensor(row=row, col=col, value=norm, sparse_sizes=(len(self.nodes), len(self.nodes))) + + return self.cache["adj_t"] + + def build_adj_gp(self, nparts: int = 4): + if "adj_gp" not in self.cache: + import ctypes + import numpy as np + import torch + import metis + from torch_sparse import SparseTensor + + row, col, norm = self.norm_info + N = len(self.nodes) + nnz = len(row) + + adj_list = [[] for _ in range(N)] + for u, v in zip(row, col): + adj_list[u].append(v) + + # --- 修改后的划分预处理 --- + idx_t = ctypes.c_int32 + xadj = (idx_t*(N+1))() # shape: (N+1,) + adjncy = (idx_t*(nnz))() # shape: (nnz,) + adjwgt = (idx_t*nnz)() # shape: (nnz,) + xadj[0] = ptr = 0 + for i, adj in enumerate(adj_list): + for j in adj: + adjncy[ptr] = j + adjwgt[ptr] = 1 + ptr += 1 + xadj[i+1] = ptr + + _, parts = metis.part_graph({ + 'nvtxs': idx_t(N), # 节点数 + 'ncon': idx_t(1), + 'xadj': xadj, + 'adjncy': adjncy, + 'vwgt': None, # 节点权重 + 'vsize': None, # 节点大小 默认 None + 'adjwgt': adjwgt # 边权重 + }, nparts=nparts) + + part_to_nodes = [[] for _ in range(nparts)] + for idx, p in enumerate(parts): + part_to_nodes[p].append(idx) + + perm = np.concatenate(part_to_nodes) + + inv_perm = np.argsort(perm) + + row2 = inv_perm[row] + col2 = inv_perm[col] + adj_gp = SparseTensor( + row=torch.tensor(row2, dtype=torch.long), + col=torch.tensor(col2, dtype=torch.long), + value=norm, + sparse_sizes=(N, N) + ) + self.cache['adj_gp'] = adj_gp + self.cache['gp_perm'] = torch.tensor(perm) + self.cache['gp_inv_perm'] = torch.tensor(inv_perm) + return + def smoothing_with_GCN(self, X, drop_rate=0.0): r"""Return the smoothed feature matrix with GCN Laplacian matrix :math:`\mathcal{L}_{GCN}`. diff --git a/easygraph/nn/__init__.py b/easygraph/nn/__init__.py index 0c2386cf..88053be0 100644 --- a/easygraph/nn/__init__.py +++ b/easygraph/nn/__init__.py @@ -21,3 +21,9 @@ " torch_scatter before you use functions related to AllDeepSet and" " AllSetTransformer." ) + +from .convs import GATConv +from .convs import GCNConv +from .convs import GraphSAGEConv + +__all__ = ['GCNConv', 'GATConv', 'GraphSAGEConv'] diff --git a/easygraph/nn/convs/__init__.py b/easygraph/nn/convs/__init__.py index 8b137891..20cd9dc3 100644 --- a/easygraph/nn/convs/__init__.py +++ b/easygraph/nn/convs/__init__.py @@ -1 +1,5 @@ +from .graphs import GCNConv +from .graphs import GATConv +from .graphs import GraphSAGEConv +__all__ = ['GCNConv', 'GATConv', 'GraphSAGEConv'] diff --git a/easygraph/nn/convs/graphs/__init__.py b/easygraph/nn/convs/graphs/__init__.py new file mode 100644 index 00000000..73bf5727 --- /dev/null +++ b/easygraph/nn/convs/graphs/__init__.py @@ -0,0 +1,5 @@ +from .gcn_conv import GCNConv +from .gat_conv import GATConv +from .sage_conv import GraphSAGEConv + +__all__ = ['GCNConv', 'GATConv', 'GraphSAGEConv'] diff --git a/easygraph/nn/convs/graphs/gat_conv.py b/easygraph/nn/convs/graphs/gat_conv.py new file mode 100644 index 00000000..9f5706ae --- /dev/null +++ b/easygraph/nn/convs/graphs/gat_conv.py @@ -0,0 +1,71 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +class GATConv(nn.Module): + def __init__(self, in_channels, out_channels, heads=4, concat=True, dropout=0.5, bias=True): + super().__init__() + self.in_channels = in_channels + self.out_channels = out_channels + self.heads = heads + self.concat = concat + self.dropout = dropout + + self.weight = nn.Parameter(torch.Tensor(heads, in_channels, out_channels)) + + self.att = nn.Parameter(torch.Tensor(heads, 2 * out_channels)) + + if bias: + self.bias = nn.Parameter(torch.zeros(out_channels * heads if concat else out_channels)) + else: + self.register_parameter('bias', None) + + self.leakyrelu = nn.LeakyReLU(0.2) + self.reset_parameters() + + def reset_parameters(self): + nn.init.xavier_uniform_(self.weight) + nn.init.xavier_uniform_(self.att) + if self.bias is not None: + nn.init.zeros_(self.bias) + + def forward(self, x, g): + + device = x.device + N = x.size(0) + H, C = self.heads, self.out_channels + src, dst = g.edge_index + + h = torch.einsum('nf,hfc->nhc', x, self.weight) + if self.training and self.dropout > 0: + h = F.dropout(h, p=self.dropout, training=True) + + h_src = h[src] + h_dst = h[dst] + + h_cat = torch.cat([h_src, h_dst], dim=-1) + + alpha = (h_cat * self.att.unsqueeze(0)).sum(dim=-1) + alpha = self.leakyrelu(alpha) + + alpha_max = torch.full((N,H), -1e9, device=device) + alpha_max.scatter_reduce_(0, dst[:,None].expand(-1,H), alpha, reduce="amax", include_self=True) + alpha_exp = torch.exp(alpha - alpha_max[dst]) + sum_exp = torch.zeros(N,H,device=device).index_add_(0, dst, alpha_exp) + alpha = alpha_exp / (sum_exp[dst] + 1e-16) + + if self.training and self.dropout > 0: + alpha = F.dropout(alpha, p=self.dropout, training=True) + + out = torch.zeros(N,H,C,device=device) + out.index_add_(0, dst, h_src * alpha.unsqueeze(-1)) + + if self.concat: + out = out.reshape(N, H*C) + else: + out = out.mean(dim=1) + + if self.bias is not None: + out = out + self.bias.view(1,-1) + + return out diff --git a/easygraph/nn/convs/graphs/gcn_conv.py b/easygraph/nn/convs/graphs/gcn_conv.py new file mode 100644 index 00000000..1a9581d0 --- /dev/null +++ b/easygraph/nn/convs/graphs/gcn_conv.py @@ -0,0 +1,197 @@ +import torch +import torch.nn as nn +# class GCNConv(nn.Module): +# def __init__(self, in_channels, out_channels, bias=True): +# super(GCNConv, self).__init__() +# self.in_channels = in_channels +# self.out_channels = out_channels +# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) +# if bias: +# self.bias = nn.Parameter(torch.Tensor(out_channels)) +# else: +# self.register_parameter('bias', None) + +# self.reset_parameters() + +# def reset_parameters(self): +# nn.init.xavier_uniform_(self.weight) +# if self.bias is not None: +# nn.init.zeros_(self.bias) + +# def forward(self, x, g): + +# out = g.adj_t.matmul(x @ self.weight) + +# if self.bias is not None: +# out += self.bias + +# return out + +# def __repr__(self): +# return '{}({}, {})'.format(self.__class__.__name__, self.in_channels, self.out_channels) + + +## The version of the GP + +# import torch +# import torch.nn as nn + +# class GCNConv(nn.Module): +# ''' +# GCN with graph partition version +# ''' +# def __init__(self, in_channels, out_channels, bias=True): +# super(GCNConv, self).__init__() +# self.in_channels = in_channels +# self.out_channels = out_channels +# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) +# if bias: +# self.bias = nn.Parameter(torch.Tensor(out_channels)) +# else: +# self.register_parameter('bias', None) + +# self.reset_parameters() + +# def reset_parameters(self): +# nn.init.xavier_uniform_(self.weight) +# if self.bias is not None: +# nn.init.zeros_(self.bias) + +# def forward(self, x, g): + +# out = g.cache['adj_gp'].matmul(x @ self.weight) + +# if self.bias is not None: +# out += self.bias +# return out + +# def __repr__(self): +# return '{}({}, {})'.format(self.__class__.__name__, self.in_channels, self.out_channels) + + + +## The version of the GP+backward + +# class FastGCNConvFn(torch.autograd.Function): +# @staticmethod +# def forward(ctx, x, weight, adj, bias=None): +# AX = adj.matmul(x) +# out = AX @ weight +# # out = adj.matmul(x @ weight) +# if bias is not None: +# out += bias +# # AX, x, weight, bias 是 Tensor,可以用 save_for_backward +# ctx.save_for_backward(AX, weight, bias) +# # adj 是稀疏矩阵,直接赋值给 ctx +# ctx.adj = adj +# return out + +# @staticmethod +# def backward(ctx, grad_out): +# AX, weight, bias = ctx.saved_tensors +# adj = ctx.adj + +# grad_x = grad_w = grad_b = None +# grad_w = AX.T @ grad_out +# grad_x = adj.matmul(grad_out @ weight.T) +# if bias is not None: +# grad_b = grad_out.sum(0) +# return grad_x, grad_w, None, grad_b + +# class GCNConv(nn.Module): +# def __init__(self, in_channels, out_channels, bias=True): +# super().__init__() +# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) +# self.bias = nn.Parameter(torch.Tensor(out_channels)) if bias else None +# self.reset_parameters() + +# def reset_parameters(self): +# nn.init.xavier_uniform_(self.weight) +# if self.bias is not None: +# nn.init.zeros_(self.bias) + +# def forward(self, x, g): +# return FastGCNConvFn.apply(x, self.weight, g.cache['adj_gp'], self.bias) + + + +### The version of C++ BK +# try: +# import cpp_easygraph +# HAS_CPP_BACKEND = True +# except ImportError: +# print("Warning: cpp_easygraph module not found. Using slow Python fallback.") +# HAS_CPP_BACKEND = False + +# class GCNConv(nn.Module): +# def __init__(self, in_channels, out_channels, bias=True): +# super().__init__() +# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) +# self.bias = nn.Parameter(torch.Tensor(out_channels)) if bias else None +# self.reset_parameters() + +# def reset_parameters(self): +# nn.init.xavier_uniform_(self.weight) +# if self.bias is not None: +# nn.init.zeros_(self.bias) + +# def forward(self, x, g): +# return cpp_easygraph.upscale_gcn_forward(x, self.weight, g.cache['adj_torch'], self.bias) + + +### The version of GP+BW upup +class FastGCNConvFn(torch.autograd.Function): + @staticmethod + def forward(ctx, x, weight, adj, bias=None): + AX = adj.matmul(x) + out = AX @ weight + + if bias is not None: + out += bias + + ctx.save_for_backward(AX, weight, bias) + ctx.adj = adj + + return out + + @staticmethod + def backward(ctx, grad_out): + AX, weight, bias = ctx.saved_tensors + adj = ctx.adj + + grad_x = grad_w = grad_b = None + + if ctx.needs_input_grad[1]: + grad_w = AX.t() @ grad_out + + if ctx.needs_input_grad[0]: + grad_temp = grad_out @ weight.t() + grad_x = adj.t().matmul(grad_temp) + + # 3. 计算 Bias 梯度 + if bias is not None and ctx.needs_input_grad[3]: + grad_b = grad_out.sum(0) + + return grad_x, grad_w, None, grad_b + +class GCNConv(nn.Module): + def __init__(self, in_channels, out_channels, bias=True): + super().__init__() + self.in_channels = in_channels + self.out_channels = out_channels + self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) + self.bias = nn.Parameter(torch.Tensor(out_channels)) if bias else None + self.reset_parameters() + + def reset_parameters(self): + nn.init.xavier_uniform_(self.weight) + if self.bias is not None: + nn.init.zeros_(self.bias) + + def forward(self, x, g): + # 1. 检查 adj_gp 是否存在 + if not hasattr(g, 'cache') or 'adj_gp' not in g.cache: + raise RuntimeError("EasyGraph Error: 'adj_gp' not found in graph cache. Please run g.build_adj_gp() first.") + + + return FastGCNConvFn.apply(x, self.weight, g.cache['adj_gp'], self.bias) \ No newline at end of file diff --git a/easygraph/nn/convs/graphs/gcnii_conv.py b/easygraph/nn/convs/graphs/gcnii_conv.py new file mode 100644 index 00000000..7521dc97 --- /dev/null +++ b/easygraph/nn/convs/graphs/gcnii_conv.py @@ -0,0 +1,51 @@ +import torch +import torch.nn as nn + +class GCNIIConv(nn.Module): + def __init__(self, in_channels, out_channels, alpha=0.1, theta=0.5, bias=True, learnable_alpha=False): + + super().__init__() + self.in_channels = in_channels + self.out_channels = out_channels + self.theta = theta + + self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) + if learnable_alpha: + self.alpha = nn.Parameter(torch.Tensor([alpha])) + else: + self.register_buffer('alpha', torch.Tensor([alpha])) + if bias: + self.bias = nn.Parameter(torch.Tensor(out_channels)) + else: + self.register_parameter('bias', None) + + self.reset_parameters() + + def reset_parameters(self): + nn.init.xavier_uniform_(self.weight) + if hasattr(self, 'bias') and self.bias is not None: + nn.init.zeros_(self.bias) + if hasattr(self, 'alpha') and isinstance(self.alpha, nn.Parameter): + nn.init.constant_(self.alpha, self.alpha.item()) + + def forward(self, x, g, x0=None): + + if x0 is None: + x0 = x + + agg = g.adj_t.matmul(x @ self.weight) + + out = (1 - self.alpha) * agg + self.alpha * x0 + + out = self.theta * out + + if self.bias is not None: + out = out + self.bias + + return out + + def __repr__(self): + return '{}({}, {}, alpha={}, theta={})'.format( + self.__class__.__name__, self.in_channels, self.out_channels, + self.alpha.item(), self.theta + ) diff --git a/easygraph/nn/convs/graphs/gin_conv.py b/easygraph/nn/convs/graphs/gin_conv.py new file mode 100644 index 00000000..9253e81f --- /dev/null +++ b/easygraph/nn/convs/graphs/gin_conv.py @@ -0,0 +1,32 @@ +import torch +import torch.nn as nn + +class GINConv(nn.Module): + def __init__(self, in_channels, out_channels, eps=0.0, train_eps=True): + super().__init__() + # MLP 层 + self.mlp = nn.Sequential( + nn.Linear(in_channels, out_channels), + nn.ReLU(), + nn.Linear(out_channels, out_channels) + ) + if train_eps: + self.eps = nn.Parameter(torch.Tensor([eps])) + else: + self.register_buffer('eps', torch.Tensor([eps])) + self.reset_parameters() + + def reset_parameters(self): + for layer in self.mlp: + if isinstance(layer, nn.Linear): + nn.init.xavier_uniform_(layer.weight) + nn.init.zeros_(layer.bias) + + def forward(self, x, g): + + agg = g.adj_t.matmul(x) + + out = (1 + self.eps) * x + agg + + out = self.mlp(out) + return out diff --git a/easygraph/nn/convs/graphs/sage_conv.py b/easygraph/nn/convs/graphs/sage_conv.py new file mode 100644 index 00000000..5280ac68 --- /dev/null +++ b/easygraph/nn/convs/graphs/sage_conv.py @@ -0,0 +1,186 @@ +import math +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.nn.parameter import Parameter + + +# class GraphSAGEConv(nn.Module): +# """ +# GraphSAGE convolution layer supporting 'mean' and 'pool' aggregation. + +# Parameters: +# in_channels (int): Input feature dimension. +# out_channels (int): Output feature dimension. +# aggr (str): Aggregation method, either 'mean' or 'pool'. +# bias (bool): Whether to add bias. +# dropout (float): Dropout rate. +# use_bn (bool): Whether to use BatchNorm1d. +# is_last (bool): If True, skip activation and dropout. +# """ + +# def __init__( +# self, +# in_channels: int, +# out_channels: int, +# aggr: str = "mean", +# bias: bool = True, +# dropout: float = 0.5, +# use_bn: bool = False, +# is_last: bool = False, +# ): +# super(GraphSAGEConv, self).__init__() +# assert aggr in ["mean", "pool"] +# self.in_channels = in_channels +# self.out_channels = out_channels +# self.aggr = aggr +# self.dropout = dropout +# self.is_last = is_last +# self.use_bn = use_bn + +# self.weight = Parameter(torch.Tensor(in_channels * 2, out_channels)) +# if aggr == "pool": +# self.fc_pool = nn.Linear(in_channels, in_channels) + +# if bias: +# self.bias = Parameter(torch.Tensor(out_channels)) +# else: +# self.register_parameter('bias', None) + +# if self.use_bn: +# self.bn = nn.BatchNorm1d(out_channels) +# else: +# self.bn = None + +# self.reset_parameters() + +# def reset_parameters(self): +# stdv = 1. / math.sqrt(self.out_channels) +# self.weight.data.uniform_(-stdv, stdv) +# if self.bias is not None: +# self.bias.data.uniform_(-stdv, stdv) +# if self.aggr == "pool": +# nn.init.xavier_uniform_(self.fc_pool.weight) + +# def forward(self, x, adj): +# N = x.size(0) +# if adj.is_sparse: +# adj = adj.to_dense() + +# if self.aggr == "mean": +# deg = adj.sum(dim=1, keepdim=True).clamp(min=1) +# agg = torch.matmul(adj, x) / deg + +# elif self.aggr == "pool": +# x_pool = F.relu(self.fc_pool(x)) + +# masked = adj.unsqueeze(-1) * x_pool.unsqueeze(0) +# masked[adj == 0] = float('-inf') +# agg = torch.max(masked, dim=1)[0] + +# else: +# raise NotImplementedError + +# h = torch.cat([x, agg], dim=1) +# h = torch.matmul(h, self.weight) + +# if self.bias is not None: +# h = h + self.bias + +# if not self.is_last: +# h = F.relu(h) +# if self.bn is not None: +# h = self.bn(h) +# h = F.dropout(h, p=self.dropout, training=self.training) + +# return h + +# def __repr__(self): +# return f"{self.__class__.__name__}({self.in_channels} -> {self.out_channels}, aggr='{self.aggr}')" + +class GraphSAGEConv(nn.Module): + """ + GraphSAGE convolution layer supporting 'mean' and 'pool' aggregation with SparseTensor. + Uses g.adj_t for adjacency to maintain consistent API. + """ + def __init__( + self, + in_channels: int, + out_channels: int, + aggr: str = "mean", + bias: bool = True, + dropout: float = 0.5, + use_bn: bool = False, + is_last: bool = False, + ): + super(GraphSAGEConv, self).__init__() + assert aggr in ["mean", "pool"] + self.in_channels = in_channels + self.out_channels = out_channels + self.aggr = aggr + self.dropout = dropout + self.is_last = is_last + self.use_bn = use_bn + + self.weight = Parameter(torch.Tensor(in_channels * 2, out_channels)) + if aggr == "pool": + self.fc_pool = nn.Linear(in_channels, in_channels) + + if bias: + self.bias = Parameter(torch.Tensor(out_channels)) + else: + self.register_parameter('bias', None) + + if self.use_bn: + self.bn = nn.BatchNorm1d(out_channels) + else: + self.bn = None + + self.reset_parameters() + + def reset_parameters(self): + stdv = 1. / math.sqrt(self.out_channels) + self.weight.data.uniform_(-stdv, stdv) + if self.bias is not None: + self.bias.data.uniform_(-stdv, stdv) + if self.aggr == "pool": + nn.init.xavier_uniform_(self.fc_pool.weight) + + def forward(self, x, g): + """ + x: (N, F_in) + g: Graph object with g.adj_t as torch_sparse.SparseTensor + """ + adj_t: SparseTensor = g.adj_t # 从图对象取稀疏邻接矩阵 + + if self.aggr == "mean": + # 邻居聚合 (稀疏矩阵乘法) + agg = adj_t.matmul(x) + # 计算度并做平均 + deg = adj_t.sum(dim=1).clamp(min=1).unsqueeze(-1) + agg = agg / deg + + elif self.aggr == "pool": + # 非线性映射 + x_pool = F.relu(self.fc_pool(x)) + # 用 torch_scatter 实现基于邻居的 max pooling + row, col, _ = adj_t.coo() # 获取边索引 + agg = scatter(x_pool[col], row, dim=0, reduce='max') + + else: + raise NotImplementedError + + # 拼接自身特征与聚合特征 + h = torch.cat([x, agg], dim=1) + h = torch.matmul(h, self.weight) + + if self.bias is not None: + h = h + self.bias + + if not self.is_last: + h = F.relu(h) + if self.bn is not None: + h = self.bn(h) + h = F.dropout(h, p=self.dropout, training=self.training) + + return h \ No newline at end of file diff --git a/easygraph/nn/tests/GAT_TESTs/result.out b/easygraph/nn/tests/GAT_TESTs/result.out new file mode 100644 index 00000000..38addc40 --- /dev/null +++ b/easygraph/nn/tests/GAT_TESTs/result.out @@ -0,0 +1,178 @@ +nohup: ignoring input + Please install Pytorch before use graph-related datasets and hypergraph-related datasets. + +================= 数据集: CS ================= +Train nodes: 10999 | Val nodes: 3667 | Test nodes: 3667 +81894 + + 0%| | 0/50 [00:00= EARLY_STOP_WINDOW: + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py new file mode 100644 index 00000000..ecfa6c45 --- /dev/null +++ b/easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py @@ -0,0 +1,229 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics + + +# -------------------- 配置 -------------------- +BACKEND = 'Cogdl' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 500 +HIDDEN_DIM = 8 +DROPOUT = 0.6 +Heads = 8 +EARLY_STOP_WINDOW = 100 +RR = 5 + +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + # ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products') +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +from cogdl.models.nn.gat import GAT +from cogdl.data import Graph + +transform = T.NormalizeFeatures() + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + g = Graph( + x=data.x, + edge_index=data.edge_index, + y=data.y, + num_nodes=num_nodes + ) + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + elif backend_type in 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + # -------------------- 初始化模型 -------------------- + model = GAT(in_feats = dataset.num_node_features, hidden_size = HIDDEN_DIM, + out_features = dataset.num_classes, num_layers = 2, dropout=DROPOUT, alpha=0.2, + attn_drop = DROPOUT, nhead= Heads, residual=False, last_nhead=1, norm=None).to(DEVICE) + model = torch.compile(model, backend="inductor") + optimizer = torch.optim.Adam( + model.parameters(), + lr=0.005, # 学习率 0.005 + weight_decay=5e-4 # L2 正则 + ) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + for epoch in tqdm(range(1, EPOCHS+1)): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(g) + end_fwd = time.perf_counter() + + # loss 计算 + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + if loss_val.item() < best_val_loss: + best_val_loss = loss_val.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py new file mode 100644 index 00000000..9447ddd2 --- /dev/null +++ b/easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py @@ -0,0 +1,286 @@ +import time +import torch +import torch.nn.functional as F +import torch.nn as nn +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm + +# -------------------- 配置 -------------------- +BACKEND = 'dgl' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 500 +HIDDEN_DIM = 8 +DROPOUT = 0.6 +Heads = 8 +EARLY_STOP_WINDOW = 100 +RR = 1 + +DATASETS = [ + # ('Coauthor', 'CS'), + ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + # ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products') +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- + +from torch_geometric.datasets import Coauthor, Planetoid +import torch_geometric.transforms as T +import dgl +# import dgl.nn.pytorch as dglnn +from dgl.nn import GATConv +from ogb.nodeproppred import PygNodePropPredDataset + +# 解决 torch.load 报错 +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +transform = T.NormalizeFeatures() +# 数据集选择(自行切换) +# dataset = Coauthor(root='data/Coauthor', name='CS') +# dataset = Coauthor(root='data/Coauthor', name='Physics') +# dataset = Planetoid(root='/tmp/Cora', name='Cora') +# dataset = Planetoid(root='/tmp/PubMed', name='PubMed', transform=transform) +# dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='data/OGB') +# dataset = PygNodePropPredDataset(name='ogbn-products', root='data/OGB') +# data = dataset[0] + +# 构建 DGL 图(用 PyG 的 edge_index 转换) +class GAT(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.6, heads=8): + super(GAT, self).__init__() + self.dropout = dropout + + # 第一层: 多头注意力 + concat + self.gat1 = GATConv( + in_feats=in_channels, + out_feats=hidden_channels, + num_heads=heads, + feat_drop=dropout, + attn_drop=dropout, + activation=F.elu + ) + + self.gat2 = GATConv( + in_feats=hidden_channels * heads, + out_feats=out_channels, + num_heads=8, + feat_drop=dropout, + attn_drop=dropout, + activation=None + ) + + def forward(self, g, x): + # 第一层 + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gat1(g, x) + x = x.flatten(1) + # 第二层 + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gat2(g, x) + x = x.mean(1) + + return x + +for backend_type, dataset_name in DATASETS: + + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + # 原始边 + src, dst = data.edge_index + g = dgl.graph((src, dst), num_nodes=data.num_nodes) + g = dgl.add_self_loop(g) + g = g.to(DEVICE) + g.ndata['feat'] = data.x.to(DEVICE) + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + elif backend_type in 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + + g.ndata['label'] = data.y.to(DEVICE) + g.ndata['train_mask'] = train_mask.to(DEVICE) + g.ndata['val_mask'] = val_mask.to(DEVICE) + g.ndata['test_mask'] = test_mask.to(DEVICE) + + + All_forward_times = [] + All_backward_times = [] + All_epoch_times = [] + All_total_train_time = [] + All_test_acc = [] + ALL_f1 = [] + + for R in tqdm(range(RR)): + # -------------------- 设备转移 -------------------- + model = GAT(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT, heads= Heads).to(DEVICE) + # -------------------- 训练配置 -------------------- + optimizer = torch.optim.Adam( + model.parameters(), + lr=0.005, # 学习率 0.005 + weight_decay=5e-4 # L2 正则 + ) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + for epoch in range(1, EPOCHS + 1): + model.train() + optimizer.zero_grad() + epoch_start = time.perf_counter() + + # 前向传播 + start_fwd = time.perf_counter() + out = model(g, g.ndata['feat']) + end_fwd = time.perf_counter() + + # 计算loss + loss = criterion(out[g.ndata['train_mask']], g.ndata['label'][g.ndata['train_mask']].squeeze()) + loss_test = criterion(out[g.ndata['test_mask']], g.ndata['label'][g.ndata['test_mask']].squeeze()) + loss_val = criterion(out[g.ndata['val_mask']], g.ndata['label'][g.ndata['val_mask']].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + epoch_end = time.perf_counter() + + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(epoch_end - epoch_start) + + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + # if loss_val.item() < best_val_loss: + # best_val_loss = loss_val.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试函数 -------------------- + @torch.no_grad() + def evaluate(model, data, mask_key='test_mask'): + model.eval() + out = model(g, g.ndata['feat']) + mask = g.ndata[mask_key] + pred = out.argmax(dim=1) + correct = (pred[mask] == g.ndata['label'][mask].squeeze()).sum() + acc = int(correct) / int(mask.sum()) + macro_f1 = f1_score(g.ndata['label'][mask].squeeze(), pred[mask], average='macro') + return acc, macro_f1 + + test_acc, f1 = evaluate(model, data) + + # -------------------- 结果输出 -------------------- + avg_fwd = sum(forward_times) / len(forward_times) + avg_bwd = sum(backward_times) / len(backward_times) + avg_epoch = sum(epoch_times) / len(epoch_times) + + All_forward_times.append(avg_fwd) + All_backward_times.append(avg_bwd) + All_epoch_times.append(avg_epoch) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(f1) + + # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') + # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') + # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') + + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") \ No newline at end of file diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py new file mode 100644 index 00000000..e22116eb --- /dev/null +++ b/easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py @@ -0,0 +1,322 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +from easygraph.utils.Effective_R import EffectiveResistance +# from easygraph.utils.GraphSampler import FERGraphSampler +from easygraph.utils.GraphSampler import degree_community_sampling +torch.set_num_threads(30) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 500 +HIDDEN_DIM = 8 +DROPOUT = 0.6 +Heads = 8 +EARLY_STOP_WINDOW = 100 +RR = 5 + + + +DATASETS = [ + # ('Coauthor', 'CS'), + ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + # ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products'), + # ('ogb2', 'ogbn-mag'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +# def feature_similarity(x, edge_index): +# src, dst = edge_index +# sim = F.cosine_similarity(x[src], x[dst]) +# sim = (sim + 1.0) / 2.0 # 归一化到 [0,1] +# return sim + +# def FER_score(r_ij, s_ij, alpha=1.0, beta=1.0): +# score = (r_ij ** alpha) * (s_ij ** beta) +# return score / (score.max() + 1e-12) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr, Amazon +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import easygraph as eg + +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +class GAT(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.6, heads=8): + super(GAT, self).__init__() + self.dropout = dropout + + self.gat1 = eg.GATConv( + in_channels=in_channels, + out_channels=hidden_channels, + heads=heads, + concat=True, + dropout=dropout + ) + + self.gat2 = eg.GATConv( + in_channels=hidden_channels * heads, + out_channels=out_channels, + heads=8, + concat=False, + dropout=dropout + ) + + def forward(self, x, g): + + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gat1(x, g) + x = F.elu(x) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gat2(x, g) + + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') + elif backend_type =='ogb2': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/autodl-tmp/zss/data/OGB') + elif backend_type == 'Reddit': + dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') + elif backend_type == 'Flickr': + dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') + elif backend_type == 'Yelp': + dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + + elif backend_type == 'ogb2': + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + + elif backend_type == 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type in ['Reddit', 'Flickr', 'Coauthor']: + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + elif backend_type == 'Yelp': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + + print('开始采样') + g_s, x_s, sampled_nodes_tensor = degree_community_sampling( + data.edge_index, data.x, data.y, num_nodes, sample_ratio=0.4, + min_nodes=150, alpha=0.65, random_ratio=0.08, bridge_ratio=0.1, k=20, nodes_per_class=17) + + print('采样完成') + print(f'节点数量: {len(g_s.nodes)}, 边数:{len(g_s.edges)}') + + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + x_s = x_s.to(DEVICE) + + # -------------------- 构建训练 mask 子集 -------------------- + train_mask_sub = data.train_mask[sampled_nodes_tensor] + val_mask_sub = data.val_mask[sampled_nodes_tensor] + test_mask_sub = data.test_mask[sampled_nodes_tensor] + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + + # sample_g = FERGraphSampler(edge_index, num_nodes, FER) + # -------------------- 初始化模型 -------------------- + model = GAT(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT, heads= Heads).to(DEVICE) + optimizer = torch.optim.Adam( + model.parameters(), + lr=0.005, # 学习率 0.005 + weight_decay=5e-4 # L2 正则 + ) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + train_start = time.perf_counter() + + for epoch in tqdm(range(1, EPOCHS+1)): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(x_s, g_s) + end_fwd = time.perf_counter() + + # -------------------- 子图 loss -------------------- + # loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub].squeeze()) + loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub].squeeze()) + + # -------------------- 全图验证 -------------------- + model.eval() + with torch.no_grad(): + out_full = model(data.x, g) # 全图前向 + loss_val = criterion(out_full[data.val_mask], data.y[data.val_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + + + # early stopping + if loss_val.item() < best_val_loss: + best_val_loss = loss_val.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") + + # -------------------- 输出最终结果 -------------------- + # print(f'\nTrain Loss = {LOSS_LIST}') + # print(f'Valid Loss = {LOSS_LIST_VALID}') + # print(f'Test Loss = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py new file mode 100644 index 00000000..09937da4 --- /dev/null +++ b/easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py @@ -0,0 +1,275 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +torch.set_num_threads(30) + +# -------------------- 配置 -------------------- +BACKEND = 'PyG' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 500 +HIDDEN_DIM = 256 +DROPOUT = 0.6 +Heads = 8 +EARLY_STOP_WINDOW = 100 +RR = 10 + +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products') +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +from torch_geometric.nn import GATConv +# from torch_geometric.nn.models import GAT +import easygraph as eg + +transform = T.NormalizeFeatures() + + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +class GATNet(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.6): + super(GATNet, self).__init__() + self.dropout = dropout + # 第一层: 8个head, 每个head输出8维 + self.conv1 = GATConv( + in_channels, hidden_channels, + heads = 8, + dropout=dropout, # attention dropout + ) + # 第二层: 单head输出分类结果 + self.conv2 = GATConv( + hidden_channels * 8, # 因为 concat + out_channels, + heads=1, + concat=False, # 原文最后一层不concat + dropout=dropout, + ) + + def forward(self, x, edge_index): + x = F.dropout(x, p=self.dropout, training=self.training) # 输入特征 dropout + x = self.conv1(x, edge_index) + x = F.elu(x) # 原文用 ELU + + x = F.dropout(x, p=self.dropout, training=self.training) # 输入特征 dropout + x = self.conv2(x, edge_index) + + return x # CrossEntropyLoss 里自带 softmax + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + elif backend_type in 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + print(len(g.edges)) + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + # -------------------- 初始化模型 -------------------- + # model = GAT( + # in_channels=dataset.num_node_features, + # hidden_channels=HIDDEN_DIM, + # out_channels=dataset.num_classes, + # num_layers=2, + # heads=Heads, + # dropout=DROPOUT + # ).to(DEVICE) + + model = GATNet( + in_channels=dataset.num_node_features, + hidden_channels=HIDDEN_DIM, + out_channels=dataset.num_classes, + dropout=DROPOUT + ).to(DEVICE) + + # model = torch.compile(model, backend="inductor") + + optimizer = torch.optim.Adam( + model.parameters(), + lr=0.005, # 学习率 0.005 + weight_decay=5e-4 # L2 正则 + ) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(data.x, data.edge_index) + end_fwd = time.perf_counter() + + # loss 计算 + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + # if loss_val.item() < best_val_loss: + # best_val_loss = loss_val.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, data.edge_index) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/result_gcn.out b/easygraph/nn/tests/GCN_TESTs/result_gcn.out new file mode 100644 index 00000000..30779f09 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/result_gcn.out @@ -0,0 +1,216 @@ +nohup: ignoring input + Please install Pytorch before use graph-related datasets and hypergraph-related datasets. + +================= 数据集: ogbn-arxiv ================= +Train nodes: 90941 | Val nodes: 29799 | Test nodes: 48603 +1157799 + + 0%| | 0/50 [00:00 0 + deg_inv_sqrt[nz] = 1.0 / np.sqrt(deg[nz]) + val = deg_inv_sqrt[row] * deg_inv_sqrt[col] + val_torch = torch.tensor(val, dtype=torch.float32) + + adj_gp = SparseTensor( + row=torch.tensor(row, dtype=torch.long), + col=torch.tensor(col, dtype=torch.long), + value=val_torch, + sparse_sizes=(num_nodes, num_nodes) + ) + + # =========== 基础实现: out = A.matmul(X @ W) =========== + def baseline_spmm(A, X, W, repeat=200, warmup=2): + # X: (N, in_ch), W: (in_ch, out_ch) + # for _ in range(warmup): + # _ = A.matmul(X @ W) + times = [] + for _ in range(repeat): + t0 = time.perf_counter() + _ = A.matmul(X @ W) + t1 = time.perf_counter() + times.append(t1 - t0) + return float(np.mean(times)), float(np.std(times)) + + # =========== Chunked 实现(feature-dim chunking) =========== + def chunked_spmm(A, X, W, chunk_size=64, repeat=200, warmup=2): + # split output channels into chunks of size chunk_size + out_ch = W.shape[1] + nchunks = (out_ch + chunk_size - 1) // chunk_size + times = [] + for _ in range(repeat): + t0 = time.perf_counter() + outs = [] + for i in range(nchunks): + s = i * chunk_size + e = min((i + 1) * chunk_size, out_ch) + w_chunk = W[:, s:e] + xw = X @ w_chunk + out_chunk = A.matmul(xw) + outs.append(out_chunk) + out = torch.cat(outs, dim=1) + t1 = time.perf_counter() + times.append(t1 - t0) + return float(np.mean(times)), float(np.std(times)) + + # =========== 主测试流程 =========== + def test_chunked_variants(X, A, in_ch, out_ch, chunk_sizes=[16,32,64,128,256,512], repeat=200): + # random weight + W = torch.randn(in_ch, out_ch, dtype=torch.float32) + + # print("\nRunning baseline ...") + t_base_mean, t_base_std = baseline_spmm(A, X, W, repeat=repeat) + # print(f"Baseline avg: {t_base_mean*1000:.3f} ms (std {t_base_std*1000:.3f} ms)") + + results = [] + for cs in chunk_sizes: + # print(f"\nRunning chunked (chunk_size={cs}) ...") + t_mean, t_std = chunked_spmm(A, X, W, chunk_size=cs, repeat=repeat) + speedup = t_base_mean / t_mean if t_mean>0 else float('nan') + # print(f"Chunk {cs} avg: {t_mean*1000:.3f} ms (std {t_std*1000:.3f} ms) | speedup: {speedup:.3f}x") + results.append((cs, t_mean, t_std, speedup)) + return (t_base_mean, t_base_std, results) + + base_mean, base_std, details = test_chunked_variants(X, adj_gp, in_channels, out_channels, + chunk_sizes=[16,32,64,128,256,512,1024], repeat=20) + print(f"network:{dataset_name}") + print(f"Baseline {base_mean*1000:.3f} ms") + for cs, t_mean, t_std, speedup in details: + print(f"chunk={cs}: {t_mean*1000:.3f} ms | speedup {speedup:.3f}x") diff --git a/easygraph/nn/tests/GCN_TESTs/test_NDP.py b/easygraph/nn/tests/GCN_TESTs/test_NDP.py new file mode 100644 index 00000000..1782d50e --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_NDP.py @@ -0,0 +1,123 @@ +import torch +from torch_sparse import SparseTensor +import time +import os +import random +import numpy as np +import scipy.sparse as sp +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +from torch_geometric.datasets import Coauthor, Planetoid +import easygraph as eg +transform = T.NormalizeFeatures() + +# =========== 配置 =========== +DEVICE = torch.device('cpu') +# NUM_THREADS = 32 # 根据你机器调整 +# os.environ["OMP_NUM_THREADS"] = str(NUM_THREADS) +# os.environ["MKL_NUM_THREADS"] = str(NUM_THREADS) +# torch.set_num_threads(NUM_THREADS) +random.seed(42) +np.random.seed(42) +torch.manual_seed(42) + +def spmm_dim_partition(adj_t: SparseTensor, x: torch.Tensor, weight: torch.Tensor, + dim_block: int = 64): + N, Fin = x.shape + Fout = weight.shape[1] + out = torch.zeros(N, Fout, dtype=x.dtype) + + # 分块计算 + for d0 in range(0, Fout, dim_block): + d1 = min(Fout, d0 + dim_block) + xw_block = x @ weight[:, d0:d1] + out[:, d0:d1] = adj_t.matmul(xw_block) + return out + +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + ('ogb', 'ogbn-arxiv'), + +] + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +if __name__ == "__main__": + + _N = 20 + + for backend_type, dataset_name in DATASETS: + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/tmp/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/tmp/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + edge_index = data.edge_index + num_nodes = data.num_nodes + X = data.x.float().to(DEVICE) + in_channels = X.shape[1] + if dataset_name == 'ogbn-arxiv': + out_channels = 256 + else: + out_channels = 16 + +# =========== 构建归一化稀疏矩阵 A = D^{-1/2} A D^{-1/2} =========== + row = edge_index[0].cpu().numpy() + col = edge_index[1].cpu().numpy() + # compute degree and normalization values (like GCN) + deg = np.zeros(num_nodes, dtype=np.float32) + for r in row: + deg[r] += 1 + deg_inv_sqrt = np.zeros_like(deg) + nz = deg > 0 + deg_inv_sqrt[nz] = 1.0 / np.sqrt(deg[nz]) + val = deg_inv_sqrt[row] * deg_inv_sqrt[col] + val_torch = torch.tensor(val, dtype=torch.float32) + + adj_t = SparseTensor( + row=torch.tensor(row, dtype=torch.long), + col=torch.tensor(col, dtype=torch.long), + value=val_torch, + sparse_sizes=(num_nodes, num_nodes) + ) + + W = torch.randn(in_channels, out_channels, dtype=torch.float32) + + T_B = [] + T_P = [] + for _ in range(_N): + # Baseline: 全量计算 + t1 = time.perf_counter() + y_baseline = adj_t.matmul(X @ W) + t2 = time.perf_counter() + + # 分特征维度计算 + t3 = time.perf_counter() + y_part = spmm_dim_partition(adj_t, X, W, dim_block=32) + t4 = time.perf_counter() + + T_B.append(t2-t1) + T_P.append(t4-t3) + + + print(f'dataset:{dataset_name}') + # print(f'TB:{T_B}') + # print(f'TB:{T_P}') + print(f"Baseline time: {np.mean(T_B) :.4f}s") + print(f"Dim-partitioned time: {np.mean(T_P):.4f}s") + print(f"Speedup: { np.mean(T_B) / np.mean(T_P):.2f}x") + print("Result match:", torch.allclose(y_baseline, y_part, atol=1e-5)) diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv.py new file mode 100644 index 00000000..93e66a09 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv.py @@ -0,0 +1,372 @@ +import os + +# ------------------- 引入库 --------------------- +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +# from nodevectors import Node2Vec +from txtReader import TxtGraphReader +# torch.set_num_threads(32) +print(torch.__config__.show()) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 200 +# HIDDEN_DIM = 16 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 2 + +DATASETS = [ + ('Coauthor', 'CS'), + ('Coauthor', 'Physics'), + ('Planetoid', 'Cora'), + ('Planetoid', 'Citeseer'), + ('Planetoid', 'PubMed'), + # ('ogb', 'ogbn-arxiv'), + # ('txt', 'Web_BerkStan'), + # ('txt', 'soc-pokec'), + # ('txt', 'Reddit'), + # # ('ogb', 'ogbn-mag'), + # ('ogb', 'ogbn-products'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import easygraph as eg +import torch + +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +# class GCN(nn.Module): +# def __init__(self, in_channels, hidden_channels, out_channels, dropout): +# super(GCN, self).__init__() +# self.gcn1 = eg.GCNConv(in_channels, hidden_channels) +# self.gcn2 = eg.GCNConv(hidden_channels, out_channels) +# self.dropout = dropout + + +# def forward(self, x, g): +# x = self.gcn1(x, g) +# x = F.relu(x) +# x = F.dropout(x, p=self.dropout, training=self.training) +# x = self.gcn2(x, g) +# return x + +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout, nparts: int=32): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + self.nparts = nparts + self._graph_partition = None + + def forward(self, x, g): + # g.build_adj_gp(nparts=self.nparts) + # x = x[g.cache['gp_perm']] + x = self.gcn1(x, g) + x = F.relu(x) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + # x = x[g.cache['gp_inv_perm']] + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +Result_Chart = {} + +def Show_Result(dataset_name, RR, All_total_train_time, All_epoch_times, All_forward_times, All_backward_times, peak_memory_mb, All_test_acc, ALL_f1): + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒 ({[round(_, 3) for _ in All_total_train_time]})") + print(f"🔥 总训练时间标准差: {statistics.stdev(All_total_train_time):.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") + Result_Chart[dataset_name] = { + "Total_Train_Time": sum(All_total_train_time)/RR, + "Std_Train_Time": statistics.stdev(All_total_train_time), + "Accuracy": sum(All_test_acc)/RR, + "Std_Accuracy": statistics.stdev(All_test_acc), + } + +def main(): + root = "./dataset/" + for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=root, name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=root, name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root=root) + elif backend_type == 'txt': + dataset = TxtGraphReader(root=root, name=dataset_name) + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + elif backend_type in 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + elif backend_type == 'txt': + # 自定义划分方式 + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + # 调整隐藏层 + if dataset_name == 'ogbn-arxiv': + HIDDEN_DIM = 256 + else: + HIDDEN_DIM = 16 + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + + # -------------------- 移动数据到设备 -------------------- + try: + data = data.to(DEVICE) + except: + pass + + # -------------------- 节点特征 和 标签 -------------------- + try: + num_node_features = dataset.num_node_features + except: + num_node_features = data.x.shape[1] + try: + num_classes = dataset.num_classes + except: + num_classes = int(data.y.max().item()) + 1 + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + x_orig = data.x.clone() + y_orig = data.y.clone() + train_mask_orig = train_mask.clone() + val_mask_orig = val_mask.clone() + test_mask_orig = test_mask.clone() + for R in tqdm(range(RR + 1)): + + if 'adj_gp' in g.cache: + del g.cache['adj_gp'] # 删除 adj_gp + g.build_adj_gp(nparts=32) # 重新分割 + + # 3️⃣ 获取新的 perm 并应用到数据和 mask 上 + perm = g.cache['gp_perm'] + # 每次循环用原始数据生成 perm 后的新数据 + data.x = x_orig[perm] + data.y = y_orig[perm] + train_mask = train_mask_orig[perm] + val_mask = val_mask_orig[perm] + test_mask = test_mask_orig[perm] + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + # -------------------- 初始化模型 -------------------- + model = GCN(num_node_features, HIDDEN_DIM, num_classes, dropout=DROPOUT).to(DEVICE) + # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) + # model = torch.compile(model, backend="inductor") + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + # for epoch in tqdm(range(1, EPOCHS+1)): + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + start_fwd = time.perf_counter() + out = model(data.x, g) + end_fwd = time.perf_counter() + + # loss 计算 + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + # if loss_val.item() < best_val_loss: + # best_val_loss = loss_val.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") + + # -------------------- 输出最终结果 -------------------- + # print(f'\nTrain Loss = {LOSS_LIST}') + # print(f'Valid Loss = {LOSS_LIST_VALID}') + # print(f'Test Loss = {LOSS_LIST_TEST}') + + Show_Result(dataset_name, RR, All_total_train_time[-RR:], All_epoch_times[-RR:], All_forward_times[-RR:], All_backward_times[-RR:], peak_memory_mb, All_test_acc[-RR:], ALL_f1[-RR:]) + # print("\n======= 统一测试结果汇总 =======") + # print(f"🔹 后端框架: {BACKEND}") + # print(f"🔹 数据集: {dataset_name}") + # print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒 ({[round(_, 3) for _ in All_total_train_time]})") + # print(f"🔥 总训练时间标准差: {statistics.stdev(All_total_train_time):.3f} 秒") + # print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + # print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + # print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + # # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + # print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + # print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + # print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + # print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") + + + +if __name__ == "__main__": + main() + import pandas as pd + df = pd.DataFrame(Result_Chart).T + df.to_csv("gcn_easygraph_results.csv") \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py new file mode 100644 index 00000000..48d34e9a --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py @@ -0,0 +1,346 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import statistics + +# -------------------- 配置 -------------------- +BACKEND = 'Cogdl' +DEVICE = torch.device('cpu') +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 16 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 10 + +DATASETS = [ + ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), +# ('Planetoid', 'Cora'), +# ('Planetoid', 'Citeseer'), +# ('Planetoid', 'PubMed'), +# # ('ogb', 'ogbn-arxiv'), +# ('ogb', 'ogbn-products') +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +import torch_geometric.transforms as T +from cogdl.models.nn import GCN +import torch.nn.functional as F +from cogdl.data import Graph +from ogb.nodeproppred import PygNodePropPredDataset + +# 解决 torch.load 报错 +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +transform = T.NormalizeFeatures() + + +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + g = Graph( + x=data.x, + edge_index=data.edge_index, + y=data.y, + num_nodes=num_nodes + ) + # g.sym_norm() + + # -------------------- 数据集划分 -------------------- + if backend_type in 'Planetoid': # 使用原生 mask + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + print(f"Train nodes: {train_mask.sum().item()}") + print(f"Valid nodes: {val_mask.sum().item()}") + print(f"Test nodes: {test_mask.sum().item()}") + elif backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + + for split in ['train', 'valid', 'test']: + idx = split_idx[split] + print(f"{split} nodes: {len(idx)}") + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + data = data.to(DEVICE) + + + All_forward_times = [] + All_backward_times = [] + All_epoch_times = [] + All_total_train_time = [] + All_test_acc = [] + ALL_f1 = [] + + + for R in tqdm(range(RR)): + # -------------------- 设备转移 -------------------- + model = GCN( + in_feats=dataset.num_node_features, + hidden_size=HIDDEN_DIM, + out_feats=dataset.num_classes, + num_layers=2, + dropout=DROPOUT, + activation="relu", + residual=False, + norm=None + ).to(DEVICE) + + # -------------------- 训练配置 -------------------- + optimizer = torch.optim.Adam([ + {'params': model.layers[0].parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.layers[1].parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + process = psutil.Process(os.getpid()) + memory_usage_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = memory_usage_mb + + forward_times = [] + backward_times = [] + epoch_times = [] + + LOSS_LIST = [] + LOSS_LIST_TEST = [] + LOSS_LIST_VALID = [] + + best_val_loss = float('inf') + early_stop_counter = 0 + + train_start = time.time() + for epoch in range(1, EPOCHS + 1): + model.train() + optimizer.zero_grad() + epoch_start = time.time() + + # 前向传播 + start_fwd = time.time() + out = model(g) + end_fwd = time.time() + + # 计算loss + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + loss_valid = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + + # 反向传播 + start_bwd = time.time() + loss.backward() + optimizer.step() + end_bwd = time.time() + + epoch_end = time.time() + + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(epoch_end - epoch_start) + + LOSS_LIST.append(round(loss.item(),3)) + LOSS_LIST_TEST.append(round(loss_test.item(),3)) + LOSS_LIST_VALID.append(round(loss_valid.item(),3)) + + # # early stopping + # if loss_valid.item() < best_val_loss: + # best_val_loss = loss_valid.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # # print(f"Early stopping at epoch {epoch}. Validation loss has not decreased for {EARLY_STOP_WINDOW} epochs.") + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.time() + total_train_time = train_end - train_start + + # -------------------- 测试函数 -------------------- + @torch.no_grad() + def evaluate(model, data, mask_key='test_mask'): + model.eval() + out = model(g) + mask = getattr(data, mask_key) + pred = out.argmax(dim=1) + correct = (pred[mask] == data.y[mask].squeeze()).sum() + acc = int(correct) / int(mask.sum()) + macro_f1 = f1_score(data.y[mask].squeeze(), pred[mask], average='macro') + return acc, macro_f1 + + test_acc, f1 = evaluate(model, data) + + # -------------------- 结果输出 -------------------- + avg_fwd = sum(forward_times) / len(forward_times) + avg_bwd = sum(backward_times) / len(backward_times) + avg_epoch = sum(epoch_times) / len(epoch_times) + + All_forward_times.append(avg_fwd) + All_backward_times.append(avg_bwd) + All_epoch_times.append(avg_epoch) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(f1) + + # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') + # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') + # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") + +# dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='data/OGB') + +# import torch +# import torch.nn.functional as F +# from ogb.nodeproppred import PygNodePropPredDataset +# from torch.serialization import safe_globals +# from cogdl.data import Graph +# from cogdl.models.nn import GCN +# from cogdl.trainer import Trainer +# import torch_geometric +# # ------------------------------- +# # 1. 安全加载 PyG 数据集 (Python 3.10+ / PyTorch 2.6+) +# # ------------------------------- +# with safe_globals([torch_geometric.data.data.DataEdgeAttr]): +# dataset = PygNodePropPredDataset(name="ogbn-arxiv", root="data/OGB") + +# data = dataset[0] +# split_idx = dataset.get_idx_split() + +# x = data.x +# y = data.y.squeeze() +# num_nodes = data.num_nodes + +# # ------------------------------- +# # 2. 构建 EasyGraph 图对象 +# # ------------------------------- +# import easygraph as eg +# g = eg.Graph() +# g.add_nodes_from(range(num_nodes)) +# edge_index = data.edge_index +# edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) +# g.add_edges(edge_list) + +# # ------------------------------- +# # 3. 转换划分为 mask +# # ------------------------------- +# train_mask = torch.zeros(num_nodes, dtype=torch.bool) +# train_mask[split_idx['train']] = True +# val_mask = torch.zeros(num_nodes, dtype=torch.bool) +# val_mask[split_idx['valid']] = True +# test_mask = torch.zeros(num_nodes, dtype=torch.bool) +# test_mask[split_idx['test']] = True + +# # ------------------------------- +# # 4. 初始化 CogDL GCN +# # ------------------------------- +# device = torch.device("cuda" if torch.cuda.is_available() else "cpu") +# model = GCN( +# in_features=x.size(1), +# hidden_size=128, +# out_features=dataset.num_classes, +# num_layers=2, +# dropout=0.5 +# ).to(device) + +# x = x.to(device) +# y = y.to(device) + +# train_idx = torch.where(train_mask)[0].to(device) +# val_idx = torch.where(val_mask)[0].to(device) +# test_idx = torch.where(test_mask)[0].to(device) + +# # ------------------------------- +# # 5. 初始化 Trainer +# # ------------------------------- +# trainer = Trainer( +# model=model, +# task="node_classification", +# epochs=200, +# lr=0.01, +# weight_decay=0 +# ) + +# # ------------------------------- +# # 6. 训练 +# # ------------------------------- +# trainer.train(g, y=y, train_idx=train_idx, val_idx=val_idx, test_idx=test_idx) + +# # ------------------------------- +# # 7. 评估测试集准确率 +# # ------------------------------- +# results = trainer.evaluate(g, y, {'train': train_idx, 'valid': val_idx, 'test': test_idx}) +# print("Final Accuracy:", results) \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py new file mode 100644 index 00000000..c658b740 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py @@ -0,0 +1,275 @@ +import os + +# ------------------- 引入库 --------------------- +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +torch.set_num_threads(32) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 200 +# HIDDEN_DIM = 16 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 11 +Nparts = 32 +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products') +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import easygraph as eg +import torch +import sys +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +# class GCN(nn.Module): +# def __init__(self, in_channels, hidden_channels, out_channels, dropout): +# super(GCN, self).__init__() +# self.gcn1 = eg.GCNConv(in_channels, hidden_channels) +# self.gcn2 = eg.GCNConv(hidden_channels, out_channels) +# self.dropout = dropout + + +# def forward(self, x, g): +# x, t1 = self.gcn1(x, g) +# x = F.relu(x) +# x = F.dropout(x, p=self.dropout, training=self.training) +# x, t2 = self.gcn2(x, g) +# return x , [t1, t2] + + +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout, nparts: int=32): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + self.nparts = nparts + self._graph_partition = None + + def forward(self, x, g): + g.build_adj_gp(nparts=self.nparts) + x = x[g.cache['gp_perm']] + x = self.gcn1(x, g) + x = F.relu(x) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + x = x[g.cache['gp_inv_perm']] + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- + +def main(): + for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/autodl-tmp/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/root/autodl-tmp/data/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/autodl-tmp/data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + # -------------------- 数据类型修正 -------------------- + + data.x = data.x.float() + data.y = data.y.long() + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + elif backend_type in 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + # 调整隐藏层 + if dataset_name == 'ogbn-arxiv': + HIDDEN_DIM = 256 + else: + HIDDEN_DIM = 16 + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + print("data load finished!") + # g.build_adj_gp(nparts=32) + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + # -------------------- 初始化模型 -------------------- + model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) + # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) + # model = torch.compile(model, backend="inductor") + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + for epoch in tqdm(range(1, EPOCHS+1)): + model.train() + optimizer.zero_grad() + start_fwd = time.perf_counter() + out = model(data.x, g) + end_fwd = time.perf_counter() + + # loss 计算 + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + # if loss_val.item() < best_val_loss: + # best_val_loss = loss_val.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time[1:])/(RR-1):.3f} 秒 ({[round(_, 3) for _ in All_total_train_time]})") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py new file mode 100644 index 00000000..85b2ae01 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py @@ -0,0 +1,233 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm + +# -------------------- 配置 -------------------- +BACKEND = 'DGL' +# DATASET = 'ogbn-products' +DEVICE = torch.device('cpu') +SEED = 42 +EPOCHS = 500 +HIDDEN_DIM = 8 + + +DATASETS = [ + ('Coauthor', 'CS'), + ('Coauthor', 'Physics'), + ('Planetoid', 'Cora'), + ('Planetoid', 'PubMed'), + # ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products') +] + + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- + +from torch_geometric.datasets import Coauthor, Planetoid +import torch_geometric.transforms as T +import dgl +import dgl.nn.pytorch as dglnn +from ogb.nodeproppred import PygNodePropPredDataset + +# 解决 torch.load 报错 +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +transform = T.NormalizeFeatures() +# 数据集选择(自行切换) +# dataset = Coauthor(root='data/Coauthor', name='CS') +# dataset = Coauthor(root='data/Coauthor', name='Physics') +# dataset = Planetoid(root='/tmp/Cora', name='Cora') +# dataset = Planetoid(root='/tmp/PubMed', name='PubMed', transform=transform) +# dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='data/OGB') +# dataset = PygNodePropPredDataset(name='ogbn-products', root='data/OGB') +# data = dataset[0] + +# 构建 DGL 图(用 PyG 的 edge_index 转换) + + + +# 模型定义 +class GCN(torch.nn.Module): + def __init__(self, in_dim, hidden_dim, out_dim): + super().__init__() + self.conv1 = dglnn.GraphConv(in_dim, hidden_dim) + self.conv2 = dglnn.GraphConv(hidden_dim, out_dim) + + def forward(self, graph, features): + x = F.relu(self.conv1(graph, features)) + x = F.dropout(x, p=0.6, training=self.training) + x = self.conv2(graph, x) + return x + +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + + # 原始边 + src, dst = data.edge_index + g = dgl.graph((src, dst), num_nodes=data.num_nodes) + g = dgl.add_self_loop(g) + g = g.to(DEVICE) + g.ndata['feat'] = data.x.to(DEVICE) + + # -------------------- 数据预处理(统一) -------------------- + num_nodes = data.num_nodes + indices = torch.randperm(num_nodes) + train_idx = indices[:int(0.6 * num_nodes)] + val_idx = indices[int(0.6 * num_nodes):int(0.8 * num_nodes)] + test_idx = indices[int(0.8 * num_nodes):] + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[train_idx] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[val_idx] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[test_idx] = True + + + g.ndata['label'] = data.y.to(DEVICE) + g.ndata['train_mask'] = train_mask.to(DEVICE) + g.ndata['val_mask'] = val_mask.to(DEVICE) + g.ndata['test_mask'] = test_mask.to(DEVICE) + + + All_forward_times = [] + All_backward_times = [] + All_epoch_times = [] + All_total_train_time = [] + All_test_acc = [] + ALL_f1 = [] + RR = 50 + + for R in tqdm(range(RR)): + # -------------------- 设备转移 -------------------- + model = GCN(g.ndata['feat'].shape[1], HIDDEN_DIM, dataset.num_classes).to(DEVICE) + + # -------------------- 训练配置 -------------------- + optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=5e-4) + criterion = torch.nn.CrossEntropyLoss() + + process = psutil.Process(os.getpid()) + memory_usage_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = memory_usage_mb + + forward_times = [] + backward_times = [] + epoch_times = [] + + # -------------------- 训练循环 -------------------- + LOSS_LIST = [] + LOSS_LIST_TEST = [] + LOSS_LIST_VALID = [] + + train_start = time.time() + for epoch in range(1, EPOCHS + 1): + model.train() + optimizer.zero_grad() + epoch_start = time.time() + + # 前向传播 + start_fwd = time.time() + out = model(g, g.ndata['feat']) + end_fwd = time.time() + + # 计算loss + loss = criterion(out[g.ndata['train_mask']], g.ndata['label'][g.ndata['train_mask']].squeeze()) + loss_test = criterion(out[g.ndata['test_mask']], g.ndata['label'][g.ndata['test_mask']].squeeze()) + loss_valid = criterion(out[g.ndata['val_mask']], g.ndata['label'][g.ndata['val_mask']].squeeze()) + + # 反向传播 + start_bwd = time.time() + loss.backward() + optimizer.step() + end_bwd = time.time() + + epoch_end = time.time() + + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(epoch_end - epoch_start) + + LOSS_LIST.append(round(loss.item(),3)) + LOSS_LIST_TEST.append(round(loss_test.item(),3)) + LOSS_LIST_VALID.append(round(loss_valid.item(),3)) + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.time() + total_train_time = train_end - train_start + + # -------------------- 测试函数 -------------------- + @torch.no_grad() + def evaluate(model, data, mask_key='test_mask'): + model.eval() + out = model(g, g.ndata['feat']) + mask = g.ndata[mask_key] + pred = out.argmax(dim=1) + correct = (pred[mask] == g.ndata['label'][mask].squeeze()).sum() + acc = int(correct) / int(mask.sum()) + macro_f1 = f1_score(g.ndata['label'][mask].squeeze(), pred[mask], average='macro') + return acc, macro_f1 + + test_acc, f1 = evaluate(model, data) + + # -------------------- 结果输出 -------------------- + avg_fwd = sum(forward_times) / len(forward_times) + avg_bwd = sum(backward_times) / len(backward_times) + avg_epoch = sum(epoch_times) / len(epoch_times) + + All_forward_times.append(avg_fwd) + All_backward_times.append(avg_bwd) + All_epoch_times.append(avg_epoch) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(f1) + + # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') + # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') + # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') + + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py new file mode 100644 index 00000000..94544779 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py @@ -0,0 +1,215 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +from easygraph.utils.Effective_R import EffectiveResistance +from easygraph.utils.GraphSampler import degree_community_sampling +torch.set_num_threads(30) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 512 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 50 + +DATASETS = [ + ('Yelp', 'Yelp'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Yelp, Reddit, Flickr +import torch_geometric.transforms as T +import easygraph as eg + +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + + def forward(self, x, g): + x = F.relu(self.gcn1(x, g)) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + return x + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Yelp': + dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') + elif backend_type == 'Reddit': + dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') + elif backend_type == 'Flickr': + dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.float() # 多标签任务 + + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + + print('开始采样') + g_s, x_s, sampled_nodes_tensor = degree_community_sampling( + data.edge_index, data.x, data.y, num_nodes, sample_ratio=0.4, + min_nodes=150, alpha=0.65, random_ratio=0.08, bridge_ratio=0.1, k=20, nodes_per_class=17) + + print('采样完成') + print(f'节点数量: {len(g_s.nodes)}, 边数:{len(g_s.edges)}') + + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + x_s = x_s.to(DEVICE) + + # -------------------- 构建训练 mask 子集 -------------------- + train_mask_sub = data.train_mask[sampled_nodes_tensor] + val_mask_sub = data.val_mask[sampled_nodes_tensor] + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + + # -------------------- 初始化模型 -------------------- + model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = nn.BCEWithLogitsLoss() # 多标签损失 + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + train_start = time.perf_counter() + + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(x_s, g_s) + end_fwd = time.perf_counter() + + # -------------------- 子图 loss -------------------- + loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub]) + + # -------------------- 全图验证 -------------------- + model.eval() + with torch.no_grad(): + out_full = model(data.x, g) + loss_val = criterion(out_full[data.val_mask], data.y[data.val_mask]) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + + # if loss_val.item() < best_val_loss: + # best_val_loss = loss_val.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + prob = torch.sigmoid(out) # logits -> 概率 + pred = (prob > 0.5).int() + + test_y = data.y[data.test_mask].int() + test_pred = pred[data.test_mask] + + # Accuracy (每个标签独立平均) + test_acc = (test_pred == test_y).float().mean().item() + macro_f1 = f1_score(test_y.cpu().numpy(), test_pred.cpu().numpy(), average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # -------------------- 输出最终结果 -------------------- + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py new file mode 100644 index 00000000..f6fe2383 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py @@ -0,0 +1,272 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +from easygraph.utils.Effective_R import EffectiveResistance +from easygraph.utils.GraphSampler import FERGraphSampler +# from easygraph.utils.GraphSampler import FullGraphHybridSampler +torch.set_num_threads(30) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 256 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 50 + +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + # ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products') + +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +def feature_similarity(x, edge_index): + src, dst = edge_index + sim = F.cosine_similarity(x[src], x[dst]) + sim = (sim + 1.0) / 2.0 # 归一化到 [0,1] + return sim + +def FER_score(r_ij, s_ij, alpha=1.0, beta=1.0): + score = (r_ij ** alpha) * (s_ij ** beta) + return score / (score.max() + 1e-12) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import easygraph as eg + +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + + def forward(self, x, g): + x = F.relu(self.gcn1(x, g)) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + elif backend_type in 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + + er = EffectiveResistance(edge_index, num_nodes) + resistance_dict = er.compute_resistance_dict() + r_ij = torch.tensor([1 / (resistance_dict[(u,v)] + 1e-12) for u, v in edge_index.T.tolist()]) + r_ij = r_ij / r_ij.max() + + s_ij = feature_similarity(data.x, edge_index) + FER = FER_score(r_ij, s_ij, alpha=1.0, beta=1.0) + # sample_g = FERGraphSampler(edge_index, num_nodes, FER) + # sg = FullGraphHybridSampler(edge_index, num_nodes, 2) + # sample_g = sg(data.x) + # print(len(sample_g.edges)) + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + + sample_g = FERGraphSampler(edge_index, num_nodes, FER) + # -------------------- 初始化模型 -------------------- + model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) + # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(data.x, sample_g) + end_fwd = time.perf_counter() + + # loss 计算 + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + if loss_val.item() < best_val_loss: + best_val_loss = loss_val.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") + + # -------------------- 输出最终结果 -------------------- + # print(f'\nTrain Loss = {LOSS_LIST}') + # print(f'Valid Loss = {LOSS_LIST_VALID}') + # print(f'Test Loss = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py new file mode 100644 index 00000000..0e9d3517 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py @@ -0,0 +1,314 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +from easygraph.utils.Effective_R import EffectiveResistance +# from easygraph.utils.GraphSampler import FERGraphSampler +from easygraph.utils.GraphSampler import degree_community_sampling +torch.set_num_threads(30) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 256 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 1 + +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products'), + # ('ogb2', 'ogbn-mag'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +# def feature_similarity(x, edge_index): +# src, dst = edge_index +# sim = F.cosine_similarity(x[src], x[dst]) +# sim = (sim + 1.0) / 2.0 # 归一化到 [0,1] +# return sim + +# def FER_score(r_ij, s_ij, alpha=1.0, beta=1.0): +# score = (r_ij ** alpha) * (s_ij ** beta) +# return score / (score.max() + 1e-12) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr, Amazon +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import easygraph as eg + +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + + def forward(self, x, g): + x = F.relu(self.gcn1(x, g)) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='data/OGB') + elif backend_type =='ogb2': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/autodl-tmp/zss/data/OGB') + elif backend_type == 'Reddit': + dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') + elif backend_type == 'Flickr': + dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') + elif backend_type == 'Yelp': + dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + + elif backend_type == 'ogb2': + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + + elif backend_type == 'Planetoid': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + elif backend_type in ['Reddit', 'Flickr', 'Coauthor']: + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + + elif backend_type == 'Yelp': + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + + print('开始采样') + g_s, x_s, sampled_nodes_tensor = degree_community_sampling( + data.edge_index, data.x, data.y, num_nodes, sample_ratio=0.4, + min_nodes=150, alpha=0.65, random_ratio=0.08, bridge_ratio=0.1, k=20, nodes_per_class=17) + + print('采样完成') + print(f'节点数量: {len(g_s.nodes)}, 边数:{len(g_s.edges)}') + + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + + + # er = EffectiveResistance(edge_index, num_nodes) + # resistance_dict = er.compute_resistance_dict() + # r_ij = torch.tensor([1 / (resistance_dict[(u,v)] + 1e-12) for u, v in edge_index.T.tolist()]) + # r_ij = r_ij / r_ij.max() + + # s_ij = feature_similarity(data.x, edge_index) + # FER = FER_score(r_ij, s_ij, alpha=1.0, beta=1.0) + # sample_g = FERGraphSampler(edge_index, num_nodes, FER) + # sg = FullGraphHybridSampler(edge_index, num_nodes, 2) + # sample_g = sg(data.x) + # print(len(sample_g.edges)) + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + x_s = x_s.to(DEVICE) + + # -------------------- 构建训练 mask 子集 -------------------- + train_mask_sub = data.train_mask[sampled_nodes_tensor] + val_mask_sub = data.val_mask[sampled_nodes_tensor] + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + + # sample_g = FERGraphSampler(edge_index, num_nodes, FER) + # -------------------- 初始化模型 -------------------- + model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) + # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + train_start = time.perf_counter() + + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(x_s, g_s) + end_fwd = time.perf_counter() + + # -------------------- 子图 loss -------------------- + loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub].squeeze()) + # loss_val = criterion(out[val_mask_sub], data.y[sampled_nodes_tensor][val_mask_sub].squeeze()) + # loss_test = criterion(out[test_mask_sub], data.y[sampled_nodes_tensor][test_mask_sub].squeeze()) + + # -------------------- 全图验证 -------------------- + model.eval() + with torch.no_grad(): + out_full = model(data.x, g) # 全图前向 + loss_val = criterion(out_full[data.val_mask], data.y[data.val_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + # LOSS_LIST.append(loss.item()) + # LOSS_LIST_VALID.append(loss_val.item()) + # LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + if loss_val.item() < best_val_loss: + best_val_loss = loss_val.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") + + # -------------------- 输出最终结果 -------------------- + # print(f'\nTrain Loss = {LOSS_LIST}') + # print(f'Valid Loss = {LOSS_LIST_VALID}') + # print(f'Test Loss = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py new file mode 100644 index 00000000..91bb2f71 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py @@ -0,0 +1,218 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics +torch.set_num_threads(30) + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 256 +DROPOUT = 0.5 +EARLY_STOP_WINDOW = 10 +RR = 1 + +DATASETS = [ + ('Yelp', 'Yelp'), +] + + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # torch.use_deterministic_algorithms(True) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import easygraph as eg + +transform = T.NormalizeFeatures() + +# -------------------- 定义 GCN -------------------- +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + + def forward(self, x, g): + x = F.relu(self.gcn1(x, g)) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Yelp': + dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') + elif backend_type == 'Reddit': + dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') + elif backend_type == 'Flickr': + dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.float() # 多标签任务 + + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 构建 Easy-Graph 图对象 -------------------- + g = eg.Graph() + edge_index = data.edge_index + edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + # print(len(g.edges)) + + # -------------------- 移动数据到设备 -------------------- + data = data.to(DEVICE) + + # -------------------- 结果存储 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + for R in tqdm(range(RR)): + # -------------------- 初始化模型 -------------------- + model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) + # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + # 每次重复实验初始化 early stopping + best_val_loss = float('inf') + early_stop_counter = 0 + + LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] + forward_times, backward_times, epoch_times = [], [], [] + + process = psutil.Process(os.getpid()) + peak_memory_mb = process.memory_info().rss / 1024 / 1024 + + train_start = time.perf_counter() + + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + + start_fwd = time.perf_counter() + out = model(data.x, g) + end_fwd = time.perf_counter() + + # loss 计算 + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.perf_counter() + loss.backward() + optimizer.step() + end_bwd = time.perf_counter() + + # 记录 + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) + LOSS_LIST.append(loss.item()) + LOSS_LIST_VALID.append(loss_val.item()) + LOSS_LIST_TEST.append(loss_test.item()) + + # early stopping + if loss_val.item() < best_val_loss: + best_val_loss = loss_val.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.perf_counter() + total_train_time = train_end - train_start + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, g) + prob = torch.sigmoid(out) # logits -> 概率 + pred = (prob > 0.5).int() + + test_y = data.y[data.test_mask].int() + test_pred = pred[data.test_mask] + + # Accuracy (每个标签独立平均) + test_acc = (test_pred == test_y).float().mean().item() + macro_f1 = f1_score(test_y.cpu().numpy(), test_pred.cpu().numpy(), average='macro') + + # -------------------- 保存统计结果 -------------------- + All_forward_times.append(sum(forward_times)/len(forward_times)) + All_backward_times.append(sum(backward_times)/len(backward_times)) + All_epoch_times.append(sum(epoch_times)/len(epoch_times)) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") + + # -------------------- 输出最终结果 -------------------- + # print(f'\nTrain Loss = {LOSS_LIST}') + # print(f'Valid Loss = {LOSS_LIST_VALID}') + # print(f'Test Loss = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py new file mode 100644 index 00000000..5518dc0d --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py @@ -0,0 +1,256 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import statistics +from torch_sparse import SparseTensor + +# -------------------- 配置 -------------------- +BACKEND = 'PyG' +DEVICE = torch.device('cpu') +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 256 +DROPOUT = 0.5 +LR = 0.01 +WEIGHT_DECAY = 5e-4 +EARLY_STOP_WINDOW = 10 +RR = 5 + +DATASETS = [ + # ('Coauthor', 'CS'), + # ('Coauthor', 'Physics'), + # ('Planetoid', 'Cora'), + # ('Planetoid', 'Citeseer'), + # ('Planetoid', 'PubMed'), + ('ogb', 'ogbn-arxiv'), + # ('ogb', 'ogbn-products'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from torch_geometric.nn.models import GCN # PyG 封装好的 GCN +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T + +transform = T.NormalizeFeatures() + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + elif backend_type == 'ogb': + dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + data.adj_t = SparseTensor.from_edge_index(data.edge_index, sparse_sizes=(data.num_nodes, data.num_nodes)) + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type in 'Planetoid': # 使用原生 mask + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + print(f"Train nodes: {train_mask.sum().item()}") + print(f"Valid nodes: {val_mask.sum().item()}") + print(f"Test nodes: {test_mask.sum().item()}") + elif backend_type == 'ogb': # OGB 用 get_idx_split() + split_idx = dataset.get_idx_split() + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[split_idx['train']] = True + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask[split_idx['valid']] = True + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask[split_idx['test']] = True + + for split in ['train', 'valid', 'test']: + idx = split_idx[split] + print(f"{split} nodes: {len(idx)}") + elif backend_type == 'Coauthor': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + data = data.to(DEVICE) + + # -------------------- 结果存储 -------------------- + All_forward_times = [] + All_backward_times = [] + All_epoch_times = [] + All_total_train_time = [] + All_test_acc = [] + ALL_f1 = [] + + # -------------------- 重复实验 -------------------- + for R in tqdm(range(RR)): + # -------------------- 设备转移 -------------------- + model = GCN( + in_channels=dataset.num_node_features, + hidden_channels=HIDDEN_DIM, + out_channels=dataset.num_classes, + num_layers=2, + dropout=DROPOUT + ).to(DEVICE) + + # optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) + optimizer = torch.optim.Adam([ + {'params': model.convs[0].parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.convs[1].parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + process = psutil.Process(os.getpid()) + memory_usage_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = memory_usage_mb + + forward_times = [] + backward_times = [] + epoch_times = [] + + LOSS_LIST = [] + LOSS_LIST_TEST = [] + LOSS_LIST_VALID = [] + + best_val_loss = float('inf') + early_stop_counter = 0 + + train_start = time.time() + + for epoch in tqdm(range(1, EPOCHS + 1)): + model.train() + optimizer.zero_grad() + epoch_start = time.time() + + # 前向传播 + start_fwd = time.time() + # out = model(data.x, data.edge_index) + out = model(data.x, data.adj_t) + end_fwd = time.time() + + # 计算 loss + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_valid = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.time() + loss.backward() + optimizer.step() + end_bwd = time.time() + + epoch_end = time.time() + + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(epoch_end - epoch_start) + + LOSS_LIST.append(round(loss.item(), 3)) + LOSS_LIST_VALID.append(round(loss_valid.item(), 3)) + LOSS_LIST_TEST.append(round(loss_test.item(), 3)) + + # early stopping + # if loss_valid.item() < best_val_loss: + # best_val_loss = loss_valid.item() + # early_stop_counter = 0 + # else: + # early_stop_counter += 1 + + # if early_stop_counter >= EARLY_STOP_WINDOW: + # # print(f"Early stopping at epoch {epoch}. Validation loss has not decreased for {EARLY_STOP_WINDOW} epochs.") + # break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.time() + total_train_time = train_end - train_start + + # -------------------- 测试函数 -------------------- + @torch.no_grad() + def evaluate(model, data, mask_key='test_mask'): + model.eval() + out = model(data.x, data.edge_index) + mask = getattr(data, mask_key) + pred = out.argmax(dim=1) + correct = (pred[mask] == data.y[mask].squeeze()).sum() + acc = int(correct) / int(mask.sum()) + macro_f1 = f1_score(data.y[mask].squeeze(), pred[mask], average='macro') + return acc, macro_f1 + + test_acc, f1 = evaluate(model, data) + + avg_fwd = sum(forward_times) / len(forward_times) + avg_bwd = sum(backward_times) / len(backward_times) + avg_epoch = sum(epoch_times) / len(epoch_times) + + All_forward_times.append(avg_fwd) + All_backward_times.append(avg_bwd) + All_epoch_times.append(avg_epoch) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(f1) + + # -------------------- 输出结果 -------------------- + # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') + # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') + # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py new file mode 100644 index 00000000..e5308caa --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py @@ -0,0 +1,216 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import statistics +import torch.nn as nn + +# -------------------- 配置 -------------------- +BACKEND = 'PyG' +DEVICE = torch.device('cpu') +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 16 +DROPOUT = 0.5 +LR = 0.01 +WEIGHT_DECAY = 5e-4 +EARLY_STOP_WINDOW = 10 +RR = 5 + +DATASETS = [ + ('Yelp', 'Yelp'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # if torch.cuda.is_available(): + # torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr +from torch_geometric.nn.models import GCN # PyG 封装好的 GCN +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T + +transform = T.NormalizeFeatures() + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # -------------------- 加载数据集 -------------------- + if backend_type == 'Yelp': + dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') + elif backend_type == 'Reddit': + dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') + elif backend_type == 'Flickr': + dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.float() # 多标签任务 + + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") + + # -------------------- 结果存储 -------------------- + All_forward_times = [] + All_backward_times = [] + All_epoch_times = [] + All_total_train_time = [] + All_test_acc = [] + ALL_f1 = [] + + # -------------------- 重复实验 -------------------- + for R in tqdm(range(RR)): + # -------------------- 设备转移 -------------------- + model = GCN( + in_channels=dataset.num_node_features, + hidden_channels=HIDDEN_DIM, + out_channels=dataset.num_classes, + num_layers=2, + dropout=DROPOUT + ).to(DEVICE) + + # optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) + optimizer = torch.optim.Adam([ + {'params': model.convs[0].parameters(), 'weight_decay': 5e-4}, # 第一层 GCN + {'params': model.convs[1].parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 + ], lr=0.01) + criterion = nn.BCEWithLogitsLoss() # 多标签损失 + + process = psutil.Process(os.getpid()) + memory_usage_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = memory_usage_mb + + forward_times = [] + backward_times = [] + epoch_times = [] + + LOSS_LIST = [] + LOSS_LIST_TEST = [] + LOSS_LIST_VALID = [] + + best_val_loss = float('inf') + early_stop_counter = 0 + + train_start = time.time() + + for epoch in tqdm(range(1, EPOCHS + 1)): + model.train() + optimizer.zero_grad() + epoch_start = time.time() + + # 前向传播 + start_fwd = time.time() + out = model(data.x, data.edge_index) + end_fwd = time.time() + + # 计算 loss + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + loss_valid = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) + loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) + + # 反向传播 + start_bwd = time.time() + loss.backward() + optimizer.step() + end_bwd = time.time() + + epoch_end = time.time() + + forward_times.append(end_fwd - start_fwd) + backward_times.append(end_bwd - start_bwd) + epoch_times.append(epoch_end - epoch_start) + + LOSS_LIST.append(round(loss.item(), 3)) + LOSS_LIST_VALID.append(round(loss_valid.item(), 3)) + LOSS_LIST_TEST.append(round(loss_test.item(), 3)) + + # early stopping + if loss_valid.item() < best_val_loss: + best_val_loss = loss_valid.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + # print(f"Early stopping at epoch {epoch}. Validation loss has not decreased for {EARLY_STOP_WINDOW} epochs.") + break + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + train_end = time.time() + total_train_time = train_end - train_start + + # -------------------- 测试函数 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, data.edge_index) + prob = torch.sigmoid(out) # logits -> 概率 + pred = (prob > 0.5).int() + + test_y = data.y[data.test_mask].int() + test_pred = pred[data.test_mask] + + # Accuracy (每个标签独立平均) + test_acc = (test_pred == test_y).float().mean().item() + macro_f1 = f1_score(test_y.cpu().numpy(), test_pred.cpu().numpy(), average='macro') + + avg_fwd = sum(forward_times) / len(forward_times) + avg_bwd = sum(backward_times) / len(backward_times) + avg_epoch = sum(epoch_times) / len(epoch_times) + + All_forward_times.append(avg_fwd) + All_backward_times.append(avg_bwd) + All_epoch_times.append(avg_epoch) + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + # -------------------- 输出结果 -------------------- + # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') + # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') + # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') + + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 后端框架: {BACKEND}") + print(f"🔹 数据集: {dataset_name}") + print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") + print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") + print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") + print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") + print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") + print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") + print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py b/easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py new file mode 100644 index 00000000..9d1e9efc --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py @@ -0,0 +1,95 @@ +# test_int8_cpu.py +import os +import time +import numpy as np +import torch +from torch_sparse import SparseTensor +from torch_geometric.datasets import Coauthor, Planetoid +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T + +transform = T.NormalizeFeatures() +DEVICE = torch.device('cpu') +torch.set_num_threads(32) + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# 加载数据集 +# dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name='CS') +dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') +data = dataset[0] + +X = data.x.float().to(DEVICE) +edge_index = data.edge_index +num_nodes = data.num_nodes +in_channels = X.shape[1] +out_channels = 256 + +# 构造归一化邻接矩阵 +row = edge_index[0].numpy() +col = edge_index[1].numpy() +deg = np.zeros(num_nodes, dtype=np.float32) +for r in row: + deg[r] += 1 +deg_inv_sqrt = np.zeros_like(deg) +nz = deg > 0 +deg_inv_sqrt[nz] = 1.0 / np.sqrt(deg[nz]) +val = deg_inv_sqrt[row] * deg_inv_sqrt[col] +val_torch = torch.tensor(val, dtype=torch.float32) + +A = SparseTensor( + row=torch.tensor(row, dtype=torch.long), + col=torch.tensor(col, dtype=torch.long), + value=val_torch, + sparse_sizes=(num_nodes, num_nodes) +).to(DEVICE) + +# 测试函数 +def measure_time(A, X, W, repeat=100): + times = [] + for _ in range(repeat): + t0 = time.perf_counter() + _ = A.matmul(X @ W) + t1 = time.perf_counter() + times.append(t1 - t0) + return np.mean(times), np.std(times) + +# ===== FP32 baseline ===== +W32 = torch.randn(in_channels, out_channels, dtype=torch.float32, device=DEVICE) +mean32, std32 = measure_time(A, X, W32) + +# ===== INT8 动态量化 ===== +# PyTorch 量化方式: 先用 nn.Linear 包装,再动态量化 +linear_fp32 = torch.nn.Linear(in_channels, out_channels, bias=False) +linear_fp32.weight.data = W32.t().contiguous() # nn.Linear weight shape: (out_features, in_features) + +# ===== INT8 动态量化 ===== +linear_int8 = torch.quantization.quantize_dynamic( + linear_fp32, {torch.nn.Linear}, dtype=torch.qint8 +) + +# 测试 INT8 +def measure_time_int8(A, X, linear_int8, repeat=100): + times = [] + for _ in range(repeat): + t0 = time.perf_counter() + # 直接访问 weight 属性,不要加括号 + W_int8 = linear_int8.weight.dequantize().t() + _ = A.matmul(X @ W_int8) + t1 = time.perf_counter() + times.append(t1 - t0) + return np.mean(times), np.std(times) + + +mean_int8, std_int8 = measure_time_int8(A, X, linear_int8) + +speedup = mean32 / mean_int8 if mean_int8 > 0 else float('nan') + +print(f"CPU-only INT8 test on {DEVICE}") +print(f"FP32 : {mean32*1000:.3f} ms ± {std32*1000:.3f}") +print(f"INT8 : {mean_int8*1000:.3f} ms ± {std_int8*1000:.3f}") +print(f"Speedup: {speedup:.3f}x") diff --git a/easygraph/nn/tests/GCN_TESTs/txtReader.py b/easygraph/nn/tests/GCN_TESTs/txtReader.py new file mode 100644 index 00000000..e4e597b0 --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/txtReader.py @@ -0,0 +1,42 @@ +import os +import pandas as pd +import numpy as np +from node2vec import Node2Vec +import networkx as nx +import torch + +class TxtDataset: + def __init__(self, X, Y, edge_index=None): + self.x = torch.tensor(X) + self.y = torch.tensor(Y) + self.num_nodes = Y.shape[0] + self.edge_index = edge_index + +def TxtGraphReader(root, name): + path = os.path.join(root, name, name + '.txt') + edges = np.loadtxt(path, comments='#', dtype=int) + unique_edges = set() + for u, v in edges: + if u == v: + continue + if (u,v) not in unique_edges and (v,u) not in unique_edges: + unique_edges.add((u, v)) + + edges = edges.T + + num_nodes = max(edges.flatten()) + 1 + feature_dim = 128 + class_num = 3 + + if os.path.exists(os.path.join(root, name, 'X.npy')) and os.path.exists(os.path.join(root, name, 'Y.npy')): + X = np.load(os.path.join(root, name, 'X.npy')) + Y = np.load(os.path.join(root, name, 'Y.npy')) + else: + X = np.random.uniform(-10, 10, size=(num_nodes, feature_dim)) + Y = np.random.randint(0, class_num, size=(num_nodes, )) + np.save(os.path.join(root, name, 'X.npy'), X) + np.save(os.path.join(root, name, 'Y.npy'), Y) + + dataset = TxtDataset(X=X, Y=Y, edge_index=edges) + + return [dataset] diff --git a/easygraph/nn/tests/test_gatconv.py b/easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag.zip similarity index 100% rename from easygraph/nn/tests/test_gatconv.py rename to easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag.zip diff --git a/easygraph/nn/tests/test_gcnconv.py b/easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag/raw/mag.zip similarity index 100% rename from easygraph/nn/tests/test_gcnconv.py rename to easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag/raw/mag.zip diff --git a/easygraph/nn/tests/autodl-tmp/data/mag/raw/mag.zip b/easygraph/nn/tests/autodl-tmp/data/mag/raw/mag.zip new file mode 100644 index 00000000..e69de29b diff --git a/easygraph/nn/tests/dataset_info.py b/easygraph/nn/tests/dataset_info.py new file mode 100644 index 00000000..307c2f86 --- /dev/null +++ b/easygraph/nn/tests/dataset_info.py @@ -0,0 +1,127 @@ +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import os +from sklearn.metrics import f1_score +from tqdm import tqdm + +# -------------------- 配置 -------------------- +BACKEND = 'PyG' +DEVICE = torch.device('cpu') # 如果有GPU改成 'cuda' +SEED = 42 +EPOCHS = 200 +HIDDEN_DIM = 16 +DROPOUT = 0.5 +LR = 0.01 +WEIGHT_DECAY = 5e-4 +EARLY_STOP_WINDOW = 10 +RR = 1 # 测试先只跑1次重复实验 + +DATASETS = [ + ('Planetoid', 'Cora'), +] + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 包和数据导入 -------------------- +from torch_geometric.datasets import Coauthor, Planetoid +from torch_geometric.nn.models import GCN # PyG封装的GCN +import torch_geometric.transforms as T + +transform = T.NormalizeFeatures() + +# -------------------- 主循环 -------------------- +for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # 加载数据集 + if backend_type == 'Coauthor': + dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = dataset[0] + + # -------------------- 数据类型修正 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + # -------------------- 数据集划分 -------------------- + if backend_type in ['Coauthor', 'Planetoid']: + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + print(f"Train nodes: {train_mask.sum().item()}") + print(f"Valid nodes: {val_mask.sum().item()}") + print(f"Test nodes: {test_mask.sum().item()}") + else: + raise ValueError(f"Unknown dataset type {backend_type}") + + data = data.to(DEVICE) + + # -------------------- 训练循环 -------------------- + for R in range(RR): + model = GCN( + in_channels=dataset.num_node_features, + hidden_channels=HIDDEN_DIM, + out_channels=dataset.num_classes, + num_layers=2, + dropout=DROPOUT + ).to(DEVICE) + + optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) + criterion = torch.nn.CrossEntropyLoss() + + best_val_loss = float('inf') + early_stop_counter = 0 + + for epoch in range(1, EPOCHS + 1): + model.train() + optimizer.zero_grad() + + out = model(data.x, data.edge_index) + loss_train = criterion(out[train_mask], data.y[train_mask].squeeze()) + loss_val = criterion(out[val_mask], data.y[val_mask].squeeze()) + + loss_train.backward() + optimizer.step() + + # early stopping + if loss_val.item() < best_val_loss: + best_val_loss = loss_val.item() + early_stop_counter = 0 + else: + early_stop_counter += 1 + + if early_stop_counter >= EARLY_STOP_WINDOW: + print(f"Early stopping at epoch {epoch}") + break + + # 打印每轮信息 + model.eval() + with torch.no_grad(): + pred = out.argmax(dim=1) + acc_train = (pred[train_mask] == data.y[train_mask].squeeze()).sum().item() / train_mask.sum().item() + acc_val = (pred[val_mask] == data.y[val_mask].squeeze()).sum().item() / val_mask.sum().item() + print(f"Epoch {epoch:03d} | Train Loss: {loss_train.item():.4f} | Val Loss: {loss_val.item():.4f} | Train Acc: {acc_train:.4f} | Val Acc: {acc_val:.4f}") + + # -------------------- 测试 -------------------- + model.eval() + with torch.no_grad(): + out = model(data.x, data.edge_index) + pred = out.argmax(dim=1) + test_acc = (pred[test_mask] == data.y[test_mask].squeeze()).sum().item() / test_mask.sum().item() + macro_f1 = f1_score(data.y[test_mask].squeeze(), pred[test_mask], average='macro') + print(f"Test Accuracy: {test_acc:.4f} | Test Macro-F1: {macro_f1:.4f}") diff --git a/easygraph/nn/tests/draw.py b/easygraph/nn/tests/draw.py new file mode 100644 index 00000000..730a96f0 --- /dev/null +++ b/easygraph/nn/tests/draw.py @@ -0,0 +1,194 @@ +import networkx as nx +import matplotlib.pyplot as plt +import numpy as np +from torch_geometric.datasets import Coauthor, Planetoid, Reddit, Flickr, Yelp, Amazon +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +import torch +import igraph as ig + +# ----------------------------- +# 指标计算函数 +# ----------------------------- +# def degree_heterogeneity(G): +# degrees = np.array([d for _, d in G.degree()]) +# mean_k = degrees.mean() +# mean_k2 = np.mean(degrees ** 2) +# H = mean_k2 / (mean_k ** 2) if mean_k > 0 else 0 +# return H + +# def num_com(G, min_size=10): +# """ +# 使用 igraph 统计 NetworkX 图的社区数Louvain / Multilevel。 +# G: networkx.Graph +# 返回: 社区数量 +# """ +# ig_g = ig.Graph.from_networkx(G) +# communities = ig_g.community_multilevel() +# # 过滤掉太小的社区 +# filtered_coms = [c for c in communities if len(c) >= min_size] +# return len(filtered_coms) + +# def load_snap_graph(dataset_name, root='/root/autodl-tmp/data'): +# path_map = { +# 'com-lj': 'com-lj/soc-LiveJournal1.txt', +# 'com-amazon': 'com-amazon/com-amazon.ungraph.txt', +# 'pokec': 'pokec/soc-pokec-relationships.txt' +# } +# path = f"{root}/{path_map[dataset_name]}" +# G = nx.Graph() +# with open(path, 'r') as f: +# for line in f: +# if line.startswith('#'): # 跳过注释 +# continue +# u, v = map(int, line.strip().split()) +# G.add_edge(u, v) +# return G + +# # ----------------------------- +# # 需要处理的数据集 +# # ----------------------------- +# DATASETS = [ +# ('Coauthor', 'CS'), +# ('Coauthor', 'Physics'), +# ('Planetoid', 'Cora'), +# ('Planetoid', 'Citeseer'), +# ('Planetoid', 'PubMed'), +# ('ogb', 'ogbn-arxiv'), +# ('ogb', 'ogbn-products'), +# ('Reddit', 'Reddit'), +# ('Flickr', 'Flickr'), +# ('Yelp', 'Yelp'), +# ('snap', 'com-lj'), +# ('snap', 'com-amazon'), +# ('snap', 'pokec') +# ] + +# # ----------------------------- +# # 主程序 +# # ----------------------------- +# transform = T.NormalizeFeatures() + +# _load = torch.load +# def load(*args, **kwargs): +# kwargs['weights_only'] = False +# return _load(*args, **kwargs) +# torch.load = load + +# results = [] +# for backend_type, dataset_name in DATASETS: +# print(f"\n================= 数据集: {dataset_name} =================") + +# # -------------------- 加载数据集 -------------------- +# if backend_type == 'Coauthor': +# dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) +# edge_index = dataset[0].edge_index.numpy() +# G = nx.Graph() +# G.add_edges_from(edge_index.T) + +# elif backend_type == 'Planetoid': +# dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) +# edge_index = dataset[0].edge_index.numpy() +# G = nx.Graph() +# G.add_edges_from(edge_index.T) + +# elif backend_type in ['Reddit', 'Flickr', 'Yelp']: +# cls_map = {'Reddit': Reddit, 'Flickr': Flickr, 'Yelp': Yelp} +# dataset = cls_map[backend_type](root=f'/root/autodl-tmp/data/{backend_type}') +# edge_index = dataset[0].edge_index.numpy() +# G = nx.Graph() +# G.add_edges_from(edge_index.T) + +# elif backend_type == 'ogb': +# dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') +# edge_index = dataset[0].edge_index.numpy() +# G = nx.Graph() +# G.add_edges_from(edge_index.T) + +# elif backend_type == 'snap': +# # SNAP 格式的 txt 文件 +# G = load_snap_graph(dataset_name, root='/root/autodl-tmp/data') + +# else: +# raise ValueError(f"Unknown dataset type {backend_type} or dataset {dataset_name}") + +# # -------------------- 计算指标 -------------------- +# print(f'dataset: {dataset_name} load success!') +# H = degree_heterogeneity(G) +# NC = num_com(G) +# N = G.number_of_nodes() + +# results.append((dataset_name, H, NC, N)) +# print(f"{dataset_name}: H={H:.4f}, Num_Communities={NC}, N={N}") + +# N = G.number_of_nodes() # 节点数 +# E = G.number_of_edges() # 边数 + +# # 新增的指标 +# avg_degree = 2 * E / N if N > 0 else 0 # 平均度 +# density = 2 * E / (N * (N - 1)) if N > 1 else 0 # 密度 +# edge_node_ratio = E / N if N > 0 else 0 # 边节点比 + +# print(f"{dataset_name}: " +# f"AvgDeg={avg_degree:.4f}, " +# f"Density={density:.6f}, " +# f"E/N={edge_node_ratio:.4f}" +# f"E={E}") + + +# 度异质性,社区数量,节点数,平均度,密度,边节点比, 边数 + +# results = [ +# ("Cora", 2.7986, 104, 2708, 3.8981, 0.001440, 1.9490, 5278), +# ("Citeseer", 2.4900, 420, 3279, 2.7765, 0.000847, 1.3882, 4552), +# ("CS", 2.0390, 28, 18333, 8.9341, 0.000487, 4.4670, 81894), +# ("PubMed", 3.7317, 47, 19717, 4.4960, 0.000228, 2.2480, 44324), +# ("Physics", 2.1732, 22, 34493, 14.3775, 0.000417, 7.1888, 247962), +# ("Flickr", 10.9179, 25, 89250, 10.0813, 0.000113, 5.0406,449878), +# ("ogbn-arxiv", 26.1964, 143, 169343, 13.6740, 0.000081, 6.8370, 1157799), +# ("Reddit", 3.6429, 26, 232965, 491.9876, 0.002112, 245.9938, 57307946), +# ("com-amazon", 2.5221, 153, 403394, 12.1143, 0.000030, 6.0571, 2443408), +# ("Yelp", 12.9901, 21759, 716847, 20.4669, 0.000029, 10.2335, 7335833), +# ("pokec", 3.4607, 35, 1632803, 27.3174, 0.000017, 13.6587, 22301964), +# ("ogbn-products", 4.5131, 4427, 2400608, 51.5362, 0.000021, 25.7681, 61859140), +# ("com-lj", 9.4915, 5247, 4847571, 17.8933, 0.000004, 8.9467, 7335833) +# ] + +# 度异质性,社区数量(过滤小社区后),节点数,平均度,密度,边节点比, 边数 +results = [ + ("Cora", 2.7986, 24, 2708, 3.8981, 0.001440, 1.9490, 5278), + ("Citeseer", 2.4900, 39, 3279, 2.7765, 0.000847, 1.3882, 4552), + ("CS", 2.0390, 23, 18333, 8.9341, 0.000487, 4.4670, 81894), + ("PubMed", 3.7317, 43, 19717, 4.4960, 0.000228, 2.2480, 44324), + ("Physics", 2.1732, 21, 34493, 14.3775, 0.000417, 7.1888, 247962), + # ("Flickr", 10.9179, 26, 89250, 10.0813, 0.000113, 5.0406,449878), + ("ogbn-arxiv", 26.1964, 103, 169343, 13.6740, 0.000081, 6.8370, 1157799), + ("Reddit", 3.6429, 27, 232965, 491.9876, 0.002112, 245.9938, 57307946), + # ("com-amazon", 2.5221, 164, 403394, 12.1143, 0.000030, 6.0571, 2443408), + ("Yelp", 12.9901, 313, 716847, 20.4669, 0.000029, 10.2335, 7335833), + # ("pokec", 3.4607, 33, 1632803, 27.3174, 0.000017, 13.6587, 22301964), + ("ogbn-products", 4.5131, 204, 2400608, 51.5362, 0.000021, 25.7681, 61859140), + ("com-lj", 9.4915, 1235, 4847571, 17.8933, 0.000004, 8.9467, 7335833) +] + + + +plt.figure(figsize=(8,6)) + +for name, H, NC, N, AvgDeg, Density, ENR, E in results: + plt.scatter(np.log1p(H), np.log10(NC), s=np.sqrt(N)/2, alpha=0.7, label=name) + # plt.scatter(H, np.log10(NC), s=AvgDeg*10, alpha=0.7, label=name) # 放大一点方便区分 + # plt.scatter(H, np.log10(NC), s= np.sqrt(E)/2 , alpha=0.7, label=name) + +plt.xlabel("Degree Heterogeneity (H)") +plt.ylabel("Number of Communities") +plt.title("Real-world Network Analysis") +plt.grid(True, linestyle="--", alpha=0.6) + +# 添加图例 +plt.legend(fontsize=8, ncol=2, markerscale=0.5) + +# 保存图片 +plt.savefig("network_scatter_NC10.png", dpi=300, bbox_inches='tight') +plt.show() + diff --git a/easygraph/nn/tests/network_scatter_NC10.png b/easygraph/nn/tests/network_scatter_NC10.png new file mode 100644 index 0000000000000000000000000000000000000000..fc0104ddbcdbe9046f303924a51762d8658b78a7 GIT binary patch literal 174379 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 z

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%0DsPvP67~CEHY7PFz#L3e1CbEVed&~QxjijFPU(1 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�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 D@?zvV literal 0 HcmV?d00001 From 65c092d3195ed5096932439fa6e6dbdad75ad887 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 7 Dec 2025 23:06:17 -0700 Subject: [PATCH 09/33] change dim --- .../nn/tests/GCN_TESTs/test_gcnconv_dim.py | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py new file mode 100644 index 00000000..f1f9776e --- /dev/null +++ b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py @@ -0,0 +1,277 @@ +import os +import time +import torch +import torch.nn.functional as F +import random +import numpy as np +import psutil +from sklearn.metrics import f1_score +from tqdm import tqdm +import torch.nn as nn +import statistics + +# ------------------- 引入必要的库 --------------------- +import gensim.models.word2vec # 必须显式引入 +from nodevectors import Node2Vec +from scipy.sparse import csr_matrix +import easygraph as eg +from torch_geometric.datasets import Coauthor, Planetoid, Reddit +from torch_geometric.utils import add_self_loops +from ogb.nodeproppred import PygNodePropPredDataset +import torch_geometric.transforms as T +from txtReader import TxtGraphReader + +# ============================================================================== +# 🩹【Gensim 4.0+ 兼容性补丁】(防止 size 参数报错) +# ============================================================================== +def patch_gensim_4_compatibility(): + original_init = gensim.models.Word2Vec.__init__ + def patched_init(self, *args, **kwargs): + if 'size' in kwargs: kwargs['vector_size'] = kwargs.pop('size') + if 'iter' in kwargs: kwargs['epochs'] = kwargs.pop('iter') + original_init(self, *args, **kwargs) + gensim.models.Word2Vec.__init__ = patched_init + +patch_gensim_4_compatibility() +# ============================================================================== + +# -------------------- 配置 -------------------- +BACKEND = 'EasyGraph' +DEVICE = torch.device('cpu') +SEED = 42 +EPOCHS = 200 +DROPOUT = 0.5 +RR = 1 + +DATASETS = [ + ('Coauthor', 'CS'), + ('Coauthor', 'Physics'), + ('Planetoid', 'Cora'), + ('Planetoid', 'Citeseer'), + ('Planetoid', 'PubMed'), +] + +# 【设置】输入特征维度 +NODE2VEC_DIM = 128 +FORCE_NODE2VEC = True + +transform = T.NormalizeFeatures() + +# -------------------- 固定随机种子 -------------------- +def set_seed(seed=42): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + +set_seed(SEED) + +# -------------------- 定义 GCN -------------------- +class GCN(nn.Module): + def __init__(self, in_channels, hidden_channels, out_channels, dropout, nparts: int=32): + super(GCN, self).__init__() + self.gcn1 = eg.GCNConv(in_channels, hidden_channels) + self.gcn2 = eg.GCNConv(hidden_channels, out_channels) + self.dropout = dropout + + def forward(self, x, g): + x = self.gcn1(x, g) + x = F.relu(x) + x = F.dropout(x, p=self.dropout, training=self.training) + x = self.gcn2(x, g) + return x + +_load = torch.load +def load(*args, **kwargs): + kwargs['weights_only'] = False + return _load(*args, **kwargs) +torch.load = load + +# -------------------- 结果展示 -------------------- +Result_Chart = {} +def Show_Result(dataset_name, RR, All_total_train_time, All_epoch_times, All_forward_times, All_backward_times, peak_memory_mb, All_test_acc, ALL_f1): + print("\n======= 统一测试结果汇总 =======") + print(f"🔹 数据集: {dataset_name}") + print(f"🔹 输入维度: {NODE2VEC_DIM}") + print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") + print(f"🎯 F1-score: {sum(ALL_f1)/RR:.4f}") + + Result_Chart[dataset_name] = { + "Input_Dim": NODE2VEC_DIM, + "Total_Train_Time": sum(All_total_train_time)/RR, + "Accuracy": sum(All_test_acc)/RR, + "F1-Score": sum(ALL_f1)/RR + } + +# -------------------- 主函数 -------------------- +def main(): + root = "./dataset/" + + for backend_type, dataset_name in DATASETS: + print(f"\n================= 数据集: {dataset_name} =================") + + # 1. 加载 + if backend_type == 'Coauthor': + dataset = Coauthor(root=root, name=dataset_name) + elif backend_type == 'Planetoid': + dataset = Planetoid(root=root, name=dataset_name, transform=transform) + elif backend_type == 'txt': + dataset = TxtGraphReader(root=root, name=dataset_name) + else: + continue + + data = dataset[0] + num_nodes = data.num_nodes + + # -------------------- 【核心修改】Node2Vec -------------------- + if FORCE_NODE2VEC: + print(f"🔄 [Node2Vec] 正在生成 {NODE2VEC_DIM} 维特征...") + t0 = time.time() + + # A. CSR 矩阵 + row = data.edge_index[0].cpu().numpy() + col = data.edge_index[1].cpu().numpy() + data_ones = np.ones(len(row)) + adj_matrix = csr_matrix((data_ones, (row, col)), shape=(num_nodes, num_nodes)) + + # B. 训练 + n2v_model = Node2Vec(n_components=NODE2VEC_DIM, walklen=20, epochs=1, threads=4) + n2v_model.fit(adj_matrix) + + # C. 提取特征 (修复 KeyError 问题) + embeddings = [] + for i in range(num_nodes): + try: + # 优先尝试 String Key (因为 nodevectors 内部常转为 str) + vec = n2v_model.predict(str(i)) + except KeyError: + try: + # 如果失败,尝试 Int Key + vec = n2v_model.predict(i) + except KeyError: + # 如果还失败 (孤立节点),用 0 填充 + vec = np.zeros(NODE2VEC_DIM) + embeddings.append(vec) + + data.x = torch.tensor(np.array(embeddings), dtype=torch.float) + print(f"✅ 特征生成完毕! 耗时: {time.time()-t0:.2f}s | 维度: {data.x.shape}") + + # -------------------- 后续处理 -------------------- + data.x = data.x.float() + data.y = data.y.long() + + if backend_type == 'Coauthor' or backend_type == 'txt': + torch.manual_seed(42) + indices = torch.randperm(num_nodes) + train_end = int(0.6 * num_nodes) + val_end = int(0.8 * num_nodes) + train_mask = torch.zeros(num_nodes, dtype=torch.bool) + val_mask = torch.zeros(num_nodes, dtype=torch.bool) + test_mask = torch.zeros(num_nodes, dtype=torch.bool) + train_mask[indices[:train_end]] = True + val_mask[indices[train_end:val_end]] = True + test_mask[indices[val_end:]] = True + else: + train_mask = data.train_mask + val_mask = data.val_mask + test_mask = data.test_mask + + data.train_mask = train_mask + data.val_mask = val_mask + data.test_mask = test_mask + + if NODE2VEC_DIM >= 128: + HIDDEN_DIM = 64 + else: + HIDDEN_DIM = 16 + + # 构建 EasyGraph + g = eg.Graph() + edge_list = list(zip(data.edge_index[0].tolist(), data.edge_index[1].tolist())) + g.add_nodes_from(range(num_nodes)) + g.add_edges(edge_list) + + try: + data = data.to(DEVICE) + except: + pass + + num_node_features = data.x.shape[1] + num_classes = int(data.y.max().item()) + 1 + + # -------------------- 训练循环 -------------------- + All_forward_times, All_backward_times, All_epoch_times = [], [], [] + All_total_train_time, All_test_acc, ALL_f1 = [], [], [] + + x_orig = data.x.clone() + y_orig = data.y.clone() + train_mask_orig = train_mask.clone() + val_mask_orig = val_mask.clone() + test_mask_orig = test_mask.clone() + + for R in tqdm(range(RR + 1)): + if 'adj_gp' in g.cache: del g.cache['adj_gp'] + g.build_adj_gp(nparts=32) + perm = g.cache['gp_perm'] + + data.x = x_orig[perm] + data.y = y_orig[perm] + data.train_mask = train_mask_orig[perm] + data.val_mask = val_mask_orig[perm] + data.test_mask = test_mask_orig[perm] + + model = GCN(num_node_features, HIDDEN_DIM, num_classes, dropout=DROPOUT).to(DEVICE) + optimizer = torch.optim.Adam([ + {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, + {'params': model.gcn2.parameters(), 'weight_decay': 0.0} + ], lr=0.01) + criterion = torch.nn.CrossEntropyLoss() + + train_start = time.perf_counter() + process = psutil.Process(os.getpid()) + peak_memory_mb = 0 + + for epoch in range(1, EPOCHS+1): + model.train() + optimizer.zero_grad() + + t_s = time.perf_counter() + out = model(data.x, g) + t_f = time.perf_counter() + + loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) + + t_b_s = time.perf_counter() + loss.backward() + optimizer.step() + t_b_e = time.perf_counter() + + if R > 0: + All_forward_times.append(t_f - t_s) + All_backward_times.append(t_b_e - t_b_s) + All_epoch_times.append(t_b_e - t_s) + + current_memory_mb = process.memory_info().rss / 1024 / 1024 + peak_memory_mb = max(peak_memory_mb, current_memory_mb) + + total_train_time = time.perf_counter() - train_start + + model.eval() + with torch.no_grad(): + out = model(data.x, g) + pred = out.argmax(dim=1) + test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() + macro_f1 = f1_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu(), average='macro') + + if R > 0: + All_total_train_time.append(total_train_time) + All_test_acc.append(test_acc) + ALL_f1.append(macro_f1) + + Show_Result(dataset_name, RR, All_total_train_time, All_epoch_times, All_forward_times, All_backward_times, peak_memory_mb, All_test_acc, ALL_f1) + +if __name__ == "__main__": + main() + import pandas as pd + df = pd.DataFrame(Result_Chart).T + df.to_csv("gcn_node2vec_results.csv") + print("\n所有结果已保存至 gcn_node2vec_results.csv") \ No newline at end of file From 4ecd1d445eceb264cffd38e123f355ee40fd137a Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 14 Dec 2025 22:07:20 -0700 Subject: [PATCH 10/33] add weight --- cpp_easygraph/cpp_easygraph.cpp | 2 +- cpp_easygraph/functions/pagerank/__init__.h | 2 +- cpp_easygraph/functions/pagerank/pagerank.cpp | 75 ++++++++++++++++--- cpp_easygraph/functions/pagerank/pagerank.h | 2 +- easygraph/functions/centrality/pagerank.py | 11 ++- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/cpp_easygraph/cpp_easygraph.cpp b/cpp_easygraph/cpp_easygraph.cpp index fc945577..3e0a4285 100644 --- a/cpp_easygraph/cpp_easygraph.cpp +++ b/cpp_easygraph/cpp_easygraph.cpp @@ -87,7 +87,7 @@ PYBIND11_MODULE(cpp_easygraph, m) { m.def("cpp_effective_size", &effective_size, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); m.def("cpp_efficiency", &efficiency, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); m.def("cpp_hierarchy", &hierarchy, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); - m.def("cpp_pagerank", &_pagerank, py::arg("G"), py::arg("alpha") = 0.85, py::arg("max_iterator") = 500, py::arg("threshold") = 1e-6); + m.def("cpp_pagerank", &_pagerank, py::arg("G"), py::arg("alpha") = 0.85, py::arg("max_iterator") = 500, py::arg("threshold") = 1e-6, py::arg("weight") = py::none()); m.def("cpp_dijkstra_multisource", &_dijkstra_multisource, py::arg("G"), py::arg("sources"), py::arg("weight") = "weight", py::arg("target") = py::none()); m.def("cpp_spfa", &_spfa, py::arg("G"), py::arg("source"), py::arg("weight") = "weight"); m.def("cpp_clustering", &clustering, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none()); diff --git a/cpp_easygraph/functions/pagerank/__init__.h b/cpp_easygraph/functions/pagerank/__init__.h index 3834f1ad..181674b7 100644 --- a/cpp_easygraph/functions/pagerank/__init__.h +++ b/cpp_easygraph/functions/pagerank/__init__.h @@ -2,4 +2,4 @@ #include "../../common/common.h" -py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold); \ No newline at end of file +py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold, py::object weight); \ No newline at end of file diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index 04422ca3..254b8065 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "pagerank.h" #include "../../classes/directed_graph.h" #include "../../classes/graph.h" @@ -15,10 +16,16 @@ struct Page { double newPR, oldPR; }; -py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6) { +py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6, py::object weight=py::none()) { bool is_directed = G.attr("is_directed")().cast(); + bool use_weights = !weight.is_none(); + std::string weight_key = ""; + if (use_weights) { + weight_key = weight_to_string(weight); + } + Graph_L* G_l_ptr = nullptr; int N = 0; @@ -27,18 +34,30 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub N = G_.node.size(); if(G_.linkgraph_dirty){ - G_.linkgraph_structure = graph_to_linkgraph(G_, true, "", true, false); + G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); + G_.linkgraph_dirty = false; + } + + if (G_.linkgraph_structure.degree.size() < N + 1 || G_.linkgraph_structure.head.size() < N + 1) { + G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); G_.linkgraph_dirty = false; } + G_l_ptr = &G_.linkgraph_structure; } else { Graph& G_ = G.cast(); N = G_.node.size(); if(G_.linkgraph_dirty){ - G_.linkgraph_structure = graph_to_linkgraph(G_, false, "", true, false); + G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); + G_.linkgraph_dirty = false; + } + + if (G_.linkgraph_structure.degree.size() < N + 1 || G_.linkgraph_structure.head.size() < N + 1) { + G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); G_.linkgraph_dirty = false; } + G_l_ptr = &G_.linkgraph_structure; } @@ -46,6 +65,21 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub std::vector& outDegree = G_l_ptr->degree; std::vector& head = G_l_ptr->head; + std::vector outWeightSum; + if (use_weights) { + outWeightSum.resize(N + 1, 0.0); + #pragma omp parallel for + for(int i = 1; i < N + 1; ++i) { + if (outDegree[i] > 0) { + double sum_w = 0; + for(int p = head[i]; p != -1; p = E[p].next){ + sum_w += E[p].w; + } + outWeightSum[i] = sum_w; + } + } + } + std::vector page(N+1); #pragma omp parallel for @@ -63,20 +97,41 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub #pragma omp parallel for reduction(+:res) for(int i = 1; i < N+1; ++i) { - if (outDegree[i] == 0) { + bool is_dangling = false; + if (use_weights) { + if (outDegree[i] == 0 || outWeightSum[i] == 0) is_dangling = true; + } else { + if (outDegree[i] == 0) is_dangling = true; + } + + if (is_dangling) { res += page[i].oldPR; } } #pragma omp parallel for schedule(dynamic, 128) for(int i = 1; i < N+1; ++i) { - if (outDegree[i] == 0) continue; - - double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; + if (use_weights) { + if (outDegree[i] == 0 || outWeightSum[i] == 0) continue; + } else { + if (outDegree[i] == 0) continue; + } - for(int p = head[i]; p != -1; p = E[p].next){ - #pragma omp atomic - page[E[p].to].newPR += tmpPR; + if (!use_weights) { + double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; + for(int p = head[i]; p != -1; p = E[p].next){ + #pragma omp atomic + page[E[p].to].newPR += tmpPR; + } + } else { + double basePR = page[i].oldPR * alpha; + double inv_sum = 1.0 / outWeightSum[i]; + + for(int p = head[i]; p != -1; p = E[p].next){ + double contribution = basePR * (E[p].w * inv_sum); + #pragma omp atomic + page[E[p].to].newPR += contribution; + } } } diff --git a/cpp_easygraph/functions/pagerank/pagerank.h b/cpp_easygraph/functions/pagerank/pagerank.h index 3834f1ad..181674b7 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.h +++ b/cpp_easygraph/functions/pagerank/pagerank.h @@ -2,4 +2,4 @@ #include "../../common/common.h" -py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold); \ No newline at end of file +py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold, py::object weight); \ No newline at end of file diff --git a/easygraph/functions/centrality/pagerank.py b/easygraph/functions/centrality/pagerank.py index e1bc767a..4bfec245 100644 --- a/easygraph/functions/centrality/pagerank.py +++ b/easygraph/functions/centrality/pagerank.py @@ -8,7 +8,7 @@ @not_implemented_for("multigraph") @hybrid("cpp_pagerank") -def pagerank(G, alpha=0.85): +def pagerank(G, alpha=0.85, weight=None): """ Returns the PageRank value of each node in G. @@ -20,12 +20,15 @@ def pagerank(G, alpha=0.85): alpha : float The damping factor. Default is 0.85 + weight : None or string, optional (default=None) + If None, all edge weights are considered equal. + Otherwise holds the name of the edge attribute used as weight. """ import numpy as np if len(G) == 0: return {} - M = google_matrix(G, alpha=alpha) + M = google_matrix(G, alpha=alpha, weight=weight) # use numpy LAPACK solver eigenvalues, eigenvectors = np.linalg.eig(M.T) @@ -36,10 +39,10 @@ def pagerank(G, alpha=0.85): return dict(zip(G, map(float, largest / norm))) -def google_matrix(G, alpha): +def google_matrix(G, alpha, weight=None): import numpy as np - M = eg.to_numpy_array(G) + M = eg.to_numpy_array(G, weight=weight) N = len(G) if N == 0: return M From 2e36115c2e3f7c024e3bf8dc5e5184cd52d3c9f3 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 14 Dec 2025 22:15:27 -0700 Subject: [PATCH 11/33] add float weight --- easygraph/functions/centrality/pagerank.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easygraph/functions/centrality/pagerank.py b/easygraph/functions/centrality/pagerank.py index 4bfec245..fa8112fa 100644 --- a/easygraph/functions/centrality/pagerank.py +++ b/easygraph/functions/centrality/pagerank.py @@ -42,7 +42,7 @@ def pagerank(G, alpha=0.85, weight=None): def google_matrix(G, alpha, weight=None): import numpy as np - M = eg.to_numpy_array(G, weight=weight) + M = eg.to_numpy_array(G, weight=weight).astype(float) N = len(G) if N == 0: return M From 4fbc25fd1f5cd447486ffd0b505037c3ae22dcca Mon Sep 17 00:00:00 2001 From: sama Date: Tue, 16 Dec 2025 23:57:59 -0700 Subject: [PATCH 12/33] omp --- .../functions/centrality/closeness.cpp | 292 ++++++++++++++++-- 1 file changed, 261 insertions(+), 31 deletions(-) diff --git a/cpp_easygraph/functions/centrality/closeness.cpp b/cpp_easygraph/functions/centrality/closeness.cpp index 524532fe..63eea703 100644 --- a/cpp_easygraph/functions/centrality/closeness.cpp +++ b/cpp_easygraph/functions/centrality/closeness.cpp @@ -7,44 +7,186 @@ #include "../../classes/graph.h" #include "../../common/utils.h" #include "../../classes/linkgraph.h" -#include "../../classes/segment_tree.cpp" -double closeness_dijkstra(const Graph_L& G_l, const int &S, int cutoff, Segment_tree_zkw& segment_tree_zkw){ - const int dis_inf = 0x3f3f3f3f; +#include +#include +#include +#include + +#ifdef _OPENMP +#include +#endif + +// Heap node: use negative value + max heap to implement min heap +typedef std::pair HeapNode; + +// Optimized adjacency list cache +struct FastAdjCache { + std::vector neighbor_ptrs; + std::vector neighbor_counts; + std::vector weight_ptrs; + std::vector> neighbor_storage; + std::vector> weight_storage; + + void init(int N) { + neighbor_ptrs.resize(N + 1, nullptr); + neighbor_counts.resize(N + 1, 0); + neighbor_storage.resize(N + 1); + } + + void init_with_weights(int N) { + init(N); + weight_ptrs.resize(N + 1, nullptr); + weight_storage.resize(N + 1); + } + + inline void build_if_needed(int u, const std::vector& head, + const std::vector& edges, bool store_weights) { + if (neighbor_ptrs[u] != nullptr) return; + + std::vector& neis = neighbor_storage[u]; + for (int p = head[u]; p != -1; p = edges[p].next) { + neis.push_back(edges[p].to); + if (store_weights) { + weight_storage[u].push_back(edges[p].w); + } + } + + neighbor_counts[u] = neis.size(); + neighbor_ptrs[u] = neis.data(); + if (store_weights) { + weight_ptrs[u] = weight_storage[u].data(); + } + } + + inline int* get_neighbors_ptr(int u) const { return neighbor_ptrs[u]; } + inline int get_neighbor_count(int u) const { return neighbor_counts[u]; } + inline float* get_weights_ptr(int u) const { return weight_ptrs[u]; } +}; + +// BFS implementation - directly use raw adjacency list +double closeness_bfs_direct(const Graph_L& G_l, const int &S, int cutoff, + std::vector& already_counted, + std::vector& queue_storage, + int timestamp) { int N = G_l.n; - segment_tree_zkw.init(N); - std::vector dis(N+1, INT_MAX); const std::vector& E = G_l.edges; const std::vector& head = G_l.head; - int number_connected = 0; + + int nodes_reached = 0; long long sum_dis = 0; - dis[S] = 0; - segment_tree_zkw.change(S, 0); - while(segment_tree_zkw.t[1] != dis_inf) { - int u = segment_tree_zkw.num[1]; - if(u == 0) break; - segment_tree_zkw.change(u, dis_inf); - if (cutoff >= 0 && dis[u] > cutoff){ + + queue_storage.clear(); + + int queue_front = 0; + already_counted[S] = timestamp; + queue_storage.push_back(S); + queue_storage.push_back(0); + + while (queue_front < static_cast(queue_storage.size())) { + int u = queue_storage[queue_front++]; + int actdist = queue_storage[queue_front++]; + + if (cutoff >= 0 && actdist > cutoff) { continue; } - number_connected += 1; - sum_dis += dis[u]; - for(int p = head[u]; p != -1; p = E[p].next) { + + sum_dis += actdist; + nodes_reached++; + + for (int p = head[u]; p != -1; p = E[p].next) { int v = E[p].to; - if(cutoff >= 0 && (dis[u] + E[p].w) > cutoff){ + + if (already_counted[v] == timestamp) { continue; } - if (dis[v] > dis[u] + E[p].w) { - dis[v] = dis[u] + E[p].w; - segment_tree_zkw.change(v, dis[v]); - } + + already_counted[v] = timestamp; + queue_storage.push_back(v); + queue_storage.push_back(actdist + 1); } } - if (number_connected == 1) + + if (nodes_reached == 1) return 0.0; else - return 1.0 * (number_connected - 1) * (number_connected - 1) / ((N - 1) * sum_dis); + return 1.0 * (nodes_reached - 1) * (nodes_reached - 1) / ((N - 1) * sum_dis); +} + +// Check if the graph is unweighted +inline bool is_unweighted_graph(const Graph_L& G_l) { + const std::vector& E = G_l.edges; + for (const auto& edge : E) { + if (std::abs(edge.w - 1.0f) > 1e-9) { + return false; + } + } + return true; +} +// Dijkstra implementation - use on-demand adjacency cache +double closeness_dijkstra_cached(const Graph_L& G_l, const int &S, int cutoff, + std::vector& dist, + std::vector& which, + FastAdjCache& cache, + int timestamp) { + int N = G_l.n; + const std::vector& E = G_l.edges; + const std::vector& head = G_l.head; + + int nodes_reached = 0; + double sum_dis = 0.0; + + std::priority_queue heap; + + dist[S] = 1.0f; + which[S] = timestamp; + heap.push({-1.0f, S}); + + while (!heap.empty()) { + HeapNode top = heap.top(); + heap.pop(); + float mindist = -top.first; + int minnei = top.second; + + if (mindist > dist[minnei]) { + continue; + } + + float actual_dist = mindist - 1.0f; + if (cutoff >= 0 && actual_dist > cutoff) { + continue; + } + + sum_dis += actual_dist; + nodes_reached++; + + cache.build_if_needed(minnei, head, E, true); + + int* neis = cache.get_neighbors_ptr(minnei); + float* ws = cache.get_weights_ptr(minnei); + int nlen = cache.get_neighbor_count(minnei); + + for (int j = 0; j < nlen; j++) { + int to = neis[j]; + float altdist = mindist + ws[j]; + float curdist = dist[to]; + + if (which[to] != timestamp) { + which[to] = timestamp; + dist[to] = altdist; + heap.push({-altdist, to}); + } else if (curdist == 0.0f || altdist < curdist) { + dist[to] = altdist; + heap.push({-altdist, to}); + } + } + } + + if (nodes_reached == 1) + return 0.0; + else + return 1.0 * (nodes_reached - 1) * (nodes_reached - 1) / ((N - 1) * sum_dis); } static py::object invoke_cpp_closeness_centrality(py::object G, py::object weight, @@ -58,27 +200,115 @@ static py::object invoke_cpp_closeness_centrality(py::object G, py::object weigh if (!cutoff.is_none()){ cutoff_ = cutoff.cast(); } - Segment_tree_zkw segment_tree_zkw(N); + + // Auto algorithm selection + bool use_bfs = (weight.is_none() || is_unweighted_graph(G_l)); + std::vector CC; + if(!sources.is_none()){ py::list sources_list = py::list(sources); int sources_list_len = py::len(sources_list); + CC.resize(sources_list_len); + + // Collect all source node IDs + std::vector source_ids(sources_list_len); for(int i = 0; i < sources_list_len; i++){ if(G_.node_to_id.attr("get")(sources_list[i],py::none()).is_none()){ printf("The node should exist in the graph!"); return py::none(); } - node_t source_id = G_.node_to_id.attr("get")(sources_list[i]).cast(); - float res = closeness_dijkstra(G_l, source_id, cutoff_,segment_tree_zkw); - CC.push_back(res); + source_ids[i] = G_.node_to_id.attr("get")(sources_list[i]).cast(); + } + + // OpenMP parallel computation + // Only enable parallelism when sources are many to avoid overhead + #pragma omp parallel if(sources_list_len > 100) + { + // Per-thread data structures (avoid race conditions) + std::vector already_counted(N + 1, 0); + std::vector queue_storage; + queue_storage.reserve(N * 2); + + std::vector dist(N + 1, 0.0f); + std::vector which(N + 1, 0); + + FastAdjCache cache; + if (!use_bfs) { + cache.init_with_weights(N); + } + + // Assign unique timestamp start for each thread + #ifdef _OPENMP + int thread_id = omp_get_thread_num(); + int num_threads = omp_get_num_threads(); + int timestamp = thread_id * sources_list_len; + #else + int timestamp = 0; + #endif + + // Parallel loop: each thread handles different source node + #pragma omp for schedule(dynamic, 1) + for(int i = 0; i < sources_list_len; i++){ + timestamp++; + double res; + if (use_bfs) { + res = closeness_bfs_direct(G_l, source_ids[i], cutoff_, + already_counted, queue_storage, timestamp); + } else { + res = closeness_dijkstra_cached(G_l, source_ids[i], cutoff_, + dist, which, cache, timestamp); + } + CC[i] = res; + } } } else{ - for(int i = 1; i <= N; i++){ - float res = closeness_dijkstra(G_l, i, cutoff_,segment_tree_zkw); - CC.push_back(res); + CC.resize(N); + + // OpenMP parallel computation for all nodes + // Only enable parallelism when node count is large + #pragma omp parallel if(N > 100) + { + // Per-thread data structures + std::vector already_counted(N + 1, 0); + std::vector queue_storage; + queue_storage.reserve(N * 2); + + std::vector dist(N + 1, 0.0f); + std::vector which(N + 1, 0); + + FastAdjCache cache; + if (!use_bfs) { + cache.init_with_weights(N); + } + + // Assign unique timestamp start for each thread + #ifdef _OPENMP + int thread_id = omp_get_thread_num(); + int num_threads = omp_get_num_threads(); + int timestamp = thread_id * N; + #else + int timestamp = 0; + #endif + + // Parallel loop: dynamic scheduling for load balancing + #pragma omp for schedule(dynamic, 10) + for(int i = 1; i <= N; i++){ + timestamp++; + double res; + if (use_bfs) { + res = closeness_bfs_direct(G_l, i, cutoff_, + already_counted, queue_storage, timestamp); + } else { + res = closeness_dijkstra_cached(G_l, i, cutoff_, + dist, which, cache, timestamp); + } + CC[i - 1] = res; + } } } + py::array::ShapeContainer ret_shape{(int)CC.size()}; py::array_t ret(ret_shape, CC.data()); @@ -120,4 +350,4 @@ py::object closeness_centrality(py::object G, py::object weight, py::object cuto #else return invoke_cpp_closeness_centrality(G, weight, cutoff, sources); #endif -} +} \ No newline at end of file From fcf175b7552de12c0afa1a9315611bb7b10b6c8a Mon Sep 17 00:00:00 2001 From: sama Date: Wed, 17 Dec 2025 21:44:31 -0700 Subject: [PATCH 13/33] eigenvector_centrality --- easygraph/functions/centrality/eigenvector.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 easygraph/functions/centrality/eigenvector.py diff --git a/easygraph/functions/centrality/eigenvector.py b/easygraph/functions/centrality/eigenvector.py new file mode 100644 index 00000000..f38085d1 --- /dev/null +++ b/easygraph/functions/centrality/eigenvector.py @@ -0,0 +1,154 @@ +import math +import easygraph as eg +from easygraph.utils import * +from easygraph.utils.decorators import * +from scipy import sparse +from scipy.sparse import linalg +import numpy as np +from collections import defaultdict + +__all__ = ["eigenvector_centrality"] + +@not_implemented_for("multigraph") +@hybrid("cpp_eigenvector_centrality") +def eigenvector_centrality(G, max_iter=100, tol=1.0e-6, nstart=None, weight=None): + """Calculate eigenvector centrality for nodes in the graph + + Eigenvector centrality is based on the idea that a node's importance + depends on the importance of its neighboring nodes. + Specifically, a node's centrality is proportional to the sum of + centrality values of its neighbors. + + Parameters + ---------- + G : graph object + An undirected or directed graph + + max_iter : int, optional (default=100) + Maximum number of iterations for the power method + + tol : float, optional (default=1.0e-6) + Convergence threshold; algorithm terminates when the difference + between centrality values in consecutive iterations is less than this value + + nstart : dictionary, optional (default=None) + Dictionary mapping nodes to initial centrality values + If None, the ARPACK solver is used to directly compute the eigenvector + + weight : string or None, optional (default=None) + Name of the edge attribute to be used as edge weight + If None, all edges are considered to have weight 1 + + Returns + ------- + centrality : dictionary + Dictionary mapping nodes to their eigenvector centrality values + + Raises + ------ + EasyGraphPointlessConcept + When input is an empty graph + + EasyGraphError + When the algorithm fails to converge within the specified maximum iterations + + Notes + ----- + This algorithm uses the power iteration method to find the principal eigenvector. + When nstart is not provided, the ARPACK solver is used for efficiency. + The returned centrality values are normalized. + """ + + if len(G) == 0: + raise eg.EasyGraphPointlessConcept( + "cannot compute centrality for the null graph" + ) + + if len(G) == 1: + raise eg.EasyGraphPointlessConcept( + "cannot compute eigenvector centrality for a single node graph" + ) + + + # Build node list and mapping + nodelist = list(G.nodes) + n = len(nodelist) + node_map = {node: i for i, node in enumerate(nodelist)} + + # Build weighted adjacency matrix + row, col, data = [], [], [] + for u in nodelist: + u_idx = node_map[u] + for v, attrs in G[u].items(): + if v in node_map: + v_idx = node_map[v] + w = attrs.get(weight, 1.0) if weight else 1.0 + # Build transpose matrix for centrality calculation + row.append(v_idx) + col.append(u_idx) + data.append(float(w)) + + # Create CSR format sparse matrix + A = sparse.csr_matrix((data, (row, col)), shape=(n, n)) + + # Detect and handle isolated nodes + row_sums = np.array(A.sum(axis=1)).flatten() + col_sums = np.array(A.sum(axis=0)).flatten() + isolated_nodes = np.where((row_sums == 0) & (col_sums == 0))[0] + + has_isolated = len(isolated_nodes) > 0 + isolated_indices = [] + + # Add small self-loops to isolated nodes for stability + if has_isolated: + # Store isolated node indices + isolated_indices = isolated_nodes.tolist() + + # Add small self-loop weights to isolated nodes + for idx in isolated_indices: + A[idx, idx] = 1.0e-4 # Small enough to not affect results, but maintains numerical stability + if nstart is not None: + # Use custom initial vector for power iteration + v = np.array([nstart.get(n, 1.0) for n in nodelist], dtype=float) + v = v / np.sum(np.abs(v)) + + # Power iteration method to compute principal eigenvector + v_last = np.zeros_like(v) + for _ in range(max_iter): + np.copyto(v_last, v) + v = A @ v_last # Sparse matrix multiplication + + norm = np.linalg.norm(v) + if norm < 1e-10: + v = v_last.copy() + break + v = v / norm # Normalization + + # Check convergence + if np.linalg.norm(v - v_last) < tol: + break + else: + raise eg.EasyGraphError(f"Eigenvector calculation did not converge in {max_iter} iterations") + + centrality = v + else: + # Use ARPACK solver to directly compute the principal eigenvector + eigenvalues, eigenvectors = linalg.eigs(A, k=1, which='LR', + maxiter=max_iter, tol=tol) + centrality = np.real(eigenvectors[:,0]) + + # Ensure positive results and normalize + if centrality.sum() < 0: + centrality = -centrality + + centrality = centrality / np.linalg.norm(centrality) + # Set centrality of isolated nodes to zero + if has_isolated: + for idx in isolated_indices: + centrality[idx] = 0.0 + # Renormalize if needed + if np.sum(centrality) > 0: + centrality = centrality / np.linalg.norm(centrality) + + # Return dictionary of node centrality values + return {nodelist[i]: float(centrality[i]) for i in range(n)} \ No newline at end of file From 448262d1092cc342f5663693d5f34b85571d8a67 Mon Sep 17 00:00:00 2001 From: sama Date: Wed, 17 Dec 2025 21:53:29 -0700 Subject: [PATCH 14/33] add cpp_eigenvector --- cpp_easygraph/cpp_easygraph.cpp | 1 + .../functions/centrality/centrality.h | 10 +- .../functions/centrality/eigenvector.cpp | 337 ++++++++++++++++++ 3 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 cpp_easygraph/functions/centrality/eigenvector.cpp diff --git a/cpp_easygraph/cpp_easygraph.cpp b/cpp_easygraph/cpp_easygraph.cpp index 3e0a4285..1faf8ee5 100644 --- a/cpp_easygraph/cpp_easygraph.cpp +++ b/cpp_easygraph/cpp_easygraph.cpp @@ -81,6 +81,7 @@ PYBIND11_MODULE(cpp_easygraph, m) { m.def("cpp_closeness_centrality", &closeness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(), py::arg("sources") = py::none()); m.def("cpp_betweenness_centrality", &betweenness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(),py::arg("sources") = py::none(), py::arg("normalized") = py::bool_(true), py::arg("endpoints") = py::bool_(false)); m.def("cpp_katz_centrality", &cpp_katz_centrality, py::arg("G"), py::arg("alpha") = 0.1, py::arg("beta") = 1.0, py::arg("max_iter") = 1000, py::arg("tol") = 1e-6, py::arg("normalized") = true); + m.def("cpp_eigenvector_centrality", &cpp_eigenvector_centrality, py::arg("G"), py::arg("max_iter") = 100, py::arg("tol") = 1.0e-6, py::arg("nstart") = py::none(), py::arg("weight") = py::none()); m.def("cpp_k_core", &core_decomposition, py::arg("G")); m.def("cpp_density", &density, py::arg("G")); m.def("cpp_constraint", &constraint, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); diff --git a/cpp_easygraph/functions/centrality/centrality.h b/cpp_easygraph/functions/centrality/centrality.h index b947c205..6a204cc1 100644 --- a/cpp_easygraph/functions/centrality/centrality.h +++ b/cpp_easygraph/functions/centrality/centrality.h @@ -16,4 +16,12 @@ py::object cpp_katz_centrality( py::object degree_centrality(py::object G); py::object in_degree_centrality(py::object G); -py::object out_degree_centrality(py::object G); \ No newline at end of file +py::object out_degree_centrality(py::object G); + +py::object cpp_eigenvector_centrality( + py::object G, + py::object py_max_iter, + py::object py_tol, + py::object py_nstart, + py::object py_weight +); \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/eigenvector.cpp b/cpp_easygraph/functions/centrality/eigenvector.cpp new file mode 100644 index 00000000..316c27ed --- /dev/null +++ b/cpp_easygraph/functions/centrality/eigenvector.cpp @@ -0,0 +1,337 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // 确保包含 OpenMP 头文件 + +// 假设头文件路径一致,保留原有引用 +#include "centrality.h" +#include "../../classes/graph.h" + +namespace py = pybind11; + +// --- 移除所有 extern "C" ARPACK 声明 --- +// --- 移除所有 Eigen include --- + +// CSRMatrix 稀疏矩阵结构体 (保持不变,移除 to_eigen 方法) +class CSRMatrix { +public: + std::vector indptr; + std::vector indices; + std::vector data; + int rows; + int cols; + + CSRMatrix() : rows(0), cols(0) {} + CSRMatrix(int r, int c) : rows(r), cols(c) { + indptr.assign(r + 1, 0); + } + + void reserve(size_t nnz) { + indices.reserve(nnz); + data.reserve(nnz); + } + + // 并行矩阵向量乘法: result = A * vec + void multiply_inplace(const std::vector& vec, std::vector& result) const { + result.assign(rows, 0.0); + #pragma omp parallel for schedule(static, 64) if(rows > 1000) + for (int i = 0; i < rows; ++i) { + double sum = 0.0; + // 显式指示编译器进行循环展开或向量化优化 + int start = indptr[i]; + int end = indptr[i + 1]; + for (int j = start; j < end; ++j) { + sum += data[j] * vec[indices[j]]; + } + result[i] = sum; + } + } +}; + +// 辅助函数声明 +double vector_norm(const std::vector& x); +void normalize_vector(std::vector& x, double norm); + +// 幂迭代法 (Power Iteration) +// 这是在没有外部库时求解主特征向量的标准方法 +std::vector power_iteration( + const CSRMatrix& A, + int max_iter, + double tol, + std::vector& x // 输入初始向量,输出结果 +) { + const int n = A.rows; + std::vector x_old(n); + std::vector x_new(n, 0.0); + + // 1. 初始化归一化 + double norm0 = vector_norm(x); + if (norm0 < 1e-12) { + // 如果输入向量全0,随机初始化 + std::mt19937 gen(42); + std::uniform_real_distribution<> dis(0.0, 1.0); + for (int i = 0; i < n; ++i) x[i] = dis(gen); + norm0 = vector_norm(x); + } + normalize_vector(x, norm0); + + // 复制到 x_old + std::copy(x.begin(), x.end(), x_old.begin()); + + double lambda_old = 0.0; + + for (int iter = 0; iter < max_iter; ++iter) { + // x_new = A * x_old + A.multiply_inplace(x_old, x_new); + + // 计算模长 + double norm = vector_norm(x_new); + + // 防止下溢 + if (norm < 1e-12) break; + + // 归一化: x_new = x_new / norm + double inv_norm = 1.0 / norm; + #pragma omp parallel for schedule(static) if(n > 1000) + for(int i=0; i 1000) + for (int i = 0; i < n; ++i) { + double d = x_new[i] - x_old[i]; // 注意特征向量方向可能翻转,但这在正矩阵通常不发生 + diff += d * d; + } + diff = std::sqrt(diff); + + // 也可以检查 lambda 的变化 + double lambda = norm; // 对于幂迭代,模长的变化率趋向于主特征值 + + if (iter > 0) { + if (diff < tol || std::abs(lambda - lambda_old) < tol * std::abs(lambda)) { + // 收敛 + x = x_new; + return x; + } + } + + lambda_old = lambda; + // 交换指针/数据,准备下一次迭代 + x_old.swap(x_new); + } + + x = x_old; + return x; +} + +// 辅助函数:构建转置矩阵 (逻辑保持原样,纯C++实现) +CSRMatrix build_transpose_matrix(Graph& graph, const std::vector& nodes, const std::string& weight_key) { + try { + std::shared_ptr csr_ptr; + if (weight_key.empty()) { + csr_ptr = graph.gen_CSR(); + } else { + csr_ptr = graph.gen_CSR(weight_key); + } + + if (!csr_ptr) { + CSRMatrix A(static_cast(nodes.size()), static_cast(nodes.size())); + return A; + } + + const int n = static_cast(nodes.size()); + // EasyGraph 的 CSR 结构 + const auto& src_indptr = csr_ptr->V; + const auto& src_indices = csr_ptr->E; + std::vector src_data; + + // 处理权重 + if (weight_key.empty()) { + src_data = csr_ptr->unweighted_W.empty() ? + std::vector(csr_ptr->E.size(), 1.0) : + csr_ptr->unweighted_W; + } else { + auto it = csr_ptr->W_map.find(weight_key); + if (it != csr_ptr->W_map.end() && it->second) { + src_data = *(it->second); + } else { + src_data = std::vector(csr_ptr->E.size(), 1.0); + } + } + + // --- 构建转置矩阵 At --- + // Eigenvector Centrality 定义为 x A = lambda x,即 x 是左特征向量 + // 等价于求 A^T 的右特征向量:A^T x^T = lambda x^T + // 所以我们需要转置矩阵 + + int rows = n; + int cols = n; + CSRMatrix At(cols, rows); // 转置后行列互换,虽为方阵 + + // 1. 计算每一列有多少个非零元素 (转置后的行度) + for (int x : src_indices) { + if (x >= 0 && x < cols) At.indptr[x + 1]++; + } + // 2. 前缀和计算 indptr + for (int i = 0; i < cols; ++i) { + At.indptr[i + 1] += At.indptr[i]; + } + + // 3. 填充 indices 和 data + size_t nnz = src_indices.size(); + At.indices.resize(nnz); + At.data.resize(nnz); + + std::vector cur_pos(At.indptr.begin(), At.indptr.end()); + + for (int r = 0; r < rows; ++r) { + int start = src_indptr[r]; + int end = src_indptr[r+1]; + for (int p = start; p < end; ++p) { + int c = src_indices[p]; // 原矩阵的列 -> 转置矩阵的行 + if (c < 0 || c >= cols) continue; + + int dest = cur_pos[c]++; + At.indices[dest] = r; // 原矩阵的行 -> 转置矩阵的列 + At.data[dest] = (p < static_cast(src_data.size())) ? src_data[p] : 1.0; + } + } + return At; + + } catch (...) { + return CSRMatrix(static_cast(nodes.size()), static_cast(nodes.size())); + } +} + + +// 主入口函数 +py::object cpp_eigenvector_centrality( + py::object G, + py::object py_max_iter, + py::object py_tol, + py::object py_nstart, + py::object py_weight +) { + try { + Graph& graph = G.cast(); + int max_iter = py_max_iter.cast(); + double tol = py_tol.cast(); + std::string weight_key = ""; + if (!py_weight.is_none()) { + weight_key = py_weight.cast(); + } + + if (graph.node.empty()) { + return py::dict(); + } + + // 1. 映射节点 ID + std::vector nodes; + nodes.reserve(graph.node.size()); + for (auto& node_pair : graph.node) { + nodes.push_back(node_pair.first); + } + const int n = nodes.size(); + + // 2. 构建转置矩阵 (A^T) + CSRMatrix A_transpose = build_transpose_matrix(graph, nodes, weight_key); + + // 3. 标记孤立点 (出度为0的点在转置矩阵中表现为全0行) + // 注意:这里的 A_transpose 行实际上代表原图的入度 + std::vector isolated_nodes(n, false); + #pragma omp parallel for schedule(static) if(n > 1000) + for (int i = 0; i < n; i++) { + // 如果在转置矩阵里这一行是空的,说明原图中该点没有任何入边 + // 对于 Eigenvector Centrality,如果完全没有入边,中心性通常为0 (除非是强连通分量处理) + if (A_transpose.indptr[i + 1] == A_transpose.indptr[i]) { + isolated_nodes[i] = true; + } + } + + // 4. 初始化向量 x + std::vector x(n, 0.0); + + if (py_nstart.is_none()) { + // 默认初始化:使用度数+随机噪声,或者纯 1.0/n + #pragma omp parallel for schedule(static) if(n > 1000) + for (int i = 0; i < n; i++) { + if (!isolated_nodes[i]) { + // 使用入度作为初始猜测通常收敛更快 + int degree = A_transpose.indptr[i+1] - A_transpose.indptr[i]; + x[i] = static_cast(degree) + 1.0; + } + } + } else { + // 使用用户提供的 nstart + py::dict nstart = py_nstart.cast(); + for (size_t i = 0; i < nodes.size(); i++) { + py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; + if (nstart.contains(node_obj)) { + x[i] = nstart[node_obj].cast(); + } else { + x[i] = 0.0; // 或者 1.0 / n + } + } + } + + // 5. 执行幂迭代 (替代 ARPACK/Eigen) + // 只需要调用这一个函数,不需要复杂的 fallback 逻辑 + std::vector centrality = power_iteration(A_transpose, max_iter, tol, x); + + // 6. 后处理 + // 确保结果为正值 + double sum = 0.0; + #pragma omp parallel for reduction(+:sum) schedule(static) if(n > 1000) + for (int i = 0; i < n; i++) sum += centrality[i]; + + if (sum < 0) { + #pragma omp parallel for schedule(static) if(n > 1000) + for(int i=0; i 1e-12) { + normalize_vector(centrality, norm); + } + + // 7. 构建返回字典 + py::dict result; + for (size_t i = 0; i < nodes.size(); i++) { + py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; + result[node_obj] = centrality[i]; + } + return result; + + } catch (const std::exception& e) { + throw std::runtime_error(std::string("C++ exception: ") + e.what()); + } +} + +// 辅助函数实现 +double vector_norm(const std::vector& x) { + double s = 0.0; + #pragma omp parallel for reduction(+:s) if(x.size() > 1000) + for (size_t i = 0; i < x.size(); ++i) s += x[i] * x[i]; + return std::sqrt(s); +} + +void normalize_vector(std::vector& x, double norm) { + const double inv_norm = 1.0 / norm; + #pragma omp parallel for schedule(static) if(x.size() > 1000) + for (size_t i = 0; i < x.size(); ++i) { + x[i] *= inv_norm; + } +} \ No newline at end of file From 0038d9b440857ee9e8884780e0000362e82fdb6b Mon Sep 17 00:00:00 2001 From: sama Date: Wed, 17 Dec 2025 22:19:18 -0700 Subject: [PATCH 15/33] add eigenvector --- easygraph/functions/centrality/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/easygraph/functions/centrality/__init__.py b/easygraph/functions/centrality/__init__.py index f37e55fe..bd2fec50 100644 --- a/easygraph/functions/centrality/__init__.py +++ b/easygraph/functions/centrality/__init__.py @@ -5,4 +5,5 @@ from .flowbetweenness import * from .laplacian import * from .pagerank import * -from .katz_centrality import * \ No newline at end of file +from .katz_centrality import * +from .eigenvector import * \ No newline at end of file From 185a9e7828b0180947dc200eb35ef2771fc2ad82 Mon Sep 17 00:00:00 2001 From: sama Date: Thu, 18 Dec 2025 00:53:52 -0700 Subject: [PATCH 16/33] add cpp_eigenvector --- .../functions/centrality/eigenvector.cpp | 228 ++++-------------- 1 file changed, 53 insertions(+), 175 deletions(-) diff --git a/cpp_easygraph/functions/centrality/eigenvector.cpp b/cpp_easygraph/functions/centrality/eigenvector.cpp index 316c27ed..ecf7faed 100644 --- a/cpp_easygraph/functions/centrality/eigenvector.cpp +++ b/cpp_easygraph/functions/centrality/eigenvector.cpp @@ -7,20 +7,13 @@ #include #include #include -#include -#include -#include // 确保包含 OpenMP 头文件 +#include -// 假设头文件路径一致,保留原有引用 #include "centrality.h" #include "../../classes/graph.h" namespace py = pybind11; -// --- 移除所有 extern "C" ARPACK 声明 --- -// --- 移除所有 Eigen include --- - -// CSRMatrix 稀疏矩阵结构体 (保持不变,移除 to_eigen 方法) class CSRMatrix { public: std::vector indptr; @@ -33,110 +26,68 @@ class CSRMatrix { CSRMatrix(int r, int c) : rows(r), cols(c) { indptr.assign(r + 1, 0); } - - void reserve(size_t nnz) { - indices.reserve(nnz); - data.reserve(nnz); - } - - // 并行矩阵向量乘法: result = A * vec - void multiply_inplace(const std::vector& vec, std::vector& result) const { - result.assign(rows, 0.0); - #pragma omp parallel for schedule(static, 64) if(rows > 1000) - for (int i = 0; i < rows; ++i) { - double sum = 0.0; - // 显式指示编译器进行循环展开或向量化优化 - int start = indptr[i]; - int end = indptr[i + 1]; - for (int j = start; j < end; ++j) { - sum += data[j] * vec[indices[j]]; - } - result[i] = sum; - } - } }; -// 辅助函数声明 -double vector_norm(const std::vector& x); -void normalize_vector(std::vector& x, double norm); - -// 幂迭代法 (Power Iteration) -// 这是在没有外部库时求解主特征向量的标准方法 -std::vector power_iteration( +std::vector power_iteration_optimized( const CSRMatrix& A, int max_iter, double tol, - std::vector& x // 输入初始向量,输出结果 + std::vector& x ) { const int n = A.rows; - std::vector x_old(n); - std::vector x_new(n, 0.0); - - // 1. 初始化归一化 - double norm0 = vector_norm(x); - if (norm0 < 1e-12) { - // 如果输入向量全0,随机初始化 - std::mt19937 gen(42); - std::uniform_real_distribution<> dis(0.0, 1.0); - for (int i = 0; i < n; ++i) x[i] = dis(gen); - norm0 = vector_norm(x); - } - normalize_vector(x, norm0); + std::vector x_next(n); - // 复制到 x_old - std::copy(x.begin(), x.end(), x_old.begin()); - - double lambda_old = 0.0; + double norm = 0.0; + #pragma omp parallel for reduction(+:norm) + for (int i = 0; i < n; ++i) norm += x[i] * x[i]; + norm = std::sqrt(norm); - for (int iter = 0; iter < max_iter; ++iter) { - // x_new = A * x_old - A.multiply_inplace(x_old, x_new); + if (norm < 1e-12) { + std::fill(x.begin(), x.end(), 1.0 / std::sqrt(n)); + } else { + double inv_norm = 1.0 / norm; + #pragma omp parallel for + for (int i = 0; i < n; ++i) x[i] *= inv_norm; + } - // 计算模长 - double norm = vector_norm(x_new); - - // 防止下溢 - if (norm < 1e-12) break; + double delta = tol + 1.0; - // 归一化: x_new = x_new / norm - double inv_norm = 1.0 / norm; - #pragma omp parallel for schedule(static) if(n > 1000) - for(int i=0; i= tol; ++iter) { + double next_norm_sq = 0.0; - // 计算瑞利商 (Rayleigh Quotient) 近似特征值 lambda - // lambda = (x_new^T * A * x_new) / (x_new^T * x_new) - // 由于 x_new 已经归一化,分母为 1,且 x_new 实际上是 A*x_old 的方向 - // 这里简化判断收敛:比较 x_new 和 x_old 的差异 - - double diff = 0.0; - #pragma omp parallel for reduction(+:diff) if(n > 1000) + #pragma omp parallel for reduction(+:next_norm_sq) schedule(dynamic, 64) for (int i = 0; i < n; ++i) { - double d = x_new[i] - x_old[i]; // 注意特征向量方向可能翻转,但这在正矩阵通常不发生 - diff += d * d; + double sum = 0.0; + const int start = A.indptr[i]; + const int end = A.indptr[i+1]; + + for (int j = start; j < end; ++j) { + sum += A.data[j] * x[A.indices[j]]; + } + + x_next[i] = sum; + next_norm_sq += sum * sum; } - diff = std::sqrt(diff); - // 也可以检查 lambda 的变化 - double lambda = norm; // 对于幂迭代,模长的变化率趋向于主特征值 + double next_norm = std::sqrt(next_norm_sq); + if (next_norm < 1e-12) break; - if (iter > 0) { - if (diff < tol || std::abs(lambda - lambda_old) < tol * std::abs(lambda)) { - // 收敛 - x = x_new; - return x; - } + double inv_next_norm = 1.0 / next_norm; + delta = 0.0; + + #pragma omp parallel for reduction(+:delta) schedule(static) + for (int i = 0; i < n; ++i) { + double val = x_next[i] * inv_next_norm; + delta += std::abs(val - x[i]); + x_next[i] = val; } - lambda_old = lambda; - // 交换指针/数据,准备下一次迭代 - x_old.swap(x_new); + x.swap(x_next); } - x = x_old; return x; } -// 辅助函数:构建转置矩阵 (逻辑保持原样,纯C++实现) CSRMatrix build_transpose_matrix(Graph& graph, const std::vector& nodes, const std::string& weight_key) { try { std::shared_ptr csr_ptr; @@ -146,18 +97,13 @@ CSRMatrix build_transpose_matrix(Graph& graph, const std::vector& nodes, csr_ptr = graph.gen_CSR(weight_key); } - if (!csr_ptr) { - CSRMatrix A(static_cast(nodes.size()), static_cast(nodes.size())); - return A; - } + if (!csr_ptr) return CSRMatrix(nodes.size(), nodes.size()); const int n = static_cast(nodes.size()); - // EasyGraph 的 CSR 结构 const auto& src_indptr = csr_ptr->V; const auto& src_indices = csr_ptr->E; std::vector src_data; - // 处理权重 if (weight_key.empty()) { src_data = csr_ptr->unweighted_W.empty() ? std::vector(csr_ptr->E.size(), 1.0) : @@ -171,52 +117,39 @@ CSRMatrix build_transpose_matrix(Graph& graph, const std::vector& nodes, } } - // --- 构建转置矩阵 At --- - // Eigenvector Centrality 定义为 x A = lambda x,即 x 是左特征向量 - // 等价于求 A^T 的右特征向量:A^T x^T = lambda x^T - // 所以我们需要转置矩阵 - int rows = n; int cols = n; - CSRMatrix At(cols, rows); // 转置后行列互换,虽为方阵 + CSRMatrix At(cols, rows); - // 1. 计算每一列有多少个非零元素 (转置后的行度) for (int x : src_indices) { if (x >= 0 && x < cols) At.indptr[x + 1]++; } - // 2. 前缀和计算 indptr for (int i = 0; i < cols; ++i) { At.indptr[i + 1] += At.indptr[i]; } - // 3. 填充 indices 和 data size_t nnz = src_indices.size(); At.indices.resize(nnz); At.data.resize(nnz); - std::vector cur_pos(At.indptr.begin(), At.indptr.end()); for (int r = 0; r < rows; ++r) { int start = src_indptr[r]; int end = src_indptr[r+1]; for (int p = start; p < end; ++p) { - int c = src_indices[p]; // 原矩阵的列 -> 转置矩阵的行 + int c = src_indices[p]; if (c < 0 || c >= cols) continue; - int dest = cur_pos[c]++; - At.indices[dest] = r; // 原矩阵的行 -> 转置矩阵的列 + At.indices[dest] = r; At.data[dest] = (p < static_cast(src_data.size())) ? src_data[p] : 1.0; } } return At; - } catch (...) { - return CSRMatrix(static_cast(nodes.size()), static_cast(nodes.size())); + return CSRMatrix(nodes.size(), nodes.size()); } } - -// 主入口函数 py::object cpp_eigenvector_centrality( py::object G, py::object py_max_iter, @@ -233,11 +166,8 @@ py::object cpp_eigenvector_centrality( weight_key = py_weight.cast(); } - if (graph.node.empty()) { - return py::dict(); - } + if (graph.node.empty()) return py::dict(); - // 1. 映射节点 ID std::vector nodes; nodes.reserve(graph.node.size()); for (auto& node_pair : graph.node) { @@ -245,69 +175,33 @@ py::object cpp_eigenvector_centrality( } const int n = nodes.size(); - // 2. 构建转置矩阵 (A^T) CSRMatrix A_transpose = build_transpose_matrix(graph, nodes, weight_key); - // 3. 标记孤立点 (出度为0的点在转置矩阵中表现为全0行) - // 注意:这里的 A_transpose 行实际上代表原图的入度 - std::vector isolated_nodes(n, false); - #pragma omp parallel for schedule(static) if(n > 1000) - for (int i = 0; i < n; i++) { - // 如果在转置矩阵里这一行是空的,说明原图中该点没有任何入边 - // 对于 Eigenvector Centrality,如果完全没有入边,中心性通常为0 (除非是强连通分量处理) - if (A_transpose.indptr[i + 1] == A_transpose.indptr[i]) { - isolated_nodes[i] = true; - } - } - - // 4. 初始化向量 x std::vector x(n, 0.0); if (py_nstart.is_none()) { - // 默认初始化:使用度数+随机噪声,或者纯 1.0/n - #pragma omp parallel for schedule(static) if(n > 1000) + #pragma omp parallel for for (int i = 0; i < n; i++) { - if (!isolated_nodes[i]) { - // 使用入度作为初始猜测通常收敛更快 - int degree = A_transpose.indptr[i+1] - A_transpose.indptr[i]; - x[i] = static_cast(degree) + 1.0; + if (A_transpose.indptr[i + 1] != A_transpose.indptr[i]) { + x[i] = static_cast(A_transpose.indptr[i+1] - A_transpose.indptr[i]); + } else { + x[i] = 1.0 / n; } } } else { - // 使用用户提供的 nstart py::dict nstart = py_nstart.cast(); for (size_t i = 0; i < nodes.size(); i++) { py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; if (nstart.contains(node_obj)) { x[i] = nstart[node_obj].cast(); } else { - x[i] = 0.0; // 或者 1.0 / n + x[i] = 0.0; } } } - // 5. 执行幂迭代 (替代 ARPACK/Eigen) - // 只需要调用这一个函数,不需要复杂的 fallback 逻辑 - std::vector centrality = power_iteration(A_transpose, max_iter, tol, x); + std::vector centrality = power_iteration_optimized(A_transpose, max_iter, tol, x); - // 6. 后处理 - // 确保结果为正值 - double sum = 0.0; - #pragma omp parallel for reduction(+:sum) schedule(static) if(n > 1000) - for (int i = 0; i < n; i++) sum += centrality[i]; - - if (sum < 0) { - #pragma omp parallel for schedule(static) if(n > 1000) - for(int i=0; i 1e-12) { - normalize_vector(centrality, norm); - } - - // 7. 构建返回字典 py::dict result; for (size_t i = 0; i < nodes.size(); i++) { py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; @@ -318,20 +212,4 @@ py::object cpp_eigenvector_centrality( } catch (const std::exception& e) { throw std::runtime_error(std::string("C++ exception: ") + e.what()); } -} - -// 辅助函数实现 -double vector_norm(const std::vector& x) { - double s = 0.0; - #pragma omp parallel for reduction(+:s) if(x.size() > 1000) - for (size_t i = 0; i < x.size(); ++i) s += x[i] * x[i]; - return std::sqrt(s); -} - -void normalize_vector(std::vector& x, double norm) { - const double inv_norm = 1.0 / norm; - #pragma omp parallel for schedule(static) if(x.size() > 1000) - for (size_t i = 0; i < x.size(); ++i) { - x[i] *= inv_norm; - } } \ No newline at end of file From fe0eda36d99332caff73f04752c8bca9d0a3dcf0 Mon Sep 17 00:00:00 2001 From: sama Date: Mon, 29 Dec 2025 00:10:34 -0700 Subject: [PATCH 17/33] dirtt flag --- cpp_easygraph/functions/centrality/betweenness.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index 3ceb0a38..5da3d9b9 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -229,7 +229,6 @@ static py::object invoke_cpp_betweenness_centrality( if (G_.linkgraph_dirty) { G_l = graph_to_linkgraph(G_, is_directed, weight_key, false, false); G_.linkgraph_structure = G_l; - G_.linkgraph_dirty = false; } else { G_l = G_.linkgraph_structure; } From 55a7bdc5d6b496679060e828689c240c6ca6cf7a Mon Sep 17 00:00:00 2001 From: sama Date: Mon, 29 Dec 2025 00:11:33 -0700 Subject: [PATCH 18/33] normalize --- cpp_easygraph/functions/pagerank/pagerank.cpp | 93 +++++++------------ 1 file changed, 36 insertions(+), 57 deletions(-) diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index 254b8065..55dceea1 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -10,16 +10,15 @@ #include "../../classes/linkgraph.h" struct Page { - Page(){} - Page(const double &_newPR, const double &_oldPR) {newPR = _newPR; oldPR = _oldPR;} - + Page() {} + Page(const double &_newPR, const double &_oldPR) { newPR = _newPR; oldPR = _oldPR; } double newPR, oldPR; }; py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6, py::object weight=py::none()) { bool is_directed = G.attr("is_directed")().cast(); - + bool use_weights = !weight.is_none(); std::string weight_key = ""; if (use_weights) { @@ -32,13 +31,8 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub if (is_directed) { DiGraph& G_ = G.cast(); N = G_.node.size(); - - if(G_.linkgraph_dirty){ - G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); - G_.linkgraph_dirty = false; - } - - if (G_.linkgraph_structure.degree.size() < N + 1 || G_.linkgraph_structure.head.size() < N + 1) { + + if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); G_.linkgraph_dirty = false; } @@ -47,13 +41,8 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } else { Graph& G_ = G.cast(); N = G_.node.size(); - - if(G_.linkgraph_dirty){ - G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); - G_.linkgraph_dirty = false; - } - if (G_.linkgraph_structure.degree.size() < N + 1 || G_.linkgraph_structure.head.size() < N + 1) { + if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); G_.linkgraph_dirty = false; } @@ -69,10 +58,10 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub if (use_weights) { outWeightSum.resize(N + 1, 0.0); #pragma omp parallel for - for(int i = 1; i < N + 1; ++i) { + for (int i = 1; i < N + 1; ++i) { if (outDegree[i] > 0) { - double sum_w = 0; - for(int p = head[i]; p != -1; p = E[p].next){ + double sum_w = 0.0; + for (int p = head[i]; p != -1; p = E[p].next) { sum_w += E[p].w; } outWeightSum[i] = sum_w; @@ -80,54 +69,48 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } } - std::vector page(N+1); - + std::vector page(N + 1); #pragma omp parallel for for (int i = 1; i < N + 1; ++i) { - page[i] = Page(0, 1.0/N); + page[i] = Page(0.0, 1.0 / N); } - int cnt = 0; - int shouldStop = 0; + int cnt = 0; + int shouldStop = 0; - while(!shouldStop) - { + while (!shouldStop) { shouldStop = 1; - double res = 0; + double res = 0.0; #pragma omp parallel for reduction(+:res) - for(int i = 1; i < N+1; ++i) { + for (int i = 1; i < N + 1; ++i) { bool is_dangling = false; if (use_weights) { - if (outDegree[i] == 0 || outWeightSum[i] == 0) is_dangling = true; + if (outDegree[i] == 0 || outWeightSum[i] == 0.0) is_dangling = true; } else { if (outDegree[i] == 0) is_dangling = true; } - - if (is_dangling) { - res += page[i].oldPR; - } + if (is_dangling) res += page[i].oldPR; } #pragma omp parallel for schedule(dynamic, 128) - for(int i = 1; i < N+1; ++i) { + for (int i = 1; i < N + 1; ++i) { if (use_weights) { - if (outDegree[i] == 0 || outWeightSum[i] == 0) continue; + if (outDegree[i] == 0 || outWeightSum[i] == 0.0) continue; } else { - if (outDegree[i] == 0) continue; + if (outDegree[i] == 0) continue; } - + if (!use_weights) { double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; - for(int p = head[i]; p != -1; p = E[p].next){ + for (int p = head[i]; p != -1; p = E[p].next) { #pragma omp atomic page[E[p].to].newPR += tmpPR; } } else { double basePR = page[i].oldPR * alpha; double inv_sum = 1.0 / outWeightSum[i]; - - for(int p = head[i]; p != -1; p = E[p].next){ + for (int p = head[i]; p != -1; p = E[p].next) { double contribution = basePR * (E[p].w * inv_sum); #pragma omp atomic page[E[p].to].newPR += contribution; @@ -135,29 +118,25 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } } - double sum = 0; + double sum = 0.0; #pragma omp parallel for reduction(+:sum) - for(int i = 1; i < N+1; ++i) - { - page[i].newPR += (1 - alpha) / N + res / N * alpha; - sum += fabs(page[i].newPR - page[i].oldPR); - + for (int i = 1; i < N + 1; ++i) { + page[i].newPR += (1.0 - alpha) / N + (res / N) * alpha; + sum += std::fabs(page[i].newPR - page[i].oldPR); page[i].oldPR = page[i].newPR; - page[i].newPR = 0; + page[i].newPR = 0.0; } - - if (sum > threshold * N) - shouldStop = 0; + + if (sum > threshold * N) shouldStop = 0; cnt++; - if (cnt >= max_iterator) - break; + if (cnt >= max_iterator) break; } - - py::list res_lst = py::list(); - for(int i = 1;i < N + 1;i++){ + + py::list res_lst; + for (int i = 1; i < N + 1; ++i) { res_lst.append(page[i].oldPR); } return res_lst; -} \ No newline at end of file +} From 6b76196864e4f993372b1cbed0a246adbb2dfb77 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 4 Jan 2026 00:58:47 -0700 Subject: [PATCH 19/33] add omp --- .../functions/structural_holes/evaluation.cpp | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/cpp_easygraph/functions/structural_holes/evaluation.cpp b/cpp_easygraph/functions/structural_holes/evaluation.cpp index 6627fc5b..08bda4b3 100644 --- a/cpp_easygraph/functions/structural_holes/evaluation.cpp +++ b/cpp_easygraph/functions/structural_holes/evaluation.cpp @@ -1,5 +1,8 @@ #include "evaluation.h" #include +#ifdef _OPENMP +#include +#endif #ifdef EASYGRAPH_ENABLE_GPU #include #endif @@ -106,7 +109,7 @@ weight_t directed_local_constraint(DiGraph& G, node_t u, node_t v, std::string w if (n == v) { continue; } - indirect += directed_normalized_mutual_weight(G, u, n, weight, sum, sum_nmw_rec) * + indirect += directed_normalized_mutual_weight(G, u, n, weight, sum, sum_nmw_rec) * directed_normalized_mutual_weight(G, n, v, weight, sum, sum_nmw_rec); } weight_t result = pow((direct + indirect), 2); @@ -126,7 +129,7 @@ weight_t local_constraint(Graph& G, node_t u, node_t v, std::string weight, rec_ if (w.first == v) { continue; } - indirect += normalized_mutual_weight(G, u, w.first, weight, sum, sum_nmw_rec) * + indirect += normalized_mutual_weight(G, u, w.first, weight, sum, sum_nmw_rec) * normalized_mutual_weight(G, w.first, v, weight, sum, sum_nmw_rec); } weight_t result = pow((direct + indirect), 2); @@ -170,7 +173,6 @@ std::pair directed_compute_constraint_of_v(DiGraph& G, node_t py::object invoke_cpp_constraint(py::object G, py::object nodes, py::object weight) { std::string weight_key = weight_to_string(weight); - rec_type sum_nmw_rec, local_constraint_rec; if (nodes.is_none()) { nodes = G.attr("nodes"); @@ -180,23 +182,50 @@ py::object invoke_cpp_constraint(py::object G, py::object nodes, py::object weig int nodes_list_len = py::len(nodes_list); std::vector constraint_results(nodes_list_len, 0.0); - if (G.attr("is_directed")().cast()) { + std::vector node_ids(nodes_list_len); + bool is_directed = G.attr("is_directed")().cast(); + + if (is_directed) { DiGraph& G_ = G.cast(); for (int i = 0; i < nodes_list_len; i++) { py::object v = nodes_list[i]; - node_t v_id = G_.node_to_id[v].cast(); - std::pair constraint_pair = - directed_compute_constraint_of_v(G_, v_id, weight_key, local_constraint_rec, sum_nmw_rec); - constraint_results[i] = constraint_pair.second; + node_ids[i] = G_.node_to_id[v].cast(); + } + + { + py::gil_scoped_release release; + #pragma omp parallel + { + rec_type sum_nmw_rec_private, local_constraint_rec_private; + #pragma omp for schedule(static) + for (int i = 0; i < nodes_list_len; i++) { + std::pair constraint_pair = + directed_compute_constraint_of_v(G_, node_ids[i], weight_key, + local_constraint_rec_private, sum_nmw_rec_private); + constraint_results[i] = constraint_pair.second; + } + } } } else { Graph& G_ = G.cast(); for (int i = 0; i < nodes_list_len; i++) { py::object v = nodes_list[i]; - node_t v_id = G_.node_to_id[v].cast(); - std::pair constraint_pair = - compute_constraint_of_v(G_, v_id, weight_key, local_constraint_rec, sum_nmw_rec); - constraint_results[i] = constraint_pair.second; + node_ids[i] = G_.node_to_id[v].cast(); + } + + { + py::gil_scoped_release release; + #pragma omp parallel + { + rec_type sum_nmw_rec_private, local_constraint_rec_private; + #pragma omp for schedule(static) + for (int i = 0; i < nodes_list_len; i++) { + std::pair constraint_pair = + compute_constraint_of_v(G_, node_ids[i], weight_key, + local_constraint_rec_private, sum_nmw_rec_private); + constraint_results[i] = constraint_pair.second; + } + } } } @@ -221,7 +250,7 @@ static py::object invoke_gpu_constraint(py::object G, py::object nodes, py::obje std::vector& E = csr_graph->E; std::vector& row = coo_graph->row; std::vector& col = coo_graph->col; - std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) + std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) : coo_graph->W_map.find(weight_to_string(weight))->second.get(); std::unordered_map& node2idx = coo_graph->node2idx; int num_nodes = coo_graph->node2idx.size(); @@ -299,7 +328,7 @@ py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object py::list nodes_list = py::list(nodes); int nodes_list_len = py::len(nodes_list); std::vector effective_size_results(nodes_list_len, 0.0); - + if (!G.attr("is_directed")().cast()){ Graph& G_ = G.cast(); for (int i = 0; i < nodes_list_len; i++) { @@ -339,7 +368,7 @@ py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object } std::reverse(effective_size_results.begin(), effective_size_results.end()); - + py::array::ShapeContainer ret_shape{nodes_list_len}; py::array_t ret(ret_shape, effective_size_results.data()); return ret; @@ -435,7 +464,7 @@ static py::object invoke_gpu_efficiency(py::object G, py::object nodes, py::obje nodes_list = py::list(nodes); for (auto node : nodes_list) { int node_id = node2idx[G_.node_to_id[node].cast()]; - node_mask[node_id] = 1; + node_mask[node_id] = 1; } } else { nodes_list = py::list(G.attr("nodes")); @@ -627,7 +656,7 @@ py::object invoke_cpp_hierarchy(py::object G, py::object nodes, py::object weigh py::list nodes_list = py::list(nodes); int nodes_list_len = py::len(nodes_list); py::dict hierarchy = py::dict(); - + if(G.attr("is_directed")().cast()){ DiGraph& G_ = G.cast(); for (int i = 0; i < nodes_list_len; i++) { @@ -761,7 +790,7 @@ static py::object invoke_gpu_hierarchy(py::object G, py::object nodes, py::objec std::vector& E = csr_graph->E; std::vector& row = coo_graph->row; std::vector& col = coo_graph->col; - std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) + std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) : coo_graph->W_map.find(weight_to_string(weight))->second.get(); std::unordered_map& node2idx = coo_graph->node2idx; int num_nodes = coo_graph->node2idx.size(); @@ -777,7 +806,7 @@ static py::object invoke_gpu_hierarchy(py::object G, py::object nodes, py::objec } } else { nodes_list = py::list(G.attr("nodes")); - std::fill(node_mask.begin(), node_mask.end(), 1); + std::fill(node_mask.begin(), node_mask.end(), 1); } int gpu_r = gpu_easygraph::hierarchy(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, hierarchy_results); From 6880fcf30d69af5a33a5208d8b6e34874d5a18ac Mon Sep 17 00:00:00 2001 From: sama Date: Thu, 8 Jan 2026 03:22:10 -0700 Subject: [PATCH 20/33] optimize constraint --- .../functions/structural_holes/evaluation.cpp | 272 +++++++++--------- 1 file changed, 136 insertions(+), 136 deletions(-) diff --git a/cpp_easygraph/functions/structural_holes/evaluation.cpp b/cpp_easygraph/functions/structural_holes/evaluation.cpp index 08bda4b3..4876b8f8 100644 --- a/cpp_easygraph/functions/structural_holes/evaluation.cpp +++ b/cpp_easygraph/functions/structural_holes/evaluation.cpp @@ -1,5 +1,12 @@ #include "evaluation.h" #include +#include +#include +#include +#include +#include +#include + #ifdef _OPENMP #include #endif @@ -55,120 +62,92 @@ weight_t directed_mutual_weight(DiGraph& G, node_t u, node_t v, std::string weig weight_t normalized_mutual_weight(Graph& G, node_t u, node_t v, std::string weight, norm_t norm, rec_type& nmw_rec) { std::pair edge = std::make_pair(u, v); - weight_t nmw; - if (nmw_rec.count(edge)) { - nmw = nmw_rec[edge]; - } else { - weight_t scale = 0; - for (auto& w : G.adj[u]) { - weight_t temp_weight = mutual_weight(G, u, w.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); - } - nmw = scale ? (mutual_weight(G, u, v, weight) / scale) : 0; - nmw_rec[edge] = nmw; - } + if (nmw_rec.count(edge)) return nmw_rec[edge]; + + weight_t scale = 0; + for (auto& w : G.adj[u]) { + weight_t temp_weight = mutual_weight(G, u, w.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + } + weight_t nmw = scale ? (mutual_weight(G, u, v, weight) / scale) : 0; + nmw_rec[edge] = nmw; return nmw; } weight_t directed_normalized_mutual_weight(DiGraph& G, node_t u, node_t v, std::string weight, norm_t norm, rec_type& nmw_rec) { std::pair edge = std::make_pair(u, v); - weight_t nmw; - if (nmw_rec.count(edge)) { - nmw = nmw_rec[edge]; - } else { - weight_t scale = 0; - for (auto& w : G.adj[u]) { - weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); - } - for (auto& w : G.pred[u]) { - weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); - } - nmw = scale ? (directed_mutual_weight(G, u, v, weight) / scale) : 0; - nmw_rec[edge] = nmw; + if (nmw_rec.count(edge)) return nmw_rec[edge]; + + weight_t scale = 0; + for (auto& w : G.adj[u]) { + weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); } + for (auto& w : G.pred[u]) { + weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + } + weight_t nmw = scale ? (directed_mutual_weight(G, u, v, weight) / scale) : 0; + nmw_rec[edge] = nmw; return nmw; } -weight_t directed_local_constraint(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { +weight_t local_constraint(Graph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { std::pair edge = std::make_pair(u, v); - if (local_constraint_rec.count(edge)) { - return local_constraint_rec[edge]; - } else { - weight_t direct = directed_normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); - weight_t indirect = 0; - std::unordered_set neighbors; - for (const auto& n : G.adj[v]) { - neighbors.insert(n.first); - } - for (const auto& n : G.pred[v]) { - neighbors.insert(n.first); - } - for (const auto& n : neighbors) { - if (n == v) { - continue; - } - indirect += directed_normalized_mutual_weight(G, u, n, weight, sum, sum_nmw_rec) * - directed_normalized_mutual_weight(G, n, v, weight, sum, sum_nmw_rec); - } - weight_t result = pow((direct + indirect), 2); - local_constraint_rec[edge] = result; - return result; - } + if (local_constraint_rec.count(edge)) return local_constraint_rec[edge]; + + weight_t direct = normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); + weight_t indirect = 0; + for (auto& w : G.adj[u]) { + if (w.first == v) continue; + indirect += normalized_mutual_weight(G, u, w.first, weight, sum, sum_nmw_rec) * + normalized_mutual_weight(G, w.first, v, weight, sum, sum_nmw_rec); + } + weight_t result = pow((direct + indirect), 2); + local_constraint_rec[edge] = result; + return result; } -weight_t local_constraint(Graph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { +weight_t directed_local_constraint(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { std::pair edge = std::make_pair(u, v); - if (local_constraint_rec.count(edge)) { - return local_constraint_rec[edge]; - } else { - weight_t direct = normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); - weight_t indirect = 0; - for (auto& w : G.adj[u]) { - if (w.first == v) { - continue; - } - indirect += normalized_mutual_weight(G, u, w.first, weight, sum, sum_nmw_rec) * - normalized_mutual_weight(G, w.first, v, weight, sum, sum_nmw_rec); - } - weight_t result = pow((direct + indirect), 2); - local_constraint_rec[edge] = result; - return result; - } -} + if (local_constraint_rec.count(edge)) return local_constraint_rec[edge]; -std::pair compute_constraint_of_v(Graph& G, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { - weight_t constraint_of_v = 0; - if (G.adj[v].size() == 0) { - constraint_of_v = Py_NAN; - } else { - for (const auto& n : G.adj[v]) { - weight_t local_cons = local_constraint(G, v, n.first, weight, local_constraint_rec, sum_nmw_rec); - constraint_of_v += local_cons; - } - } - return std::make_pair(v, constraint_of_v); + weight_t direct = directed_normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); + weight_t indirect = 0; + std::unordered_set neighbors; + for (const auto& n : G.adj[v]) neighbors.insert(n.first); + for (const auto& n : G.pred[v]) neighbors.insert(n.first); + + for (const auto& n : neighbors) { + if (n == v) continue; + indirect += directed_normalized_mutual_weight(G, u, n, weight, sum, sum_nmw_rec) * + directed_normalized_mutual_weight(G, n, v, weight, sum, sum_nmw_rec); + } + weight_t result = pow((direct + indirect), 2); + local_constraint_rec[edge] = result; + return result; } -std::pair directed_compute_constraint_of_v(DiGraph& G, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { - weight_t constraint_of_v = 0; - if (G.adj[v].size() == 0) { - constraint_of_v = Py_NAN; - } else { - std::unordered_set neighbors; - for (const auto& n : G.adj[v]) { - neighbors.insert(n.first); - } - for (const auto& n : G.pred[v]) { - neighbors.insert(n.first); - } - for (const auto& n : neighbors) { - weight_t local_cons = directed_local_constraint(G, v, n, weight, local_constraint_rec, sum_nmw_rec); - constraint_of_v += local_cons; +void preprocess_graph_for_constraint( + Graph& G, + std::string weight_key, + std::unordered_map>& weighted_adj, + std::unordered_map& strength +) { + for (auto& u_entry : G.adj) { + node_t u = u_entry.first; + for (auto& v_entry : u_entry.second) { + node_t v = v_entry.first; + double w = 1.0; + if (!weight_key.empty() && v_entry.second.count(weight_key)) { + w = v_entry.second[weight_key]; + } + weighted_adj[u][v] += w; + strength[u] += w; + weighted_adj[v][u] += w; + strength[v] += w; } } - return std::make_pair(v, constraint_of_v); } py::object invoke_cpp_constraint(py::object G, py::object nodes, py::object weight) { @@ -177,60 +156,81 @@ py::object invoke_cpp_constraint(py::object G, py::object nodes, py::object weig if (nodes.is_none()) { nodes = G.attr("nodes"); } - py::list nodes_list = py::list(nodes); int nodes_list_len = py::len(nodes_list); - std::vector constraint_results(nodes_list_len, 0.0); - + + Graph& G_ref = G.cast(); std::vector node_ids(nodes_list_len); - bool is_directed = G.attr("is_directed")().cast(); + for (int i = 0; i < nodes_list_len; i++) { + node_ids[i] = G_ref.node_to_id[nodes_list[i]].cast(); + } - if (is_directed) { - DiGraph& G_ = G.cast(); + std::unordered_map> weighted_adj; + std::unordered_map strength; + preprocess_graph_for_constraint(G_ref, weight_key, weighted_adj, strength); + + std::vector constraint_results(nodes_list_len, 0.0); + + { + py::gil_scoped_release release; + #pragma omp parallel for schedule(dynamic) for (int i = 0; i < nodes_list_len; i++) { - py::object v = nodes_list[i]; - node_ids[i] = G_.node_to_id[v].cast(); - } + node_t u = node_ids[i]; + + auto str_it = strength.find(u); + if (str_it == strength.end() || str_it->second == 0.0) { + constraint_results[i] = Py_NAN; + continue; + } + double u_strength = str_it->second; + + auto& neighbors_u = weighted_adj[u]; + if (neighbors_u.empty()) { + constraint_results[i] = Py_NAN; + continue; + } + + std::unordered_map contrib; + + for (auto& neighbor : neighbors_u) { + node_t j = neighbor.first; + double w_uj = neighbor.second; + double p_uj = w_uj / u_strength; + + contrib[j] += p_uj; + } + + for (auto& neighbor_j : neighbors_u) { + node_t j = neighbor_j.first; + double w_uj = neighbor_j.second; + double p_uj = w_uj / u_strength; + + auto q_it = weighted_adj.find(j); + if (q_it != weighted_adj.end()) { + double j_strength = strength[j]; + for (auto& neighbor_q : q_it->second) { + node_t q = neighbor_q.first; + if (q == u) continue; + + double w_jq = neighbor_q.second; + double p_jq = w_jq / j_strength; - { - py::gil_scoped_release release; - #pragma omp parallel - { - rec_type sum_nmw_rec_private, local_constraint_rec_private; - #pragma omp for schedule(static) - for (int i = 0; i < nodes_list_len; i++) { - std::pair constraint_pair = - directed_compute_constraint_of_v(G_, node_ids[i], weight_key, - local_constraint_rec_private, sum_nmw_rec_private); - constraint_results[i] = constraint_pair.second; + contrib[q] += p_uj * p_jq; + } } } - } - } else { - Graph& G_ = G.cast(); - for (int i = 0; i < nodes_list_len; i++) { - py::object v = nodes_list[i]; - node_ids[i] = G_.node_to_id[v].cast(); - } - { - py::gil_scoped_release release; - #pragma omp parallel - { - rec_type sum_nmw_rec_private, local_constraint_rec_private; - #pragma omp for schedule(static) - for (int i = 0; i < nodes_list_len; i++) { - std::pair constraint_pair = - compute_constraint_of_v(G_, node_ids[i], weight_key, - local_constraint_rec_private, sum_nmw_rec_private); - constraint_results[i] = constraint_pair.second; + double c_u = 0.0; + for (auto& neighbor : neighbors_u) { + node_t j = neighbor.first; + if (contrib.count(j)) { + c_u += pow(contrib[j], 2); } } + constraint_results[i] = c_u; } } - std::reverse(constraint_results.begin(), constraint_results.end()); - py::array::ShapeContainer ret_shape{nodes_list_len}; py::array_t ret(ret_shape, constraint_results.data()); return ret; From 4d248a94313409c04585df62b2028fcc68e77ae5 Mon Sep 17 00:00:00 2001 From: wuyazu Date: Sat, 10 Jan 2026 21:49:04 +0800 Subject: [PATCH 21/33] add omp --- .../functions/centrality/katz_centrality.cpp | 261 +++++++++++------- 1 file changed, 167 insertions(+), 94 deletions(-) diff --git a/cpp_easygraph/functions/centrality/katz_centrality.cpp b/cpp_easygraph/functions/centrality/katz_centrality.cpp index 63e78485..d8403a7f 100644 --- a/cpp_easygraph/functions/centrality/katz_centrality.cpp +++ b/cpp_easygraph/functions/centrality/katz_centrality.cpp @@ -1,120 +1,193 @@ -#include #include +#include +#include +#include + +#include #include #include + #include "centrality.h" #include "../../classes/graph.h" namespace py = pybind11; -py::object cpp_katz_centrality( - py::object G, - py::object py_alpha, - py::object py_beta, - py::object py_max_iter, - py::object py_tol, - py::object py_normalized -) { - try { - Graph& graph = G.cast(); - auto csr = graph.gen_CSR(); - int n = csr->nodes.size(); - - if (n == 0) { - return py::dict(); - } +class CSRMatrix { +public: + std::vector indptr; // size rows+1 + std::vector indices; // size nnz + std::vector data; // size nnz + int rows = 0; + int cols = 0; - // Initialize vectors - std::vector x0(n, 1.0); - std::vector x1(n); - std::vector* x_prev = &x0; - std::vector* x_next = &x1; - - // Process beta parameter - std::vector b(n); - if (py::isinstance(py_beta) || py::isinstance(py_beta)) { - double beta_val = py_beta.cast(); - for (int i = 0; i < n; i++) { - b[i] = beta_val; - } - } else if (py::isinstance(py_beta)) { - py::dict beta_dict = py_beta.cast(); - for (int i = 0; i < n; i++) { - node_t internal_id = csr->nodes[i]; - py::object node_obj = graph.id_to_node[py::cast(internal_id)]; - if (beta_dict.contains(node_obj)) { - b[i] = beta_dict[node_obj].cast(); - } else { - b[i] = 1.0; - } - } - } else { - throw py::type_error("beta must be a float or a dict"); + CSRMatrix() = default; + CSRMatrix(int r, int c) : rows(r), cols(c) { + indptr.assign(r + 1, 0); + } +}; + +// Build transpose CSR from EasyGraph CSR so that row i contains in-neighbors of i. +static CSRMatrix build_transpose_matrix_from_csr(const std::shared_ptr& csr_ptr) { + if (!csr_ptr) return CSRMatrix(); + + const int n = static_cast(csr_ptr->nodes.size()); + if (n == 0) return CSRMatrix(0, 0); + + const auto& src_indptr = csr_ptr->V; + const auto& src_indices = csr_ptr->E; + + // Unweighted: all ones. + std::vector src_data(src_indices.size(), 1.0); + + CSRMatrix At(n, n); + + // Count nnz per column in the source (becomes nnz per row in transpose). + for (int c : src_indices) { + if (c >= 0 && c < n) At.indptr[c + 1]++; + } + + // Prefix sum. + for (int i = 0; i < n; ++i) { + At.indptr[i + 1] += At.indptr[i]; + } + + const int nnz = static_cast(src_indices.size()); + At.indices.resize(nnz); + At.data.resize(nnz); + + std::vector cur_pos(At.indptr.begin(), At.indptr.end()); + + // Fill transpose. + for (int r = 0; r < n; ++r) { + const int start = src_indptr[r]; + const int end = src_indptr[r + 1]; + for (int p = start; p < end; ++p) { + const int c = src_indices[p]; + if (c < 0 || c >= n) continue; + const int dest = cur_pos[c]++; + At.indices[dest] = r; + At.data[dest] = src_data[p]; } + } - // Extract parameters - double alpha = py_alpha.cast(); - int max_iter = py_max_iter.cast(); - double tol = py_tol.cast(); - bool normalized = py_normalized.cast(); - - // Iterative updates - int iter = 0; - for (; iter < max_iter; iter++) { - for (int i = 0; i < n; i++) { - double sum = 0.0; - int start = csr->V[i]; - int end = csr->V[i + 1]; - for (int jj = start; jj < end; jj++) { - int j = csr->E[jj]; - sum += (*x_prev)[j]; - } - (*x_next)[i] = alpha * sum + b[i]; - } + return At; +} - // Check convergence - double change = 0.0; - for (int i = 0; i < n; i++) { - change += std::abs((*x_next)[i] - (*x_prev)[i]); - } +static std::vector katz_centrality_omp(const CSRMatrix& A, + double alpha, + const std::vector& beta, + int max_iters, + double tol, + bool normalize) { + const int n = A.rows; + std::vector x(n, 1.0); // initial guess + std::vector x_next(n, 0.0); // next iterate + if (n == 0) return x; + + for (int iter = 0; iter < max_iters; ++iter) { + double err_sq = 0.0; + double norm_sq = 0.0; + + // SpMV + Katz update + error and norm in ONE pass + #pragma omp parallel for reduction(+ : err_sq, norm_sq) schedule(static) + for (int i = 0; i < n; ++i) { + double sum = 0.0; + const int row_start = A.indptr[i]; + const int row_end = A.indptr[i + 1]; - if (change < tol) { - break; + for (int e = row_start; e < row_end; ++e) { + sum += A.data[e] * x[A.indices[e]]; } - std::swap(x_prev, x_next); + const double new_val = alpha * sum + beta[i]; + const double diff = new_val - x[i]; + + x_next[i] = new_val; + err_sq += diff * diff; + norm_sq += new_val * new_val; } - // Handle convergence failure - if (iter == max_iter) { - throw std::runtime_error("Katz centrality failed to converge in " + std::to_string(max_iter) + " iterations"); + const double err = std::sqrt(err_sq); + const double norm = std::sqrt(norm_sq); + + x.swap(x_next); + + if (norm > 0.0 && (err / norm) < tol) { + break; } + } - // Normalization - std::vector& x_final = *x_next; - if (normalized) { - double norm = 0.0; - for (double val : x_final) { - norm += val * val; - } - norm = std::sqrt(norm); - if (norm > 0) { - for (int i = 0; i < n; i++) { - x_final[i] /= norm; - } + if (normalize) { + double norm_sq2 = 0.0; + #pragma omp parallel for reduction(+ : norm_sq2) schedule(static) + for (int i = 0; i < n; ++i) { + norm_sq2 += x[i] * x[i]; + } + const double norm = std::sqrt(norm_sq2); + if (norm > 0.0) { + #pragma omp parallel for schedule(static) + for (int i = 0; i < n; ++i) { + x[i] /= norm; } } + } + + return x; +} - // Prepare results - py::dict result; - for (int i = 0; i < n; i++) { - node_t internal_id = csr->nodes[i]; +py::object cpp_katz_centrality(py::object G, + py::object py_alpha, + py::object py_beta, + py::object py_max_iter, + py::object py_tol, + py::object py_normalized) { + Graph& graph = G.cast(); + + const double alpha = py_alpha.cast(); + const int max_iter = py_max_iter.cast(); + const double tol = py_tol.cast(); + const bool normalized = py_normalized.cast(); + + std::shared_ptr csr_ptr = graph.gen_CSR(); + if (!csr_ptr || csr_ptr->nodes.empty()) { + return py::dict(); + } + + const int n = static_cast(csr_ptr->nodes.size()); + + // Build transpose CSR so that we accumulate from in-neighbors. + CSRMatrix A = build_transpose_matrix_from_csr(csr_ptr); + + // Process beta parameter: scalar or dict(node->beta). + std::vector beta(n, 1.0); + if (py::isinstance(py_beta) || py::isinstance(py_beta)) { + const double beta_val = py_beta.cast(); + #pragma omp parallel for schedule(static) + for (int i = 0; i < n; ++i) { + beta[i] = beta_val; + } + } else if (py::isinstance(py_beta)) { + py::dict beta_dict = py_beta.cast(); + for (int i = 0; i < n; ++i) { + node_t internal_id = csr_ptr->nodes[i]; py::object node_obj = graph.id_to_node[py::cast(internal_id)]; - result[node_obj] = x_final[i]; + if (beta_dict.contains(node_obj)) { + beta[i] = beta_dict[node_obj].cast(); + } } + } else { + throw py::type_error("beta must be a float/int or a dict"); + } - return result; - } catch (const std::exception& e) { - throw std::runtime_error(e.what()); + std::vector scores = katz_centrality_omp(A, alpha, beta, max_iter, tol, normalized); + + // Prepare results + py::dict result; + for (int i = 0; i < n; ++i) { + node_t internal_id = csr_ptr->nodes[i]; + py::object node_obj = graph.id_to_node[py::cast(internal_id)]; + result[node_obj] = scores[i]; } -} \ No newline at end of file + + return result; +} From fd63ce230f08d4d50af56a1385dde03436927bf9 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 11 Jan 2026 04:22:48 -0700 Subject: [PATCH 22/33] optimize pagerank --- cpp_easygraph/functions/pagerank/pagerank.cpp | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index 55dceea1..a88c8489 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -9,6 +9,11 @@ #include "../../common/utils.h" #include "../../classes/linkgraph.h" +struct IncomingEdge { + int source; + double weight; +}; + struct Page { Page() {} Page(const double &_newPR, const double &_oldPR) { newPR = _newPR; oldPR = _oldPR; } @@ -18,7 +23,6 @@ struct Page { py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6, py::object weight=py::none()) { bool is_directed = G.attr("is_directed")().cast(); - bool use_weights = !weight.is_none(); std::string weight_key = ""; if (use_weights) { @@ -31,22 +35,18 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub if (is_directed) { DiGraph& G_ = G.cast(); N = G_.node.size(); - if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); G_.linkgraph_dirty = false; } - G_l_ptr = &G_.linkgraph_structure; } else { Graph& G_ = G.cast(); N = G_.node.size(); - if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); G_.linkgraph_dirty = false; } - G_l_ptr = &G_.linkgraph_structure; } @@ -69,6 +69,15 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } } + std::vector> reverse_graph(N + 1); + for (int u = 1; u < N + 1; ++u) { + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + double w = use_weights ? E[p].w : 1.0; + reverse_graph[v].push_back({u, w}); + } + } + std::vector page(N + 1); #pragma omp parallel for for (int i = 1; i < N + 1; ++i) { @@ -80,9 +89,9 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub while (!shouldStop) { shouldStop = 1; - double res = 0.0; + double dangling_sum = 0.0; - #pragma omp parallel for reduction(+:res) + #pragma omp parallel for reduction(+:dangling_sum) for (int i = 1; i < N + 1; ++i) { bool is_dangling = false; if (use_weights) { @@ -90,53 +99,47 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } else { if (outDegree[i] == 0) is_dangling = true; } - if (is_dangling) res += page[i].oldPR; + if (is_dangling) dangling_sum += page[i].oldPR; } #pragma omp parallel for schedule(dynamic, 128) for (int i = 1; i < N + 1; ++i) { - if (use_weights) { - if (outDegree[i] == 0 || outWeightSum[i] == 0.0) continue; - } else { - if (outDegree[i] == 0) continue; - } - - if (!use_weights) { - double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; - for (int p = head[i]; p != -1; p = E[p].next) { - #pragma omp atomic - page[E[p].to].newPR += tmpPR; - } - } else { - double basePR = page[i].oldPR * alpha; - double inv_sum = 1.0 / outWeightSum[i]; - for (int p = head[i]; p != -1; p = E[p].next) { - double contribution = basePR * (E[p].w * inv_sum); - #pragma omp atomic - page[E[p].to].newPR += contribution; + double incoming_pr = 0.0; + + for (const auto& edge : reverse_graph[i]) { + int source = edge.source; + + if (use_weights) { + if (outWeightSum[source] > 0) { + incoming_pr += page[source].oldPR * (edge.weight / outWeightSum[source]); + } + } else { + if (outDegree[source] > 0) { + incoming_pr += page[source].oldPR / outDegree[source]; + } } } - } - double sum = 0.0; + page[i].newPR = (1.0 - alpha) / N + alpha * (dangling_sum / N + incoming_pr); + } - #pragma omp parallel for reduction(+:sum) + double diff_sum = 0.0; + #pragma omp parallel for reduction(+:diff_sum) for (int i = 1; i < N + 1; ++i) { - page[i].newPR += (1.0 - alpha) / N + (res / N) * alpha; - sum += std::fabs(page[i].newPR - page[i].oldPR); + diff_sum += std::fabs(page[i].newPR - page[i].oldPR); page[i].oldPR = page[i].newPR; page[i].newPR = 0.0; } - if (sum > threshold * N) shouldStop = 0; + if (diff_sum > threshold * N) shouldStop = 0; cnt++; if (cnt >= max_iterator) break; } - py::list res_lst; + py::list res; for (int i = 1; i < N + 1; ++i) { - res_lst.append(page[i].oldPR); + res.append(page[i].oldPR); } - return res_lst; -} + return res; +} \ No newline at end of file From 386f8868cbc2b5c4858c466fe5d38d1af882ff43 Mon Sep 17 00:00:00 2001 From: sama Date: Sun, 11 Jan 2026 23:40:05 -0700 Subject: [PATCH 23/33] optimize pagerank --- cpp_easygraph/functions/pagerank/pagerank.cpp | 75 +++++++++---------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index a88c8489..efe95d07 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -9,11 +9,6 @@ #include "../../common/utils.h" #include "../../classes/linkgraph.h" -struct IncomingEdge { - int source; - double weight; -}; - struct Page { Page() {} Page(const double &_newPR, const double &_oldPR) { newPR = _newPR; oldPR = _oldPR; } @@ -23,6 +18,7 @@ struct Page { py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6, py::object weight=py::none()) { bool is_directed = G.attr("is_directed")().cast(); + bool use_weights = !weight.is_none(); std::string weight_key = ""; if (use_weights) { @@ -35,18 +31,22 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub if (is_directed) { DiGraph& G_ = G.cast(); N = G_.node.size(); + if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); G_.linkgraph_dirty = false; } + G_l_ptr = &G_.linkgraph_structure; } else { Graph& G_ = G.cast(); N = G_.node.size(); + if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); G_.linkgraph_dirty = false; } + G_l_ptr = &G_.linkgraph_structure; } @@ -69,15 +69,6 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } } - std::vector> reverse_graph(N + 1); - for (int u = 1; u < N + 1; ++u) { - for (int p = head[u]; p != -1; p = E[p].next) { - int v = E[p].to; - double w = use_weights ? E[p].w : 1.0; - reverse_graph[v].push_back({u, w}); - } - } - std::vector page(N + 1); #pragma omp parallel for for (int i = 1; i < N + 1; ++i) { @@ -89,9 +80,9 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub while (!shouldStop) { shouldStop = 1; - double dangling_sum = 0.0; + double res = 0.0; - #pragma omp parallel for reduction(+:dangling_sum) + #pragma omp parallel for reduction(+:res) for (int i = 1; i < N + 1; ++i) { bool is_dangling = false; if (use_weights) { @@ -99,47 +90,53 @@ py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, doub } else { if (outDegree[i] == 0) is_dangling = true; } - if (is_dangling) dangling_sum += page[i].oldPR; + if (is_dangling) res += page[i].oldPR; } #pragma omp parallel for schedule(dynamic, 128) for (int i = 1; i < N + 1; ++i) { - double incoming_pr = 0.0; - - for (const auto& edge : reverse_graph[i]) { - int source = edge.source; - - if (use_weights) { - if (outWeightSum[source] > 0) { - incoming_pr += page[source].oldPR * (edge.weight / outWeightSum[source]); - } - } else { - if (outDegree[source] > 0) { - incoming_pr += page[source].oldPR / outDegree[source]; - } - } + if (use_weights) { + if (outDegree[i] == 0 || outWeightSum[i] == 0.0) continue; + } else { + if (outDegree[i] == 0) continue; } - page[i].newPR = (1.0 - alpha) / N + alpha * (dangling_sum / N + incoming_pr); + if (!use_weights) { + double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; + for (int p = head[i]; p != -1; p = E[p].next) { + #pragma omp atomic + page[E[p].to].newPR += tmpPR; + } + } else { + double basePR = page[i].oldPR * alpha; + double inv_sum = 1.0 / outWeightSum[i]; + for (int p = head[i]; p != -1; p = E[p].next) { + double contribution = basePR * (E[p].w * inv_sum); + #pragma omp atomic + page[E[p].to].newPR += contribution; + } + } } - double diff_sum = 0.0; - #pragma omp parallel for reduction(+:diff_sum) + double sum = 0.0; + + #pragma omp parallel for reduction(+:sum) for (int i = 1; i < N + 1; ++i) { - diff_sum += std::fabs(page[i].newPR - page[i].oldPR); + page[i].newPR += (1.0 - alpha) / N + (res / N) * alpha; + sum += std::fabs(page[i].newPR - page[i].oldPR); page[i].oldPR = page[i].newPR; page[i].newPR = 0.0; } - if (diff_sum > threshold * N) shouldStop = 0; + if (sum > threshold * N) shouldStop = 0; cnt++; if (cnt >= max_iterator) break; } - py::list res; + py::list res_lst; for (int i = 1; i < N + 1; ++i) { - res.append(page[i].oldPR); + res_lst.append(page[i].oldPR); } - return res; + return res_lst; } \ No newline at end of file From 6bebe84994220565fdff6b1a307b2a03b1c180ee Mon Sep 17 00:00:00 2001 From: sama Date: Thu, 15 Jan 2026 03:26:47 -0600 Subject: [PATCH 24/33] optimize effective_size --- .../functions/structural_holes/evaluation.cpp | 672 ++++-------------- 1 file changed, 158 insertions(+), 514 deletions(-) diff --git a/cpp_easygraph/functions/structural_holes/evaluation.cpp b/cpp_easygraph/functions/structural_holes/evaluation.cpp index 4876b8f8..4a50f6ce 100644 --- a/cpp_easygraph/functions/structural_holes/evaluation.cpp +++ b/cpp_easygraph/functions/structural_holes/evaluation.cpp @@ -34,99 +34,78 @@ enum norm_t { max }; -weight_t mutual_weight(Graph& G, node_t u, node_t v, std::string weight) { +weight_t mutual_weight(const Graph& G, node_t u, node_t v, const std::string& weight) { weight_t a_uv = 0, a_vu = 0; - if (G.adj.count(u) && G.adj[u].count(v)) { - edge_attr_dict_factory& guv = G.adj[u][v]; - a_uv = guv.count(weight) ? guv[weight] : 1; + if (G.adj.count(u)) { + const auto& adj_u = G.adj.at(u); + auto it = adj_u.find(v); + if (it != adj_u.end()) { + const auto& guv = it->second; + a_uv = guv.count(weight) ? guv.at(weight) : 1; + } } - if (G.adj.count(v) && G.adj[v].count(u)) { - edge_attr_dict_factory& gvu = G.adj[v][u]; - a_vu = gvu.count(weight) ? gvu[weight] : 1; + if (G.adj.count(v)) { + const auto& adj_v = G.adj.at(v); + auto it = adj_v.find(u); + if (it != adj_v.end()) { + const auto& gvu = it->second; + a_vu = gvu.count(weight) ? gvu.at(weight) : 1; + } } return a_uv + a_vu; } -weight_t directed_mutual_weight(DiGraph& G, node_t u, node_t v, std::string weight) { +weight_t directed_mutual_weight(const DiGraph& G, node_t u, node_t v, const std::string& weight) { weight_t a_uv = 0, a_vu = 0; - if (G.adj.count(u) && G.adj[u].count(v)) { - edge_attr_dict_factory& guv = G.adj[u][v]; - a_uv = guv.count(weight) ? guv[weight] : 1; + if (G.adj.count(u)) { + const auto& adj_u = G.adj.at(u); + auto it = adj_u.find(v); + if (it != adj_u.end()) { + const auto& guv = it->second; + a_uv = guv.count(weight) ? guv.at(weight) : 1; + } } - if (G.adj.count(v) && G.adj[v].count(u)) { - edge_attr_dict_factory& gvu = G.adj[v][u]; - a_vu = gvu.count(weight) ? gvu[weight] : 1; + if (G.adj.count(v)) { + const auto& adj_v = G.adj.at(v); + auto it = adj_v.find(u); + if (it != adj_v.end()) { + const auto& gvu = it->second; + a_vu = gvu.count(weight) ? gvu.at(weight) : 1; + } } return a_uv + a_vu; } -weight_t normalized_mutual_weight(Graph& G, node_t u, node_t v, std::string weight, norm_t norm, rec_type& nmw_rec) { - std::pair edge = std::make_pair(u, v); - if (nmw_rec.count(edge)) return nmw_rec[edge]; - +weight_t normalized_mutual_weight(const Graph& G, node_t u, node_t v, const std::string& weight, norm_t norm) { weight_t scale = 0; - for (auto& w : G.adj[u]) { - weight_t temp_weight = mutual_weight(G, u, w.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + if (G.adj.count(u)) { + for (const auto& w_pair : G.adj.at(u)) { + weight_t temp_weight = mutual_weight(G, u, w_pair.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + } } weight_t nmw = scale ? (mutual_weight(G, u, v, weight) / scale) : 0; - nmw_rec[edge] = nmw; return nmw; } -weight_t directed_normalized_mutual_weight(DiGraph& G, node_t u, node_t v, std::string weight, norm_t norm, rec_type& nmw_rec) { - std::pair edge = std::make_pair(u, v); - if (nmw_rec.count(edge)) return nmw_rec[edge]; - +weight_t directed_normalized_mutual_weight(const DiGraph& G, node_t u, node_t v, const std::string& weight, norm_t norm) { weight_t scale = 0; - for (auto& w : G.adj[u]) { - weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + if (G.adj.count(u)) { + for (const auto& w_pair : G.adj.at(u)) { + weight_t temp_weight = directed_mutual_weight(G, u, w_pair.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + } } - for (auto& w : G.pred[u]) { - weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + if (G.pred.count(u)) { + for (const auto& w_pair : G.pred.at(u)) { + weight_t temp_weight = directed_mutual_weight(G, u, w_pair.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); + } } weight_t nmw = scale ? (directed_mutual_weight(G, u, v, weight) / scale) : 0; - nmw_rec[edge] = nmw; return nmw; } -weight_t local_constraint(Graph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { - std::pair edge = std::make_pair(u, v); - if (local_constraint_rec.count(edge)) return local_constraint_rec[edge]; - - weight_t direct = normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); - weight_t indirect = 0; - for (auto& w : G.adj[u]) { - if (w.first == v) continue; - indirect += normalized_mutual_weight(G, u, w.first, weight, sum, sum_nmw_rec) * - normalized_mutual_weight(G, w.first, v, weight, sum, sum_nmw_rec); - } - weight_t result = pow((direct + indirect), 2); - local_constraint_rec[edge] = result; - return result; -} - -weight_t directed_local_constraint(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { - std::pair edge = std::make_pair(u, v); - if (local_constraint_rec.count(edge)) return local_constraint_rec[edge]; - - weight_t direct = directed_normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); - weight_t indirect = 0; - std::unordered_set neighbors; - for (const auto& n : G.adj[v]) neighbors.insert(n.first); - for (const auto& n : G.pred[v]) neighbors.insert(n.first); - - for (const auto& n : neighbors) { - if (n == v) continue; - indirect += directed_normalized_mutual_weight(G, u, n, weight, sum, sum_nmw_rec) * - directed_normalized_mutual_weight(G, n, v, weight, sum, sum_nmw_rec); - } - weight_t result = pow((direct + indirect), 2); - local_constraint_rec[edge] = result; - return result; -} void preprocess_graph_for_constraint( Graph& G, @@ -290,84 +269,139 @@ py::object constraint(py::object G, py::object nodes, py::object weight, py::obj #endif } -weight_t redundancy(Graph& G, node_t u, node_t v, std::string weight, rec_type& sum_nmw_rec, rec_type& max_nmw_rec) { +weight_t redundancy(const Graph& G, node_t u, node_t v, const std::string& weight) { weight_t r = 0; - std::unordered_set neighbors; - for (const auto& n : G.adj[v]) { - neighbors.insert(n.first); - } - for (const auto& w : neighbors) { - r += normalized_mutual_weight(G, u, w, weight, sum, sum_nmw_rec) * normalized_mutual_weight(G, v, w, weight, max, max_nmw_rec); + if (G.adj.count(v)) { + for (const auto& n_pair : G.adj.at(v)) { + node_t w = n_pair.first; + r += normalized_mutual_weight(G, u, w, weight, sum) * normalized_mutual_weight(G, v, w, weight, max); + } } return 1 - r; } -weight_t directed_redundancy(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& sum_nmw_rec, rec_type& max_nmw_rec) { +weight_t directed_redundancy(const DiGraph& G, node_t u, node_t v, const std::string& weight) { weight_t r = 0; std::unordered_set neighbors; - for (const auto& n : G.adj[v]) { - neighbors.insert(n.first); + if (G.adj.count(v)) { + for (const auto& n : G.adj.at(v)) neighbors.insert(n.first); } - for (const auto& n : G.pred[v]) { - neighbors.insert(n.first); + if (G.pred.count(v)) { + for (const auto& n : G.pred.at(v)) neighbors.insert(n.first); } + for (const auto& w : neighbors) { - r += directed_normalized_mutual_weight(G, u, w, weight, sum, sum_nmw_rec) * directed_normalized_mutual_weight(G, v, w, weight, max, max_nmw_rec); + r += directed_normalized_mutual_weight(G, u, w, weight, sum) * directed_normalized_mutual_weight(G, v, w, weight, max); } return 1 - r; } py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object weight) { - std::string weight_key = weight_to_string(weight); - rec_type sum_nmw_rec, max_nmw_rec; + std::string weight_key = weight_to_string(weight); + bool is_directed = G.attr("is_directed")().cast(); if (nodes.is_none()) { nodes = G.attr("nodes"); } - py::list nodes_list = py::list(nodes); int nodes_list_len = py::len(nodes_list); + + Graph& G_ref = G.cast(); + py::object node_to_id = G_ref.node_to_id; + + std::vector node_ids(nodes_list_len); + for (int i = 0; i < nodes_list_len; i++) { + node_ids[i] = node_to_id[nodes_list[i]].cast(); + } + std::vector effective_size_results(nodes_list_len, 0.0); - if (!G.attr("is_directed")().cast()){ - Graph& G_ = G.cast(); - for (int i = 0; i < nodes_list_len; i++) { - weight_t redundancy_sum = 0; - py::object v = nodes_list[i]; - node_t v_id = G_.node_to_id[v].cast(); - if (py::len(G[v]) == 0) { - effective_size_results[i] = Py_NAN; - continue; - } - for (const auto& neighbor_info : G_.adj[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - effective_size_results[i] = redundancy_sum; - } - } else{ - DiGraph& G_ = G.cast(); - for (int i = 0; i < nodes_list_len; i++) { - weight_t redundancy_sum = 0; - py::object v = nodes_list[i]; - node_t v_id = G_.node_to_id[v].cast(); - if (py::len(G[v]) == 0) { - effective_size_results[i] = Py_NAN; - continue; - } - for (const auto& neighbor_info : G_.adj[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); + bool use_fast_path = !is_directed && (weight.is_none()); + + { + py::gil_scoped_release release; + + if (!is_directed) { + const Graph& G_ = G.cast(); + + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < nodes_list_len; i++) { + node_t v_id = node_ids[i]; + + if (G_.adj.find(v_id) == G_.adj.end() || G_.adj.at(v_id).empty()) { + effective_size_results[i] = NAN; + continue; + } + + if (use_fast_path) { + const auto& v_neighbors = G_.adj.at(v_id); + double n = (double)v_neighbors.size(); + double sum_common = 0; + + for (const auto& u_pair : v_neighbors) { + node_t u = u_pair.first; + if (u == v_id) continue; + + if (G_.adj.count(u)) { + const auto& u_neighbors = G_.adj.at(u); + if (v_neighbors.size() < u_neighbors.size()) { + for (const auto& w_pair : v_neighbors) { + node_t w = w_pair.first; + if (w == u) continue; + if (u_neighbors.count(w)) sum_common += 1.0; + } + } else { + for (const auto& w_pair : u_neighbors) { + node_t w = w_pair.first; + if (w == v_id) continue; + if (v_neighbors.count(w)) sum_common += 1.0; + } + } + } + } + effective_size_results[i] = n - (sum_common / n); + + } else { + double redundancy_sum = 0; + for (const auto& neighbor_info : G_.adj.at(v_id)) { + node_t u_id = neighbor_info.first; + redundancy_sum += redundancy(G_, v_id, u_id, weight_key); + } + effective_size_results[i] = redundancy_sum; + } } - for (const auto& neighbor_info : G_.pred[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); + } else { + const DiGraph& G_ = G.cast(); + + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < nodes_list_len; i++) { + node_t v_id = node_ids[i]; + + bool has_neighbors = (G_.adj.count(v_id) && !G_.adj.at(v_id).empty()) || + (G_.pred.count(v_id) && !G_.pred.at(v_id).empty()); + + if (!has_neighbors) { + effective_size_results[i] = NAN; + continue; + } + + double redundancy_sum = 0; + if (G_.adj.count(v_id)) { + for (const auto& neighbor_info : G_.adj.at(v_id)) { + node_t u_id = neighbor_info.first; + redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key); + } + } + if (G_.pred.count(v_id)) { + for (const auto& neighbor_info : G_.pred.at(v_id)) { + node_t u_id = neighbor_info.first; + redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key); + } + } + effective_size_results[i] = redundancy_sum; } - effective_size_results[i] = redundancy_sum; } - } - - std::reverse(effective_size_results.begin(), effective_size_results.end()); + } py::array::ShapeContainer ret_shape{nodes_list_len}; py::array_t ret(ret_shape, effective_size_results.data()); @@ -433,402 +467,12 @@ py::object effective_size(py::object G, py::object nodes, py::object weight, py: #endif } -#ifdef EASYGRAPH_ENABLE_GPU -static py::object invoke_gpu_efficiency(py::object G, py::object nodes, py::object weight) { - Graph& G_ = G.cast(); - py::dict effective_size = py::dict(); - if (weight.is_none()) { - G_.gen_CSR(); - } else { - G_.gen_CSR(weight_to_string(weight)); - } - auto csr_graph = G_.csr_graph; - auto coo_graph = G_.transfer_csr_to_coo(csr_graph); - - std::vector& V = csr_graph->V; - std::vector& E = csr_graph->E; - std::vector& row = coo_graph->row; - std::vector& col = coo_graph->col; - - std::vector* W_p = weight.is_none() ? &(coo_graph->unweighted_W) - : coo_graph->W_map.find(weight_to_string(weight))->second.get(); - - std::unordered_map& node2idx = coo_graph->node2idx; - int num_nodes = coo_graph->node2idx.size(); - std::vector effective_size_results(num_nodes); - bool is_directed = G.attr("is_directed")().cast(); - - std::vector node_mask(num_nodes, 0); - py::list nodes_list; - if (!nodes.is_none()) { - nodes_list = py::list(nodes); - for (auto node : nodes_list) { - int node_id = node2idx[G_.node_to_id[node].cast()]; - node_mask[node_id] = 1; - } - } else { - nodes_list = py::list(G.attr("nodes")); - std::fill(node_mask.begin(), node_mask.end(), 1); - } - - int gpu_r = gpu_easygraph::effective_size(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, effective_size_results); - - if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { - py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); - } - - py::dict effective_size_dict; - for (auto node : nodes_list) { - int node_id = G_.node_to_id[node].cast(); - int idx = node2idx[node_id]; - - py::object node_name = G_.id_to_node.attr("get")(py::cast(node_id)); - effective_size_dict[node_name] = py::cast(effective_size_results[idx]); - } - py::dict degree; - if (weight.is_none()) { - degree = G.attr("degree")(py::none()).cast(); - } else { - degree = G.attr("degree")(weight).cast(); - } - - py::dict efficiency_dict; - for (auto item : effective_size_dict) { - int node = py::reinterpret_borrow(item.first).cast(); - double eff_size = py::reinterpret_borrow(item.second).cast(); - - if (!degree.contains(py::cast(node))) { - continue; - } - double node_degree = py::reinterpret_borrow(degree[py::cast(node)]).cast(); - if (node_degree == 0.0) { - efficiency_dict[py::cast(node)] = py::cast(Py_NAN); - } else { - double efficiency_value = eff_size / node_degree; - efficiency_dict[py::cast(node)] = py::cast(efficiency_value); - } - } - return efficiency_dict; +py::object efficiency(py::object G, py::object nodes, py::object weight, py::object ignored_arg) { + return py::none(); } -#endif - -py::object invoke_cpp_efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { - rec_type sum_nmw_rec, max_nmw_rec; - py::dict effective_size_dict = py::dict(); - if (nodes.is_none()) { - nodes = G; - } - nodes = py::list(nodes); - if (!G.attr("is_directed")().cast()){ - Graph& G_ = G.cast(); - std::string weight_key = weight_to_string(weight); - int nodes_len = py::len(nodes); - for (int i = 0; i < nodes_len; i++) { - py::object v = nodes[py::cast(i)]; - if (py::len(G[v]) == 0) { - effective_size_dict[v] = py::cast(Py_NAN); - continue; - } - weight_t redundancy_sum = 0; - node_t v_id = G_.node_to_id[v].cast(); - for (const auto& neighbor_info : G_.adj[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - effective_size_dict[v] = redundancy_sum; - } - } else{ - DiGraph& G_ = G.cast(); - std::string weight_key = weight_to_string(weight); - int nodes_len = py::len(nodes); - for (int i = 0; i < nodes_len; i++) { - py::object v = nodes[py::cast(i)]; - if (py::len(G[v]) == 0) { - effective_size_dict[v] = py::cast(Py_NAN); - continue; - } - weight_t redundancy_sum = 0; - node_t v_id = G_.node_to_id[v].cast(); - for (const auto& neighbor_info : G_.adj[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - for (const auto& neighbor_info : G_.pred[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - effective_size_dict[v] = redundancy_sum; - } - } - - py::dict degree; - if (weight.is_none()) { - degree = G.attr("degree")(py::none()).cast(); - } else { - degree = G.attr("degree")(weight).cast(); - } - - py::dict efficiency_dict; - for (auto item : effective_size_dict) { - int node = py::reinterpret_borrow(item.first).cast(); - double eff_size = py::reinterpret_borrow(item.second).cast(); - - if (!degree.contains(py::cast(node))) { - continue; - } - - double node_degree = py::reinterpret_borrow(degree[py::cast(node)]).cast(); - if (node_degree == 0.0) { - efficiency_dict[py::cast(node)] = py::cast(Py_NAN); - } else { - double efficiency_value = eff_size / node_degree; - efficiency_dict[py::cast(node)] = py::cast(efficiency_value); - } - } - - return efficiency_dict; -} - -py::object efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { -#ifdef EASYGRAPH_ENABLE_GPU - return invoke_gpu_efficiency(G, nodes, weight); -#else - return invoke_cpp_efficiency(G, nodes, weight, n_workers); -#endif -} - -void hierarchy_parallel(Graph* G, std::vector* nodes, std::string weight, std::unordered_map* ret) { - rec_type local_constraint_rec, sum_nmw_rec; - for (node_t v : *nodes) { - int n = G->adj[v].size(); // len(G.ego_subgraph(v)) - 1 - weight_t C = 0; - std::unordered_map c; - for (const auto& w_pair : G->adj[v]) { - node_t w = w_pair.first; - C += local_constraint(*G, v, w, weight, local_constraint_rec, sum_nmw_rec); - c[w] = local_constraint(*G, v, w, weight, local_constraint_rec, sum_nmw_rec); - } - if (n > 1) { - weight_t sum = 0; - for (const auto& w_pair : G->adj[v]) { - node_t w = w_pair.first; - sum += c[w] / C * n * log(c[w] / C * n) / (n * log(n)); - } - (*ret)[v] = sum; - } - else { - (*ret)[v] = 0; - } - } -} - -inline std::vector > split_len(const std::vector& nodes, int step) { - std::vector > ret; - for (int i = 0; i < nodes.size();i += step) { - ret.emplace_back(nodes.begin() + i, (i + step > nodes.size()) ? nodes.end() : nodes.begin() + i + step); - } - if (ret.back().size() * 3 < step) { - ret[ret.size() - 2].insert(ret[ret.size() - 2].end(), ret.back().begin(), ret.back().end()); - ret.pop_back(); - } - return ret; -} - -inline std::vector > split(const std::vector& nodes, int n) { - std::vector > ret; - int length = nodes.size(); - int step = length / n + 1; - for (int i = 0;i < length;i += step) { - ret.emplace_back(nodes.begin() + i, i + step > length ? nodes.end() : nodes.begin() + i + step); - } - return ret; -} - -py::object invoke_cpp_hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers) { - rec_type local_constraint_rec, sum_nmw_rec; - std::string weight_key = weight_to_string(weight); - if (nodes.is_none()) { - nodes = G.attr("nodes"); - } - py::list nodes_list = py::list(nodes); - int nodes_list_len = py::len(nodes_list); - py::dict hierarchy = py::dict(); - - if(G.attr("is_directed")().cast()){ - DiGraph& G_ = G.cast(); - for (int i = 0; i < nodes_list_len; i++) { - py::object v = nodes_list[i]; - weight_t C = 0; - std::map c; - - py::list successors_of_v = py::list(G.attr("successors")(v)); - py::list predecessors_of_v = py::list(G.attr("predecessors")(v)); - - std::set neighbors_of_v; - for (const auto& w : successors_of_v) { - neighbors_of_v.insert(G_.node_to_id[w].cast()); - } - for (const auto& w : predecessors_of_v) { - neighbors_of_v.insert(G_.node_to_id[w].cast()); - } - - for (const auto& w_id : neighbors_of_v) { - node_t v_id = G_.node_to_id[v].cast(); - - C += directed_local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); - c[w_id] = directed_local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); - } - int n = neighbors_of_v.size(); - - if (n > 1) { - weight_t hierarchy_sum = 0; - for (const auto& w_id : neighbors_of_v) { - hierarchy_sum += c[w_id] / C * n * log(c[w_id] / C * n) / (n * log(n)); - } - hierarchy[v] = hierarchy_sum; - } - - if (!hierarchy.contains(v)) { - hierarchy[v] = 0; - } - } - - }else{ - Graph& G_ = G.cast(); - if (!n_workers.is_none()) { - std::vector node_ids; - int n_workers_num = n_workers.cast(); - for (int i = 0;i < py::len(nodes_list);i++) { - py::object node = nodes_list[i]; - node_ids.push_back(G_.node_to_id[node].cast()); - } - std::shuffle(node_ids.begin(), node_ids.end(), std::random_device()); - std::vector > split_nodes; - if (node_ids.size() > n_workers_num * 30000) { - split_nodes = split_len(node_ids, 30000); - } - else { - split_nodes = split(node_ids, n_workers_num); - } - while (split_nodes.size() < n_workers_num) { - split_nodes.push_back(std::vector()); - } - std::vector > rets(n_workers_num); - Py_BEGIN_ALLOW_THREADS - - std::vector threads; - for (int i = 0;i < n_workers_num; i++) { - threads.push_back(std::thread(hierarchy_parallel, &G_, &split_nodes[i], weight_key, &rets[i])); - } - for (int i = 0;i < n_workers_num;i++) { - threads[i].join(); - } - - Py_END_ALLOW_THREADS - - for (int i = 1;i < rets.size();i++) { - rets[0].insert(rets[i].begin(), rets[i].end()); - } - for (const auto& hierarchy_pair : rets[0]) { - py::object node = G_.id_to_node[py::cast(hierarchy_pair.first)]; - hierarchy[node] = hierarchy_pair.second; - } - } - else { - for (int i = 0; i < nodes_list_len; i++) { - py::object v = nodes_list[i]; - py::object E = G.attr("ego_subgraph")(v); - - int n = py::len(E) - 1; - - weight_t C = 0; - std::map c; - py::list neighbors_of_v = py::list(G.attr("neighbors")(v)); - int neighbors_of_v_len = py::len(neighbors_of_v); - for (int j = 0; j < neighbors_of_v_len; j++) { - py::object w = neighbors_of_v[j]; - node_t v_id = G_.node_to_id[v].cast(); - node_t w_id = G_.node_to_id[w].cast(); - C += local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); - c[w_id] = local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); - } - if (n > 1) { - weight_t hierarchy_sum = 0; - int neighbors_of_v_len = py::len(neighbors_of_v); - for (int k = 0; k < neighbors_of_v_len; k++) { - py::object w = neighbors_of_v[k]; - node_t w_id = G_.node_to_id[w].cast(); - hierarchy_sum += c[w_id] / C * n * log(c[w_id] / C * n) / (n * log(n)); - } - hierarchy[v] = hierarchy_sum; - } - if (!hierarchy.contains(v)) { - hierarchy[v] = 0; - } - } - } - } - - - return hierarchy; -} - -#ifdef EASYGRAPH_ENABLE_GPU -static py::object invoke_gpu_hierarchy(py::object G, py::object nodes, py::object weight) { - Graph& G_ = G.cast(); - if (weight.is_none()) { - G_.gen_CSR(); - } else { - G_.gen_CSR(weight_to_string(weight)); - } - auto csr_graph = G_.csr_graph; - auto coo_graph = G_.transfer_csr_to_coo(csr_graph); - std::vector& V = csr_graph->V; - std::vector& E = csr_graph->E; - std::vector& row = coo_graph->row; - std::vector& col = coo_graph->col; - std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) - : coo_graph->W_map.find(weight_to_string(weight))->second.get(); - std::unordered_map& node2idx = coo_graph->node2idx; - int num_nodes = coo_graph->node2idx.size(); - bool is_directed = G.attr("is_directed")().cast(); - std::vector hierarchy_results; - std::vector node_mask(num_nodes, 0); - py::list nodes_list; - if (!nodes.is_none()) { - nodes_list = py::list(nodes); - for (auto node : nodes_list) { - int node_id = node2idx[G_.node_to_id[node].cast()]; - node_mask[node_id] = 1; - } - } else { - nodes_list = py::list(G.attr("nodes")); - std::fill(node_mask.begin(), node_mask.end(), 1); - } - - int gpu_r = gpu_easygraph::hierarchy(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, hierarchy_results); - if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { - py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); - } - py::dict hierarchy_dict; - for (auto node : nodes_list) { - int node_id = G_.node_to_id[node].cast(); - int idx = node2idx[node_id]; - - py::object node_name = G_.id_to_node.attr("get")(py::cast(node_id)); - hierarchy_dict[node_name] = py::cast(hierarchy_results[idx]); - } - return hierarchy_dict; -} -#endif - -py::object hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers) { -#ifdef EASYGRAPH_ENABLE_GPU - return invoke_gpu_hierarchy(G, nodes, weight); -#else - return invoke_cpp_hierarchy(G, nodes, weight, n_workers); -#endif +py::object hierarchy(py::object G, py::object nodes, py::object weight, py::object ignored_arg) { + return py::none(); } \ No newline at end of file From eab386a04a404a33314827943391607551777a6b Mon Sep 17 00:00:00 2001 From: sama Date: Thu, 15 Jan 2026 23:04:50 -0600 Subject: [PATCH 25/33] optimize effective_size --- .../functions/structural_holes/evaluation.cpp | 838 +++++++++++++++--- 1 file changed, 695 insertions(+), 143 deletions(-) diff --git a/cpp_easygraph/functions/structural_holes/evaluation.cpp b/cpp_easygraph/functions/structural_holes/evaluation.cpp index 4a50f6ce..59b93358 100644 --- a/cpp_easygraph/functions/structural_holes/evaluation.cpp +++ b/cpp_easygraph/functions/structural_holes/evaluation.cpp @@ -34,78 +34,99 @@ enum norm_t { max }; -weight_t mutual_weight(const Graph& G, node_t u, node_t v, const std::string& weight) { +weight_t mutual_weight(Graph& G, node_t u, node_t v, std::string weight) { weight_t a_uv = 0, a_vu = 0; - if (G.adj.count(u)) { - const auto& adj_u = G.adj.at(u); - auto it = adj_u.find(v); - if (it != adj_u.end()) { - const auto& guv = it->second; - a_uv = guv.count(weight) ? guv.at(weight) : 1; - } + if (G.adj.count(u) && G.adj[u].count(v)) { + edge_attr_dict_factory& guv = G.adj[u][v]; + a_uv = guv.count(weight) ? guv[weight] : 1; } - if (G.adj.count(v)) { - const auto& adj_v = G.adj.at(v); - auto it = adj_v.find(u); - if (it != adj_v.end()) { - const auto& gvu = it->second; - a_vu = gvu.count(weight) ? gvu.at(weight) : 1; - } + if (G.adj.count(v) && G.adj[v].count(u)) { + edge_attr_dict_factory& gvu = G.adj[v][u]; + a_vu = gvu.count(weight) ? gvu[weight] : 1; } return a_uv + a_vu; } -weight_t directed_mutual_weight(const DiGraph& G, node_t u, node_t v, const std::string& weight) { +weight_t directed_mutual_weight(DiGraph& G, node_t u, node_t v, std::string weight) { weight_t a_uv = 0, a_vu = 0; - if (G.adj.count(u)) { - const auto& adj_u = G.adj.at(u); - auto it = adj_u.find(v); - if (it != adj_u.end()) { - const auto& guv = it->second; - a_uv = guv.count(weight) ? guv.at(weight) : 1; - } + if (G.adj.count(u) && G.adj[u].count(v)) { + edge_attr_dict_factory& guv = G.adj[u][v]; + a_uv = guv.count(weight) ? guv[weight] : 1; } - if (G.adj.count(v)) { - const auto& adj_v = G.adj.at(v); - auto it = adj_v.find(u); - if (it != adj_v.end()) { - const auto& gvu = it->second; - a_vu = gvu.count(weight) ? gvu.at(weight) : 1; - } + if (G.adj.count(v) && G.adj[v].count(u)) { + edge_attr_dict_factory& gvu = G.adj[v][u]; + a_vu = gvu.count(weight) ? gvu[weight] : 1; } return a_uv + a_vu; } -weight_t normalized_mutual_weight(const Graph& G, node_t u, node_t v, const std::string& weight, norm_t norm) { +weight_t normalized_mutual_weight(Graph& G, node_t u, node_t v, std::string weight, norm_t norm, rec_type& nmw_rec) { + std::pair edge = std::make_pair(u, v); + if (nmw_rec.count(edge)) return nmw_rec[edge]; + weight_t scale = 0; - if (G.adj.count(u)) { - for (const auto& w_pair : G.adj.at(u)) { - weight_t temp_weight = mutual_weight(G, u, w_pair.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); - } + for (auto& w : G.adj[u]) { + weight_t temp_weight = mutual_weight(G, u, w.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); } weight_t nmw = scale ? (mutual_weight(G, u, v, weight) / scale) : 0; + nmw_rec[edge] = nmw; return nmw; } -weight_t directed_normalized_mutual_weight(const DiGraph& G, node_t u, node_t v, const std::string& weight, norm_t norm) { +weight_t directed_normalized_mutual_weight(DiGraph& G, node_t u, node_t v, std::string weight, norm_t norm, rec_type& nmw_rec) { + std::pair edge = std::make_pair(u, v); + if (nmw_rec.count(edge)) return nmw_rec[edge]; + weight_t scale = 0; - if (G.adj.count(u)) { - for (const auto& w_pair : G.adj.at(u)) { - weight_t temp_weight = directed_mutual_weight(G, u, w_pair.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); - } + for (auto& w : G.adj[u]) { + weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); } - if (G.pred.count(u)) { - for (const auto& w_pair : G.pred.at(u)) { - weight_t temp_weight = directed_mutual_weight(G, u, w_pair.first, weight); - scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); - } + for (auto& w : G.pred[u]) { + weight_t temp_weight = directed_mutual_weight(G, u, w.first, weight); + scale = (norm == sum) ? (scale + temp_weight) : std::max(scale, temp_weight); } weight_t nmw = scale ? (directed_mutual_weight(G, u, v, weight) / scale) : 0; + nmw_rec[edge] = nmw; return nmw; } +weight_t local_constraint(Graph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { + std::pair edge = std::make_pair(u, v); + if (local_constraint_rec.count(edge)) return local_constraint_rec[edge]; + + weight_t direct = normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); + weight_t indirect = 0; + for (auto& w : G.adj[u]) { + if (w.first == v) continue; + indirect += normalized_mutual_weight(G, u, w.first, weight, sum, sum_nmw_rec) * + normalized_mutual_weight(G, w.first, v, weight, sum, sum_nmw_rec); + } + weight_t result = pow((direct + indirect), 2); + local_constraint_rec[edge] = result; + return result; +} + +weight_t directed_local_constraint(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& local_constraint_rec, rec_type& sum_nmw_rec) { + std::pair edge = std::make_pair(u, v); + if (local_constraint_rec.count(edge)) return local_constraint_rec[edge]; + + weight_t direct = directed_normalized_mutual_weight(G, u, v, weight, sum, sum_nmw_rec); + weight_t indirect = 0; + std::unordered_set neighbors; + for (const auto& n : G.adj[v]) neighbors.insert(n.first); + for (const auto& n : G.pred[v]) neighbors.insert(n.first); + + for (const auto& n : neighbors) { + if (n == v) continue; + indirect += directed_normalized_mutual_weight(G, u, n, weight, sum, sum_nmw_rec) * + directed_normalized_mutual_weight(G, n, v, weight, sum, sum_nmw_rec); + } + weight_t result = pow((direct + indirect), 2); + local_constraint_rec[edge] = result; + return result; +} void preprocess_graph_for_constraint( Graph& G, @@ -269,35 +290,45 @@ py::object constraint(py::object G, py::object nodes, py::object weight, py::obj #endif } -weight_t redundancy(const Graph& G, node_t u, node_t v, const std::string& weight) { - weight_t r = 0; - if (G.adj.count(v)) { - for (const auto& n_pair : G.adj.at(v)) { - node_t w = n_pair.first; - r += normalized_mutual_weight(G, u, w, weight, sum) * normalized_mutual_weight(G, v, w, weight, max); - } - } - return 1 - r; +template +inline weight_t get_edge_weight(const MapType& attrs, const std::string& weight_key) { + if (weight_key.empty()) return 1.0; + auto it = attrs.find(weight_key); + return it != attrs.end() ? it->second : 1.0; } -weight_t directed_redundancy(const DiGraph& G, node_t u, node_t v, const std::string& weight) { - weight_t r = 0; - std::unordered_set neighbors; +inline weight_t compute_mutual_weight(const Graph& G, node_t u, node_t v, const std::string& weight_key) { + weight_t w = 0; + if (G.adj.count(u)) { + const auto& adj_u = G.adj.at(u); + auto it = adj_u.find(v); + if (it != adj_u.end()) w += get_edge_weight(it->second, weight_key); + } if (G.adj.count(v)) { - for (const auto& n : G.adj.at(v)) neighbors.insert(n.first); + const auto& adj_v = G.adj.at(v); + auto it = adj_v.find(u); + if (it != adj_v.end()) w += get_edge_weight(it->second, weight_key); } - if (G.pred.count(v)) { - for (const auto& n : G.pred.at(v)) neighbors.insert(n.first); + return w; +} + +inline weight_t compute_directed_mutual_weight(const DiGraph& G, node_t u, node_t v, const std::string& weight_key) { + weight_t w = 0; + if (G.adj.count(u)) { + const auto& adj_u = G.adj.at(u); + auto it = adj_u.find(v); + if (it != adj_u.end()) w += get_edge_weight(it->second, weight_key); } - - for (const auto& w : neighbors) { - r += directed_normalized_mutual_weight(G, u, w, weight, sum) * directed_normalized_mutual_weight(G, v, w, weight, max); + if (G.adj.count(v)) { + const auto& adj_v = G.adj.at(v); + auto it = adj_v.find(u); + if (it != adj_v.end()) w += get_edge_weight(it->second, weight_key); } - return 1 - r; + return w; } py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object weight) { - std::string weight_key = weight_to_string(weight); + std::string weight_key = weight_to_string(weight); bool is_directed = G.attr("is_directed")().cast(); if (nodes.is_none()) { @@ -305,103 +336,207 @@ py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object } py::list nodes_list = py::list(nodes); int nodes_list_len = py::len(nodes_list); - + Graph& G_ref = G.cast(); py::object node_to_id = G_ref.node_to_id; - std::vector node_ids(nodes_list_len); + // 获取所有目标节点的 ID + std::vector target_node_ids(nodes_list_len); + node_t max_id_in_targets = 0; for (int i = 0; i < nodes_list_len; i++) { - node_ids[i] = node_to_id[nodes_list[i]].cast(); + node_t id = node_to_id[nodes_list[i]].cast(); + target_node_ids[i] = id; + if (id > max_id_in_targets) max_id_in_targets = id; } + // 确定预计算容器的大小 + node_t max_graph_id = 0; + if (is_directed) { + const DiGraph& G_ = G.cast(); + for (const auto& kv : G_.adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + for (const auto& kv : G_.pred) if (kv.first > max_graph_id) max_graph_id = kv.first; + } else { + const Graph& G_ = G.cast(); + for (const auto& kv : G_.adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + } + max_graph_id = std::max(max_graph_id, max_id_in_targets); + + // 预计算数组 + std::vector scale_sum_vec(max_graph_id + 1, 0.0); + std::vector scale_max_vec(max_graph_id + 1, 0.0); + std::vector all_nodes_vec; std::vector effective_size_results(nodes_list_len, 0.0); - bool use_fast_path = !is_directed && (weight.is_none()); + if (!is_directed) { + const Graph& G_ = G.cast(); + + all_nodes_vec.reserve(G_.adj.size()); + for(const auto& kv : G_.adj) all_nodes_vec.push_back(kv.first); - { - py::gil_scoped_release release; + #pragma omp parallel for schedule(dynamic) + for(int i = 0; i < all_nodes_vec.size(); ++i) { + node_t u = all_nodes_vec[i]; + double s_sum = 0; + double s_max = 0; - if (!is_directed) { - const Graph& G_ = G.cast(); + if (G_.adj.count(u)) { + for (const auto& w_pair : G_.adj.at(u)) { + weight_t temp_weight = compute_mutual_weight(G_, u, w_pair.first, weight_key); + s_sum += temp_weight; + s_max = std::max(s_max, static_cast(temp_weight)); + } + } + scale_sum_vec[u] = s_sum; + scale_max_vec[u] = s_max; + } - #pragma omp parallel for schedule(dynamic) - for (int i = 0; i < nodes_list_len; i++) { - node_t v_id = node_ids[i]; + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < nodes_list_len; i++) { + node_t v_id = target_node_ids[i]; - if (G_.adj.find(v_id) == G_.adj.end() || G_.adj.at(v_id).empty()) { - effective_size_results[i] = NAN; - continue; - } + if (G_.adj.find(v_id) == G_.adj.end() || G_.adj.at(v_id).empty()) { + effective_size_results[i] = NAN; + continue; + } - if (use_fast_path) { - const auto& v_neighbors = G_.adj.at(v_id); - double n = (double)v_neighbors.size(); - double sum_common = 0; - - for (const auto& u_pair : v_neighbors) { - node_t u = u_pair.first; - if (u == v_id) continue; - - if (G_.adj.count(u)) { - const auto& u_neighbors = G_.adj.at(u); - if (v_neighbors.size() < u_neighbors.size()) { - for (const auto& w_pair : v_neighbors) { - node_t w = w_pair.first; - if (w == u) continue; - if (u_neighbors.count(w)) sum_common += 1.0; - } - } else { - for (const auto& w_pair : u_neighbors) { - node_t w = w_pair.first; - if (w == v_id) continue; - if (v_neighbors.count(w)) sum_common += 1.0; - } - } - } - } - effective_size_results[i] = n - (sum_common / n); + const auto& v_neighbors = G_.adj.at(v_id); + double redundancy_sum = 0; + double scale_v_sum = scale_sum_vec[v_id]; - } else { - double redundancy_sum = 0; - for (const auto& neighbor_info : G_.adj.at(v_id)) { - node_t u_id = neighbor_info.first; - redundancy_sum += redundancy(G_, v_id, u_id, weight_key); - } - effective_size_results[i] = redundancy_sum; + for (const auto& neighbor_info : v_neighbors) { + node_t u_id = neighbor_info.first; + double scale_u_max = scale_max_vec[u_id]; + double r_vu = 0; + + for (const auto& w_pair : v_neighbors) { + node_t w_id = w_pair.first; + if (u_id == w_id) continue; + + weight_t mw_uw = compute_mutual_weight(G_, u_id, w_id, weight_key); + if (mw_uw == 0) continue; + + weight_t mw_vw = compute_mutual_weight(G_, v_id, w_id, weight_key); + + double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; + double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; + + r_vu += p_iq * m_jq; } + redundancy_sum += (1.0 - r_vu); } - } else { - const DiGraph& G_ = G.cast(); + effective_size_results[i] = redundancy_sum; + } + } + else { + const DiGraph& G_ = G.cast(); + + std::vector temp_nodes; + temp_nodes.reserve(G_.adj.size() + G_.pred.size()); + for(const auto& kv : G_.adj) temp_nodes.push_back(kv.first); + for(const auto& kv : G_.pred) temp_nodes.push_back(kv.first); + std::sort(temp_nodes.begin(), temp_nodes.end()); + temp_nodes.erase(std::unique(temp_nodes.begin(), temp_nodes.end()), temp_nodes.end()); + all_nodes_vec = std::move(temp_nodes); + + #pragma omp parallel for schedule(dynamic) + for(int i = 0; i < all_nodes_vec.size(); ++i) { + node_t u = all_nodes_vec[i]; - #pragma omp parallel for schedule(dynamic) - for (int i = 0; i < nodes_list_len; i++) { - node_t v_id = node_ids[i]; - - bool has_neighbors = (G_.adj.count(v_id) && !G_.adj.at(v_id).empty()) || - (G_.pred.count(v_id) && !G_.pred.at(v_id).empty()); - - if (!has_neighbors) { - effective_size_results[i] = NAN; - continue; + double s_sum = 0; + double s_max = 0; + + if (G_.adj.count(u)) { + for(const auto& p : G_.adj.at(u)) { + node_t w = p.first; + weight_t temp_weight = compute_directed_mutual_weight(G_, u, w, weight_key); + s_sum += temp_weight; + s_max = std::max(s_max, static_cast(temp_weight)); } + } + if (G_.pred.count(u)) { + for(const auto& p : G_.pred.at(u)) { + node_t w = p.first; + weight_t temp_weight = compute_directed_mutual_weight(G_, u, w, weight_key); + s_sum += temp_weight; + s_max = std::max(s_max, static_cast(temp_weight)); + } + } + + if (u < scale_sum_vec.size()) { + scale_sum_vec[u] = s_sum; + scale_max_vec[u] = s_max; + } + } - double redundancy_sum = 0; - if (G_.adj.count(v_id)) { - for (const auto& neighbor_info : G_.adj.at(v_id)) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key); + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < nodes_list_len; i++) { + node_t v_id = target_node_ids[i]; // Center + + bool has_neighbors = (G_.adj.count(v_id) && !G_.adj.at(v_id).empty()) || + (G_.pred.count(v_id) && !G_.pred.at(v_id).empty()); + + if (!has_neighbors) { + effective_size_results[i] = NAN; + continue; + } + + double redundancy_sum = 0; + double scale_v_sum = (v_id < scale_sum_vec.size()) ? scale_sum_vec[v_id] : 0; + + std::vector common_candidates; + if (G_.adj.count(v_id)) { + for(auto& p : G_.adj.at(v_id)) common_candidates.push_back(p.first); + } + if (G_.pred.count(v_id)) { + for(auto& p : G_.pred.at(v_id)) common_candidates.push_back(p.first); + } + std::sort(common_candidates.begin(), common_candidates.end()); + common_candidates.erase(std::unique(common_candidates.begin(), common_candidates.end()), common_candidates.end()); + + if (G_.adj.count(v_id)) { + for (const auto& neighbor_info : G_.adj.at(v_id)) { + node_t u_id = neighbor_info.first; // Neighbor + double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; + double r_vu = 0; + for (const auto& w_id : common_candidates) { + if (u_id == w_id) continue; + + weight_t mw_uw = compute_directed_mutual_weight(G_, u_id, w_id, weight_key); + if (mw_uw == 0) continue; + + weight_t mw_vw = compute_directed_mutual_weight(G_, v_id, w_id, weight_key); + + double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; + double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; + r_vu += p_iq * m_jq; } + redundancy_sum += (1.0 - r_vu); } - if (G_.pred.count(v_id)) { - for (const auto& neighbor_info : G_.pred.at(v_id)) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key); + } + if (G_.pred.count(v_id)) { + for (const auto& neighbor_info : G_.pred.at(v_id)) { + node_t u_id = neighbor_info.first; // Neighbor + double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; + double r_vu = 0; + for (const auto& w_id : common_candidates) { + if (u_id == w_id) continue; + + weight_t mw_uw = compute_directed_mutual_weight(G_, u_id, w_id, weight_key); + if (mw_uw == 0) continue; + + weight_t mw_vw = compute_directed_mutual_weight(G_, v_id, w_id, weight_key); + + double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; + double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; + r_vu += p_iq * m_jq; } + redundancy_sum += (1.0 - r_vu); } - effective_size_results[i] = redundancy_sum; } + + effective_size_results[i] = redundancy_sum; } - } + } py::array::ShapeContainer ret_shape{nodes_list_len}; py::array_t ret(ret_shape, effective_size_results.data()); @@ -467,12 +602,429 @@ py::object effective_size(py::object G, py::object nodes, py::object weight, py: #endif } +weight_t redundancy(Graph& G, node_t u, node_t v, std::string weight, rec_type& sum_nmw_rec, rec_type& max_nmw_rec) { + weight_t r = 0; + std::unordered_set neighbors; + for (const auto& n : G.adj[v]) { + neighbors.insert(n.first); + } + for (const auto& w : neighbors) { + r += normalized_mutual_weight(G, u, w, weight, sum, sum_nmw_rec) * normalized_mutual_weight(G, v, w, weight, max, max_nmw_rec); + } + return 1 - r; +} + +weight_t directed_redundancy(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& sum_nmw_rec, rec_type& max_nmw_rec) { + weight_t r = 0; + std::unordered_set neighbors; + for (const auto& n : G.adj[v]) { + neighbors.insert(n.first); + } + for (const auto& n : G.pred[v]) { + neighbors.insert(n.first); + } + for (const auto& w : neighbors) { + r += directed_normalized_mutual_weight(G, u, w, weight, sum, sum_nmw_rec) * directed_normalized_mutual_weight(G, v, w, weight, max, max_nmw_rec); + } + return 1 - r; +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_efficiency(py::object G, py::object nodes, py::object weight) { + Graph& G_ = G.cast(); + py::dict effective_size = py::dict(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + auto coo_graph = G_.transfer_csr_to_coo(csr_graph); + + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + + std::vector* W_p = weight.is_none() ? &(coo_graph->unweighted_W) + : coo_graph->W_map.find(weight_to_string(weight))->second.get(); + + std::unordered_map& node2idx = coo_graph->node2idx; + int num_nodes = coo_graph->node2idx.size(); + std::vector effective_size_results(num_nodes); + bool is_directed = G.attr("is_directed")().cast(); + + std::vector node_mask(num_nodes, 0); + py::list nodes_list; + if (!nodes.is_none()) { + nodes_list = py::list(nodes); + for (auto node : nodes_list) { + int node_id = node2idx[G_.node_to_id[node].cast()]; + node_mask[node_id] = 1; + } + } else { + nodes_list = py::list(G.attr("nodes")); + std::fill(node_mask.begin(), node_mask.end(), 1); + } + + int gpu_r = gpu_easygraph::effective_size(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, effective_size_results); + + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::dict effective_size_dict; + for (auto node : nodes_list) { + int node_id = G_.node_to_id[node].cast(); + int idx = node2idx[node_id]; + + py::object node_name = G_.id_to_node.attr("get")(py::cast(node_id)); + effective_size_dict[node_name] = py::cast(effective_size_results[idx]); + } + py::dict degree; + if (weight.is_none()) { + degree = G.attr("degree")(py::none()).cast(); + } else { + degree = G.attr("degree")(weight).cast(); + } + + py::dict efficiency_dict; + for (auto item : effective_size_dict) { + int node = py::reinterpret_borrow(item.first).cast(); + double eff_size = py::reinterpret_borrow(item.second).cast(); + + if (!degree.contains(py::cast(node))) { + continue; + } + + double node_degree = py::reinterpret_borrow(degree[py::cast(node)]).cast(); + if (node_degree == 0.0) { + efficiency_dict[py::cast(node)] = py::cast(Py_NAN); + } else { + double efficiency_value = eff_size / node_degree; + efficiency_dict[py::cast(node)] = py::cast(efficiency_value); + } + } + + return efficiency_dict; +} +#endif + + +py::object invoke_cpp_efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { + rec_type sum_nmw_rec, max_nmw_rec; + py::dict effective_size_dict = py::dict(); + if (nodes.is_none()) { + nodes = G; + } + nodes = py::list(nodes); + if (!G.attr("is_directed")().cast()){ + Graph& G_ = G.cast(); + std::string weight_key = weight_to_string(weight); + int nodes_len = py::len(nodes); + for (int i = 0; i < nodes_len; i++) { + py::object v = nodes[py::cast(i)]; + if (py::len(G[v]) == 0) { + effective_size_dict[v] = py::cast(Py_NAN); + continue; + } + weight_t redundancy_sum = 0; + node_t v_id = G_.node_to_id[v].cast(); + for (const auto& neighbor_info : G_.adj[v_id]) { + node_t u_id = neighbor_info.first; + redundancy_sum += redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); + } + effective_size_dict[v] = redundancy_sum; + } + } else{ + DiGraph& G_ = G.cast(); + std::string weight_key = weight_to_string(weight); + int nodes_len = py::len(nodes); + for (int i = 0; i < nodes_len; i++) { + py::object v = nodes[py::cast(i)]; + if (py::len(G[v]) == 0) { + effective_size_dict[v] = py::cast(Py_NAN); + continue; + } + weight_t redundancy_sum = 0; + node_t v_id = G_.node_to_id[v].cast(); + for (const auto& neighbor_info : G_.adj[v_id]) { + node_t u_id = neighbor_info.first; + redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); + } + for (const auto& neighbor_info : G_.pred[v_id]) { + node_t u_id = neighbor_info.first; + redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); + } + effective_size_dict[v] = redundancy_sum; + } + } + + py::dict degree; + if (weight.is_none()) { + degree = G.attr("degree")(py::none()).cast(); + } else { + degree = G.attr("degree")(weight).cast(); + } + + py::dict efficiency_dict; + for (auto item : effective_size_dict) { + int node = py::reinterpret_borrow(item.first).cast(); + double eff_size = py::reinterpret_borrow(item.second).cast(); + + if (!degree.contains(py::cast(node))) { + continue; + } + + double node_degree = py::reinterpret_borrow(degree[py::cast(node)]).cast(); + if (node_degree == 0.0) { + efficiency_dict[py::cast(node)] = py::cast(Py_NAN); + } else { + double efficiency_value = eff_size / node_degree; + efficiency_dict[py::cast(node)] = py::cast(efficiency_value); + } + } + + return efficiency_dict; +} + +py::object efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_efficiency(G, nodes, weight); +#else + return invoke_cpp_efficiency(G, nodes, weight, n_workers); +#endif +} + +void hierarchy_parallel(Graph* G, std::vector* nodes, std::string weight, std::unordered_map* ret) { + rec_type local_constraint_rec, sum_nmw_rec; + for (node_t v : *nodes) { + int n = G->adj[v].size(); // len(G.ego_subgraph(v)) - 1 + weight_t C = 0; + std::unordered_map c; + for (const auto& w_pair : G->adj[v]) { + node_t w = w_pair.first; + C += local_constraint(*G, v, w, weight, local_constraint_rec, sum_nmw_rec); + c[w] = local_constraint(*G, v, w, weight, local_constraint_rec, sum_nmw_rec); + } + if (n > 1) { + weight_t sum = 0; + for (const auto& w_pair : G->adj[v]) { + node_t w = w_pair.first; + sum += c[w] / C * n * log(c[w] / C * n) / (n * log(n)); + } + (*ret)[v] = sum; + } + else { + (*ret)[v] = 0; + } + } +} + +inline std::vector > split_len(const std::vector& nodes, int step) { + std::vector > ret; + for (int i = 0; i < nodes.size();i += step) { + ret.emplace_back(nodes.begin() + i, (i + step > nodes.size()) ? nodes.end() : nodes.begin() + i + step); + } + if (ret.back().size() * 3 < step) { + ret[ret.size() - 2].insert(ret[ret.size() - 2].end(), ret.back().begin(), ret.back().end()); + ret.pop_back(); + } + return ret; +} + +inline std::vector > split(const std::vector& nodes, int n) { + std::vector > ret; + int length = nodes.size(); + int step = length / n + 1; + for (int i = 0;i < length;i += step) { + ret.emplace_back(nodes.begin() + i, i + step > length ? nodes.end() : nodes.begin() + i + step); + } + return ret; +} + +py::object invoke_cpp_hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers) { + rec_type local_constraint_rec, sum_nmw_rec; + std::string weight_key = weight_to_string(weight); + if (nodes.is_none()) { + nodes = G.attr("nodes"); + } + py::list nodes_list = py::list(nodes); + int nodes_list_len = py::len(nodes_list); + py::dict hierarchy = py::dict(); + + if(G.attr("is_directed")().cast()){ + DiGraph& G_ = G.cast(); + for (int i = 0; i < nodes_list_len; i++) { + py::object v = nodes_list[i]; + weight_t C = 0; + std::map c; + + py::list successors_of_v = py::list(G.attr("successors")(v)); + py::list predecessors_of_v = py::list(G.attr("predecessors")(v)); + + std::set neighbors_of_v; + for (const auto& w : successors_of_v) { + neighbors_of_v.insert(G_.node_to_id[w].cast()); + } + for (const auto& w : predecessors_of_v) { + neighbors_of_v.insert(G_.node_to_id[w].cast()); + } + + for (const auto& w_id : neighbors_of_v) { + node_t v_id = G_.node_to_id[v].cast(); + + C += directed_local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); + c[w_id] = directed_local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); + } + int n = neighbors_of_v.size(); + + if (n > 1) { + weight_t hierarchy_sum = 0; + for (const auto& w_id : neighbors_of_v) { + hierarchy_sum += c[w_id] / C * n * log(c[w_id] / C * n) / (n * log(n)); + } + hierarchy[v] = hierarchy_sum; + } + + if (!hierarchy.contains(v)) { + hierarchy[v] = 0; + } + } + + }else{ + Graph& G_ = G.cast(); + if (!n_workers.is_none()) { + std::vector node_ids; + int n_workers_num = n_workers.cast(); + for (int i = 0;i < py::len(nodes_list);i++) { + py::object node = nodes_list[i]; + node_ids.push_back(G_.node_to_id[node].cast()); + } + std::shuffle(node_ids.begin(), node_ids.end(), std::random_device()); + std::vector > split_nodes; + if (node_ids.size() > n_workers_num * 30000) { + split_nodes = split_len(node_ids, 30000); + } + else { + split_nodes = split(node_ids, n_workers_num); + } + while (split_nodes.size() < n_workers_num) { + split_nodes.push_back(std::vector()); + } + std::vector > rets(n_workers_num); + Py_BEGIN_ALLOW_THREADS + + std::vector threads; + for (int i = 0;i < n_workers_num; i++) { + threads.push_back(std::thread(hierarchy_parallel, &G_, &split_nodes[i], weight_key, &rets[i])); + } + for (int i = 0;i < n_workers_num;i++) { + threads[i].join(); + } + + Py_END_ALLOW_THREADS + + for (int i = 1;i < rets.size();i++) { + rets[0].insert(rets[i].begin(), rets[i].end()); + } + for (const auto& hierarchy_pair : rets[0]) { + py::object node = G_.id_to_node[py::cast(hierarchy_pair.first)]; + hierarchy[node] = hierarchy_pair.second; + } + } + else { + for (int i = 0; i < nodes_list_len; i++) { + py::object v = nodes_list[i]; + py::object E = G.attr("ego_subgraph")(v); + + int n = py::len(E) - 1; + + weight_t C = 0; + std::map c; + py::list neighbors_of_v = py::list(G.attr("neighbors")(v)); + int neighbors_of_v_len = py::len(neighbors_of_v); + for (int j = 0; j < neighbors_of_v_len; j++) { + py::object w = neighbors_of_v[j]; + node_t v_id = G_.node_to_id[v].cast(); + node_t w_id = G_.node_to_id[w].cast(); + C += local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); + c[w_id] = local_constraint(G_, v_id, w_id, weight_key, local_constraint_rec, sum_nmw_rec); + } + if (n > 1) { + weight_t hierarchy_sum = 0; + int neighbors_of_v_len = py::len(neighbors_of_v); + for (int k = 0; k < neighbors_of_v_len; k++) { + py::object w = neighbors_of_v[k]; + node_t w_id = G_.node_to_id[w].cast(); + hierarchy_sum += c[w_id] / C * n * log(c[w_id] / C * n) / (n * log(n)); + } + hierarchy[v] = hierarchy_sum; + } + if (!hierarchy.contains(v)) { + hierarchy[v] = 0; + } + } + } + } + + + return hierarchy; +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_hierarchy(py::object G, py::object nodes, py::object weight) { + Graph& G_ = G.cast(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + auto coo_graph = G_.transfer_csr_to_coo(csr_graph); + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) + : coo_graph->W_map.find(weight_to_string(weight))->second.get(); + std::unordered_map& node2idx = coo_graph->node2idx; + int num_nodes = coo_graph->node2idx.size(); + bool is_directed = G.attr("is_directed")().cast(); + std::vector hierarchy_results; + std::vector node_mask(num_nodes, 0); + py::list nodes_list; + if (!nodes.is_none()) { + nodes_list = py::list(nodes); + for (auto node : nodes_list) { + int node_id = node2idx[G_.node_to_id[node].cast()]; + node_mask[node_id] = 1; + } + } else { + nodes_list = py::list(G.attr("nodes")); + std::fill(node_mask.begin(), node_mask.end(), 1); + } + int gpu_r = gpu_easygraph::hierarchy(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, hierarchy_results); + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + py::dict hierarchy_dict; + for (auto node : nodes_list) { + int node_id = G_.node_to_id[node].cast(); + int idx = node2idx[node_id]; -py::object efficiency(py::object G, py::object nodes, py::object weight, py::object ignored_arg) { - return py::none(); + py::object node_name = G_.id_to_node.attr("get")(py::cast(node_id)); + hierarchy_dict[node_name] = py::cast(hierarchy_results[idx]); + } + return hierarchy_dict; } +#endif -py::object hierarchy(py::object G, py::object nodes, py::object weight, py::object ignored_arg) { - return py::none(); +py::object hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_hierarchy(G, nodes, weight); +#else + return invoke_cpp_hierarchy(G, nodes, weight, n_workers); +#endif } \ No newline at end of file From a86666343b515ac44cae466ffecc386e2e223111 Mon Sep 17 00:00:00 2001 From: sama Date: Thu, 22 Jan 2026 05:03:48 -0600 Subject: [PATCH 26/33] optimize efficiency --- .../functions/structural_holes/evaluation.cpp | 389 ++++++++---------- 1 file changed, 175 insertions(+), 214 deletions(-) diff --git a/cpp_easygraph/functions/structural_holes/evaluation.cpp b/cpp_easygraph/functions/structural_holes/evaluation.cpp index 59b93358..8cd65f45 100644 --- a/cpp_easygraph/functions/structural_holes/evaluation.cpp +++ b/cpp_easygraph/functions/structural_holes/evaluation.cpp @@ -327,95 +327,112 @@ inline weight_t compute_directed_mutual_weight(const DiGraph& G, node_t u, node_ return w; } -py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object weight) { - std::string weight_key = weight_to_string(weight); - bool is_directed = G.attr("is_directed")().cast(); - - if (nodes.is_none()) { - nodes = G.attr("nodes"); - } - py::list nodes_list = py::list(nodes); - int nodes_list_len = py::len(nodes_list); - - Graph& G_ref = G.cast(); - py::object node_to_id = G_ref.node_to_id; +std::vector compute_redundancy_core(py::object G_obj, const std::vector& target_nodes, const std::string& weight_key, bool is_directed) { - // 获取所有目标节点的 ID - std::vector target_node_ids(nodes_list_len); - node_t max_id_in_targets = 0; - for (int i = 0; i < nodes_list_len; i++) { - node_t id = node_to_id[nodes_list[i]].cast(); - target_node_ids[i] = id; - if (id > max_id_in_targets) max_id_in_targets = id; + // Cast to C++ objects once to avoid Python API overhead + const Graph* G_ptr = nullptr; + const DiGraph* DiG_ptr = nullptr; + if (is_directed) { + DiG_ptr = &G_obj.cast(); + } else { + G_ptr = &G_obj.cast(); } - // 确定预计算容器的大小 + // Pre-compute max ID and node list node_t max_graph_id = 0; + std::vector all_nodes_vec; + if (is_directed) { - const DiGraph& G_ = G.cast(); - for (const auto& kv : G_.adj) if (kv.first > max_graph_id) max_graph_id = kv.first; - for (const auto& kv : G_.pred) if (kv.first > max_graph_id) max_graph_id = kv.first; + for (const auto& kv : DiG_ptr->adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + for (const auto& kv : DiG_ptr->pred) if (kv.first > max_graph_id) max_graph_id = kv.first; + all_nodes_vec.reserve(DiG_ptr->adj.size() + DiG_ptr->pred.size()); + for(const auto& kv : DiG_ptr->adj) all_nodes_vec.push_back(kv.first); + for(const auto& kv : DiG_ptr->pred) all_nodes_vec.push_back(kv.first); } else { - const Graph& G_ = G.cast(); - for (const auto& kv : G_.adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + for (const auto& kv : G_ptr->adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + all_nodes_vec.reserve(G_ptr->adj.size()); + for(const auto& kv : G_ptr->adj) all_nodes_vec.push_back(kv.first); } - max_graph_id = std::max(max_graph_id, max_id_in_targets); - // 预计算数组 + // Deduplicate nodes + std::sort(all_nodes_vec.begin(), all_nodes_vec.end()); + all_nodes_vec.erase(std::unique(all_nodes_vec.begin(), all_nodes_vec.end()), all_nodes_vec.end()); + + // Ensure vector size covers target nodes + if (!target_nodes.empty()) { + node_t max_target = *std::max_element(target_nodes.begin(), target_nodes.end()); + max_graph_id = std::max(max_graph_id, max_target); + } + + // Pre-compute Scale std::vector scale_sum_vec(max_graph_id + 1, 0.0); std::vector scale_max_vec(max_graph_id + 1, 0.0); - std::vector all_nodes_vec; - std::vector effective_size_results(nodes_list_len, 0.0); - - if (!is_directed) { - const Graph& G_ = G.cast(); - - all_nodes_vec.reserve(G_.adj.size()); - for(const auto& kv : G_.adj) all_nodes_vec.push_back(kv.first); - #pragma omp parallel for schedule(dynamic) - for(int i = 0; i < all_nodes_vec.size(); ++i) { - node_t u = all_nodes_vec[i]; - double s_sum = 0; - double s_max = 0; - - if (G_.adj.count(u)) { - for (const auto& w_pair : G_.adj.at(u)) { - weight_t temp_weight = compute_mutual_weight(G_, u, w_pair.first, weight_key); - s_sum += temp_weight; - s_max = std::max(s_max, static_cast(temp_weight)); + #pragma omp parallel for schedule(dynamic) + for(int i = 0; i < all_nodes_vec.size(); ++i) { + node_t u = all_nodes_vec[i]; + double s_sum = 0; + double s_max = 0; + + if (is_directed) { + if (DiG_ptr->adj.count(u)) { + for(const auto& p : DiG_ptr->adj.at(u)) { + weight_t tw = compute_directed_mutual_weight(*DiG_ptr, u, p.first, weight_key); + s_sum += tw; s_max = std::max(s_max, (double)tw); + } + } + if (DiG_ptr->pred.count(u)) { + for(const auto& p : DiG_ptr->pred.at(u)) { + weight_t tw = compute_directed_mutual_weight(*DiG_ptr, u, p.first, weight_key); + s_sum += tw; s_max = std::max(s_max, (double)tw); } } + } else { + if (G_ptr->adj.count(u)) { + for(const auto& p : G_ptr->adj.at(u)) { + weight_t tw = compute_mutual_weight(*G_ptr, u, p.first, weight_key); + s_sum += tw; s_max = std::max(s_max, (double)tw); + } + } + } + if (u < scale_sum_vec.size()) { scale_sum_vec[u] = s_sum; scale_max_vec[u] = s_max; } + } - #pragma omp parallel for schedule(dynamic) - for (int i = 0; i < nodes_list_len; i++) { - node_t v_id = target_node_ids[i]; + // Compute Redundancy + std::vector results(target_nodes.size()); - if (G_.adj.find(v_id) == G_.adj.end() || G_.adj.at(v_id).empty()) { - effective_size_results[i] = NAN; + if (!is_directed) { + // Undirected + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < target_nodes.size(); i++) { + node_t v_id = target_nodes[i]; + + if (G_ptr->adj.find(v_id) == G_ptr->adj.end() || G_ptr->adj.at(v_id).empty()) { + results[i] = NAN; continue; } - const auto& v_neighbors = G_.adj.at(v_id); + const auto& v_neighbors = G_ptr->adj.at(v_id); double redundancy_sum = 0; - double scale_v_sum = scale_sum_vec[v_id]; + double scale_v_sum = (v_id < scale_sum_vec.size()) ? scale_sum_vec[v_id] : 0; + // Direct iteration avoids malloc locks for (const auto& neighbor_info : v_neighbors) { node_t u_id = neighbor_info.first; - double scale_u_max = scale_max_vec[u_id]; + double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; double r_vu = 0; for (const auto& w_pair : v_neighbors) { node_t w_id = w_pair.first; if (u_id == w_id) continue; - weight_t mw_uw = compute_mutual_weight(G_, u_id, w_id, weight_key); + weight_t mw_uw = compute_mutual_weight(*G_ptr, u_id, w_id, weight_key); if (mw_uw == 0) continue; - weight_t mw_vw = compute_mutual_weight(G_, v_id, w_id, weight_key); + weight_t mw_vw = compute_mutual_weight(*G_ptr, v_id, w_id, weight_key); double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; @@ -424,87 +441,48 @@ py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object } redundancy_sum += (1.0 - r_vu); } - effective_size_results[i] = redundancy_sum; + results[i] = redundancy_sum; } - } - else { - const DiGraph& G_ = G.cast(); - - std::vector temp_nodes; - temp_nodes.reserve(G_.adj.size() + G_.pred.size()); - for(const auto& kv : G_.adj) temp_nodes.push_back(kv.first); - for(const auto& kv : G_.pred) temp_nodes.push_back(kv.first); - std::sort(temp_nodes.begin(), temp_nodes.end()); - temp_nodes.erase(std::unique(temp_nodes.begin(), temp_nodes.end()), temp_nodes.end()); - all_nodes_vec = std::move(temp_nodes); - + } else { + //Directed #pragma omp parallel for schedule(dynamic) - for(int i = 0; i < all_nodes_vec.size(); ++i) { - node_t u = all_nodes_vec[i]; - - double s_sum = 0; - double s_max = 0; - - if (G_.adj.count(u)) { - for(const auto& p : G_.adj.at(u)) { - node_t w = p.first; - weight_t temp_weight = compute_directed_mutual_weight(G_, u, w, weight_key); - s_sum += temp_weight; - s_max = std::max(s_max, static_cast(temp_weight)); - } - } - if (G_.pred.count(u)) { - for(const auto& p : G_.pred.at(u)) { - node_t w = p.first; - weight_t temp_weight = compute_directed_mutual_weight(G_, u, w, weight_key); - s_sum += temp_weight; - s_max = std::max(s_max, static_cast(temp_weight)); - } - } + for (int i = 0; i < target_nodes.size(); i++) { + node_t v_id = target_nodes[i]; - if (u < scale_sum_vec.size()) { - scale_sum_vec[u] = s_sum; - scale_max_vec[u] = s_max; - } - } - - #pragma omp parallel for schedule(dynamic) - for (int i = 0; i < nodes_list_len; i++) { - node_t v_id = target_node_ids[i]; // Center - - bool has_neighbors = (G_.adj.count(v_id) && !G_.adj.at(v_id).empty()) || - (G_.pred.count(v_id) && !G_.pred.at(v_id).empty()); + bool has_neighbors = (DiG_ptr->adj.count(v_id) && !DiG_ptr->adj.at(v_id).empty()) || + (DiG_ptr->pred.count(v_id) && !DiG_ptr->pred.at(v_id).empty()); if (!has_neighbors) { - effective_size_results[i] = NAN; + results[i] = NAN; continue; } double redundancy_sum = 0; double scale_v_sum = (v_id < scale_sum_vec.size()) ? scale_sum_vec[v_id] : 0; + // Prepare common candidates std::vector common_candidates; - if (G_.adj.count(v_id)) { - for(auto& p : G_.adj.at(v_id)) common_candidates.push_back(p.first); + if (DiG_ptr->adj.count(v_id)) { + for(auto& p : DiG_ptr->adj.at(v_id)) common_candidates.push_back(p.first); } - if (G_.pred.count(v_id)) { - for(auto& p : G_.pred.at(v_id)) common_candidates.push_back(p.first); + if (DiG_ptr->pred.count(v_id)) { + for(auto& p : DiG_ptr->pred.at(v_id)) common_candidates.push_back(p.first); } std::sort(common_candidates.begin(), common_candidates.end()); common_candidates.erase(std::unique(common_candidates.begin(), common_candidates.end()), common_candidates.end()); - if (G_.adj.count(v_id)) { - for (const auto& neighbor_info : G_.adj.at(v_id)) { - node_t u_id = neighbor_info.first; // Neighbor + // Loop A: Out-neighbors + if (DiG_ptr->adj.count(v_id)) { + for (const auto& neighbor_info : DiG_ptr->adj.at(v_id)) { + node_t u_id = neighbor_info.first; double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; double r_vu = 0; + for (const auto& w_id : common_candidates) { if (u_id == w_id) continue; - - weight_t mw_uw = compute_directed_mutual_weight(G_, u_id, w_id, weight_key); + weight_t mw_uw = compute_directed_mutual_weight(*DiG_ptr, u_id, w_id, weight_key); if (mw_uw == 0) continue; - - weight_t mw_vw = compute_directed_mutual_weight(G_, v_id, w_id, weight_key); + weight_t mw_vw = compute_directed_mutual_weight(*DiG_ptr, v_id, w_id, weight_key); double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; @@ -513,18 +491,19 @@ py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object redundancy_sum += (1.0 - r_vu); } } - if (G_.pred.count(v_id)) { - for (const auto& neighbor_info : G_.pred.at(v_id)) { - node_t u_id = neighbor_info.first; // Neighbor + + // Loop B: In-neighbors + if (DiG_ptr->pred.count(v_id)) { + for (const auto& neighbor_info : DiG_ptr->pred.at(v_id)) { + node_t u_id = neighbor_info.first; double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; double r_vu = 0; + for (const auto& w_id : common_candidates) { if (u_id == w_id) continue; - - weight_t mw_uw = compute_directed_mutual_weight(G_, u_id, w_id, weight_key); + weight_t mw_uw = compute_directed_mutual_weight(*DiG_ptr, u_id, w_id, weight_key); if (mw_uw == 0) continue; - - weight_t mw_vw = compute_directed_mutual_weight(G_, v_id, w_id, weight_key); + weight_t mw_vw = compute_directed_mutual_weight(*DiG_ptr, v_id, w_id, weight_key); double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; @@ -533,14 +512,37 @@ py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object redundancy_sum += (1.0 - r_vu); } } + results[i] = redundancy_sum; + } + } + + return results; +} + +py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object weight) { + std::string weight_key = weight.is_none() ? "" : weight.cast(); + bool is_directed = G.attr("is_directed")().cast(); + + if (nodes.is_none()) nodes = G.attr("nodes"); + py::list nodes_list = py::list(nodes); + size_t len = py::len(nodes_list); + std::vector target_ids(len); - effective_size_results[i] = redundancy_sum; + if (py::hasattr(G, "node_to_id")) { + py::object node_to_id = G.attr("node_to_id"); + for (size_t i = 0; i < len; i++) { + target_ids[i] = node_to_id[nodes_list[i]].cast(); + } + } else { + for (size_t i = 0; i < len; i++) { + target_ids[i] = nodes_list[i].cast(); } } - py::array::ShapeContainer ret_shape{nodes_list_len}; - py::array_t ret(ret_shape, effective_size_results.data()); - return ret; + std::vector results = compute_redundancy_core(G, target_ids, weight_key, is_directed); + + py::array::ShapeContainer ret_shape{ (long)results.size() }; + return py::array_t(ret_shape, results.data()); } #ifdef EASYGRAPH_ENABLE_GPU @@ -602,33 +604,6 @@ py::object effective_size(py::object G, py::object nodes, py::object weight, py: #endif } -weight_t redundancy(Graph& G, node_t u, node_t v, std::string weight, rec_type& sum_nmw_rec, rec_type& max_nmw_rec) { - weight_t r = 0; - std::unordered_set neighbors; - for (const auto& n : G.adj[v]) { - neighbors.insert(n.first); - } - for (const auto& w : neighbors) { - r += normalized_mutual_weight(G, u, w, weight, sum, sum_nmw_rec) * normalized_mutual_weight(G, v, w, weight, max, max_nmw_rec); - } - return 1 - r; -} - -weight_t directed_redundancy(DiGraph& G, node_t u, node_t v, std::string weight, rec_type& sum_nmw_rec, rec_type& max_nmw_rec) { - weight_t r = 0; - std::unordered_set neighbors; - for (const auto& n : G.adj[v]) { - neighbors.insert(n.first); - } - for (const auto& n : G.pred[v]) { - neighbors.insert(n.first); - } - for (const auto& w : neighbors) { - r += directed_normalized_mutual_weight(G, u, w, weight, sum, sum_nmw_rec) * directed_normalized_mutual_weight(G, v, w, weight, max, max_nmw_rec); - } - return 1 - r; -} - #ifdef EASYGRAPH_ENABLE_GPU static py::object invoke_gpu_efficiency(py::object G, py::object nodes, py::object weight) { Graph& G_ = G.cast(); @@ -712,80 +687,66 @@ static py::object invoke_gpu_efficiency(py::object G, py::object nodes, py::obje py::object invoke_cpp_efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { - rec_type sum_nmw_rec, max_nmw_rec; - py::dict effective_size_dict = py::dict(); - if (nodes.is_none()) { - nodes = G; - } - nodes = py::list(nodes); - if (!G.attr("is_directed")().cast()){ - Graph& G_ = G.cast(); - std::string weight_key = weight_to_string(weight); - int nodes_len = py::len(nodes); - for (int i = 0; i < nodes_len; i++) { - py::object v = nodes[py::cast(i)]; - if (py::len(G[v]) == 0) { - effective_size_dict[v] = py::cast(Py_NAN); - continue; - } - weight_t redundancy_sum = 0; - node_t v_id = G_.node_to_id[v].cast(); - for (const auto& neighbor_info : G_.adj[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - effective_size_dict[v] = redundancy_sum; + std::string weight_key = weight.is_none() ? "" : weight.cast(); + bool is_directed = G.attr("is_directed")().cast(); + + // Parsing Nodes + if (nodes.is_none()) nodes = G.attr("nodes"); + py::list nodes_list = py::list(nodes); + size_t len = py::len(nodes_list); + std::vector target_ids(len); + + if (py::hasattr(G, "node_to_id")) { + py::object node_to_id = G.attr("node_to_id"); + for (size_t i = 0; i < len; i++) { + target_ids[i] = node_to_id[nodes_list[i]].cast(); } - } else{ - DiGraph& G_ = G.cast(); - std::string weight_key = weight_to_string(weight); - int nodes_len = py::len(nodes); - for (int i = 0; i < nodes_len; i++) { - py::object v = nodes[py::cast(i)]; - if (py::len(G[v]) == 0) { - effective_size_dict[v] = py::cast(Py_NAN); - continue; - } - weight_t redundancy_sum = 0; - node_t v_id = G_.node_to_id[v].cast(); - for (const auto& neighbor_info : G_.adj[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - for (const auto& neighbor_info : G_.pred[v_id]) { - node_t u_id = neighbor_info.first; - redundancy_sum += directed_redundancy(G_, v_id, u_id, weight_key, sum_nmw_rec, max_nmw_rec); - } - effective_size_dict[v] = redundancy_sum; + } else { + for (size_t i = 0; i < len; i++) { + target_ids[i] = nodes_list[i].cast(); } } - py::dict degree; - if (weight.is_none()) { - degree = G.attr("degree")(py::none()).cast(); - } else { - degree = G.attr("degree")(weight).cast(); - } + // Compute Efficiency = Effective Size / Degree + std::vector eff_sizes = compute_redundancy_core(G, target_ids, weight_key, is_directed); - py::dict efficiency_dict; - for (auto item : effective_size_dict) { - int node = py::reinterpret_borrow(item.first).cast(); - double eff_size = py::reinterpret_borrow(item.second).cast(); + // Cast Graph pointers for fast degree access + const Graph* G_ptr = nullptr; + const DiGraph* DiG_ptr = nullptr; + if (is_directed) DiG_ptr = &G.cast(); + else G_ptr = &G.cast(); - if (!degree.contains(py::cast(node))) { + std::vector efficiency_results(len); + + #pragma omp parallel for schedule(static) + for (size_t i = 0; i < len; ++i) { + double es = eff_sizes[i]; + + // Propagate NAN from core + if (std::isnan(es)) { + efficiency_results[i] = NAN; continue; } - double node_degree = py::reinterpret_borrow(degree[py::cast(node)]).cast(); - if (node_degree == 0.0) { - efficiency_dict[py::cast(node)] = py::cast(Py_NAN); + node_t v = target_ids[i]; + double degree = 0; + + if (is_directed) { + if (DiG_ptr->adj.count(v)) degree += DiG_ptr->adj.at(v).size(); + if (DiG_ptr->pred.count(v)) degree += DiG_ptr->pred.at(v).size(); } else { - double efficiency_value = eff_size / node_degree; - efficiency_dict[py::cast(node)] = py::cast(efficiency_value); + if (G_ptr->adj.count(v)) degree += G_ptr->adj.at(v).size(); + } + + if (degree > 0) { + efficiency_results[i] = es / degree; + } else { + efficiency_results[i] = NAN; } } - return efficiency_dict; + py::array::ShapeContainer ret_shape{ (long)len }; + return py::array_t(ret_shape, efficiency_results.data()); } py::object efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { From 5ac2447cf874f1bbe0d903694d2bb2b38b94fb40 Mon Sep 17 00:00:00 2001 From: wuyazu Date: Mon, 26 Jan 2026 18:17:21 +0800 Subject: [PATCH 27/33] support macOS --- CMakeLists.txt | 11 ++---- cpp_easygraph/CMakeLists.txt | 70 ++++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bb35232e..ca28feb6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,14 +2,7 @@ cmake_minimum_required(VERSION 3.23) project(easygraph) -find_package(OpenMP QUIET) - -if (OpenMP_FOUND) - message(STATUS "OpenMP found, enabling parallel acceleration.") - add_compile_options(${OpenMP_CXX_FLAGS}) -else() - message(STATUS "OpenMP not found, building in single-thread mode.") -endif() +option(EASYGRAPH_ENABLE_OPENMP "Enable OpenMP acceleration (auto-detect)" ON) option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) @@ -23,4 +16,4 @@ if (EASYGRAPH_ENABLE_GPU) ) else() message("easygraph gpu module is disabled") -endif() \ No newline at end of file +endif() diff --git a/cpp_easygraph/CMakeLists.txt b/cpp_easygraph/CMakeLists.txt index 5352190f..5490dba2 100644 --- a/cpp_easygraph/CMakeLists.txt +++ b/cpp_easygraph/CMakeLists.txt @@ -1,9 +1,12 @@ cmake_minimum_required(VERSION 3.23) + project(cpp_easygraph) -set(CMAKE_CXX_STANDARD 11) +set(CMAKE_CXX_STANDARD 11) +set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(OpenMP REQUIRED) +option(EASYGRAPH_ENABLE_OPENMP "Enable OpenMP acceleration (auto-detect)" ON) +option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) file(GLOB SOURCES classes/*.cpp @@ -12,12 +15,10 @@ file(GLOB SOURCES cpp_easygraph.cpp ) +# pybind11 submodule add_subdirectory(pybind11) -option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) - if (EASYGRAPH_ENABLE_GPU) - pybind11_add_module(cpp_easygraph ${SOURCES} $ @@ -25,27 +26,66 @@ if (EASYGRAPH_ENABLE_GPU) set_property(TARGET cpp_easygraph PROPERTY CUDA_ARCHITECTURES all) - target_compile_definitions(cpp_easygraph + target_compile_definitions(cpp_easygraph PRIVATE EASYGRAPH_ENABLE_GPU ) target_link_libraries(cpp_easygraph PRIVATE cudart_static ) - else() - pybind11_add_module(cpp_easygraph ${SOURCES} ) - endif() +if (EASYGRAPH_ENABLE_OPENMP) + + # macOS + AppleClang: Homebrew libomp is keg-only; help CMake find it. + if (APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # Try common Homebrew prefixes (Apple Silicon + Intel) + find_path(EG_LIBOMP_INCLUDE_DIR + NAMES omp.h + PATHS + /opt/homebrew/opt/libomp/include + /usr/local/opt/libomp/include + ) + + find_library(EG_LIBOMP_LIBRARY + NAMES omp libomp + PATHS + /opt/homebrew/opt/libomp/lib + /usr/local/opt/libomp/lib + ) + + if (EG_LIBOMP_INCLUDE_DIR AND EG_LIBOMP_LIBRARY) + message(STATUS "libomp found: ${EG_LIBOMP_LIBRARY}") + + # AppleClang: compile with -Xpreprocessor -fopenmp, link against libomp + set(OpenMP_C_FLAGS "-Xpreprocessor -fopenmp" CACHE STRING "" FORCE) + set(OpenMP_CXX_FLAGS "-Xpreprocessor -fopenmp" CACHE STRING "" FORCE) + + # Provide include & library hints for FindOpenMP + set(OpenMP_CXX_INCLUDE_DIR "${EG_LIBOMP_INCLUDE_DIR}" CACHE PATH "" FORCE) + set(OpenMP_omp_LIBRARY "${EG_LIBOMP_LIBRARY}" CACHE FILEPATH "" FORCE) + else() + message(STATUS + "libomp not found on macOS. OpenMP will be disabled.\n" + "To enable OpenMP, run: brew install libomp" + ) + endif() + endif() + + # Try to find OpenMP (quiet => will not hard-fail) + find_package(OpenMP QUIET) -target_link_libraries(cpp_easygraph PRIVATE OpenMP::OpenMP_CXX) - +endif() -#set_target_properties(cpp_easygraph PROPERTIES -# LINK_SEARCH_START_STATIC ON -# LINK_SEARCH_END_STATIC ON -#) \ No newline at end of file +if (OpenMP_CXX_FOUND) + message(STATUS "OpenMP found, enabling parallel acceleration.") + target_link_libraries(cpp_easygraph PRIVATE OpenMP::OpenMP_CXX) + target_compile_definitions(cpp_easygraph PRIVATE EASYGRAPH_USE_OPENMP=1) +else() + message(STATUS "OpenMP not found, building in single-thread mode.") + target_compile_definitions(cpp_easygraph PRIVATE EASYGRAPH_USE_OPENMP=0) +endif() From c4497b2089e5971872298512ca146aa5079d3abd Mon Sep 17 00:00:00 2001 From: wuyazu Date: Mon, 26 Jan 2026 18:33:02 +0800 Subject: [PATCH 28/33] support macOS --- cpp_easygraph/functions/centrality/betweenness.cpp | 2 ++ cpp_easygraph/functions/centrality/eigenvector.cpp | 2 ++ cpp_easygraph/functions/centrality/katz_centrality.cpp | 5 +++-- cpp_easygraph/functions/pagerank/pagerank.cpp | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp index 5da3d9b9..9bef3011 100644 --- a/cpp_easygraph/functions/centrality/betweenness.cpp +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -1,4 +1,6 @@ +#ifdef _OPENMP #include +#endif #include #include #include diff --git a/cpp_easygraph/functions/centrality/eigenvector.cpp b/cpp_easygraph/functions/centrality/eigenvector.cpp index ecf7faed..7b6e63b1 100644 --- a/cpp_easygraph/functions/centrality/eigenvector.cpp +++ b/cpp_easygraph/functions/centrality/eigenvector.cpp @@ -7,7 +7,9 @@ #include #include #include +#ifdef _OPENMP #include +#endif #include "centrality.h" #include "../../classes/graph.h" diff --git a/cpp_easygraph/functions/centrality/katz_centrality.cpp b/cpp_easygraph/functions/centrality/katz_centrality.cpp index d8403a7f..367d4397 100644 --- a/cpp_easygraph/functions/centrality/katz_centrality.cpp +++ b/cpp_easygraph/functions/centrality/katz_centrality.cpp @@ -1,9 +1,10 @@ +#ifdef _OPENMP +#include +#endif #include #include #include #include - -#include #include #include diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index efe95d07..cb42abae 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -1,4 +1,6 @@ +#ifdef _OPENMP #include +#endif #include #include #include From 164f6a445a90ffbb12e97dc28ea954b6e928d018 Mon Sep 17 00:00:00 2001 From: wuyazu Date: Mon, 26 Jan 2026 18:55:31 +0800 Subject: [PATCH 29/33] support macOS --- cpp_easygraph/CMakeLists.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cpp_easygraph/CMakeLists.txt b/cpp_easygraph/CMakeLists.txt index 5490dba2..a981afae 100644 --- a/cpp_easygraph/CMakeLists.txt +++ b/cpp_easygraph/CMakeLists.txt @@ -15,10 +15,10 @@ file(GLOB SOURCES cpp_easygraph.cpp ) -# pybind11 submodule add_subdirectory(pybind11) if (EASYGRAPH_ENABLE_GPU) + pybind11_add_module(cpp_easygraph ${SOURCES} $ @@ -33,17 +33,21 @@ if (EASYGRAPH_ENABLE_GPU) target_link_libraries(cpp_easygraph PRIVATE cudart_static ) + else() pybind11_add_module(cpp_easygraph ${SOURCES} ) + endif() -if (EASYGRAPH_ENABLE_OPENMP) +set_target_properties(cpp_easygraph PROPERTIES + LINK_SEARCH_START_STATIC ON + LINK_SEARCH_END_STATIC ON +) - # macOS + AppleClang: Homebrew libomp is keg-only; help CMake find it. +if (EASYGRAPH_ENABLE_OPENMP) if (APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") - # Try common Homebrew prefixes (Apple Silicon + Intel) find_path(EG_LIBOMP_INCLUDE_DIR NAMES omp.h PATHS @@ -61,11 +65,9 @@ if (EASYGRAPH_ENABLE_OPENMP) if (EG_LIBOMP_INCLUDE_DIR AND EG_LIBOMP_LIBRARY) message(STATUS "libomp found: ${EG_LIBOMP_LIBRARY}") - # AppleClang: compile with -Xpreprocessor -fopenmp, link against libomp set(OpenMP_C_FLAGS "-Xpreprocessor -fopenmp" CACHE STRING "" FORCE) set(OpenMP_CXX_FLAGS "-Xpreprocessor -fopenmp" CACHE STRING "" FORCE) - # Provide include & library hints for FindOpenMP set(OpenMP_CXX_INCLUDE_DIR "${EG_LIBOMP_INCLUDE_DIR}" CACHE PATH "" FORCE) set(OpenMP_omp_LIBRARY "${EG_LIBOMP_LIBRARY}" CACHE FILEPATH "" FORCE) else() @@ -76,9 +78,7 @@ if (EASYGRAPH_ENABLE_OPENMP) endif() endif() - # Try to find OpenMP (quiet => will not hard-fail) find_package(OpenMP QUIET) - endif() if (OpenMP_CXX_FOUND) @@ -88,4 +88,4 @@ if (OpenMP_CXX_FOUND) else() message(STATUS "OpenMP not found, building in single-thread mode.") target_compile_definitions(cpp_easygraph PRIVATE EASYGRAPH_USE_OPENMP=0) -endif() +endif() \ No newline at end of file From bf0770cde4e6d08d91d23568e0ab73a9cbb5bf6c Mon Sep 17 00:00:00 2001 From: wuyazu Date: Mon, 26 Jan 2026 19:54:31 +0800 Subject: [PATCH 30/33] support macOS --- cpp_easygraph/CMakeLists.txt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cpp_easygraph/CMakeLists.txt b/cpp_easygraph/CMakeLists.txt index a981afae..d7c123eb 100644 --- a/cpp_easygraph/CMakeLists.txt +++ b/cpp_easygraph/CMakeLists.txt @@ -41,11 +41,6 @@ else() endif() -set_target_properties(cpp_easygraph PROPERTIES - LINK_SEARCH_START_STATIC ON - LINK_SEARCH_END_STATIC ON -) - if (EASYGRAPH_ENABLE_OPENMP) if (APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") find_path(EG_LIBOMP_INCLUDE_DIR From f123f6d50c41e4e214046fbd647c51b229d1ee20 Mon Sep 17 00:00:00 2001 From: sama Date: Tue, 27 Jan 2026 03:02:38 -0700 Subject: [PATCH 31/33] Restore the original version --- easygraph/classes/graph.py | 98 ----- easygraph/nn/__init__.py | 6 - easygraph/nn/convs/__init__.py | 4 - easygraph/nn/convs/graphs/__init__.py | 5 - easygraph/nn/convs/graphs/gat_conv.py | 71 ---- easygraph/nn/convs/graphs/gcn_conv.py | 197 ---------- easygraph/nn/convs/graphs/gcnii_conv.py | 51 --- easygraph/nn/convs/graphs/gin_conv.py | 32 -- easygraph/nn/convs/graphs/sage_conv.py | 186 --------- easygraph/nn/tests/GAT_TESTs/result.out | 178 --------- easygraph/nn/tests/GAT_TESTs/test_gatconv.py | 261 ------------ .../nn/tests/GAT_TESTs/test_gatconv_cogdl.py | 229 ----------- .../nn/tests/GAT_TESTs/test_gatconv_dgl.py | 286 -------------- .../GAT_TESTs/test_gatconv_egsampling_dc.py | 322 --------------- .../nn/tests/GAT_TESTs/test_gatconv_pyg.py | 275 ------------- easygraph/nn/tests/GCN_TESTs/result_gcn.out | 216 ---------- easygraph/nn/tests/GCN_TESTs/test.py | 70 ---- easygraph/nn/tests/GCN_TESTs/test_GP.py | 143 ------- easygraph/nn/tests/GCN_TESTs/test_NDP.py | 123 ------ easygraph/nn/tests/GCN_TESTs/test_gcnconv.py | 372 ------------------ .../nn/tests/GCN_TESTs/test_gcnconv_cogdl.py | 346 ---------------- .../nn/tests/GCN_TESTs/test_gcnconv_copy.py | 275 ------------- .../nn/tests/GCN_TESTs/test_gcnconv_dgl.py | 233 ----------- .../nn/tests/GCN_TESTs/test_gcnconv_dim.py | 277 ------------- .../GCN_TESTs/test_gcnconv_egs_dc_multi.py | 215 ---------- .../GCN_TESTs/test_gcnconv_egsampling.py | 272 ------------- .../GCN_TESTs/test_gcnconv_egsampling_dc.py | 314 --------------- .../nn/tests/GCN_TESTs/test_gcnconv_multi.py | 218 ---------- .../nn/tests/GCN_TESTs/test_gcnconv_pyg.py | 256 ------------ .../tests/GCN_TESTs/test_gcnconv_pyg_multi.py | 216 ---------- .../nn/tests/GCN_TESTs/test_mix_Precision.py | 95 ----- easygraph/nn/tests/GCN_TESTs/txtReader.py | 42 -- .../nn/tests/autodl-tmp/data/mag/raw/mag.zip | 0 easygraph/nn/tests/dataset_info.py | 127 ------ easygraph/nn/tests/draw.py | 194 --------- easygraph/nn/tests/network_scatter_NC10.png | Bin 174379 -> 0 bytes .../data/OGBN_MAG/mag.zip => test_gatconv.py} | 0 .../mag/raw/mag.zip => test_gcnconv.py} | 0 38 files changed, 6205 deletions(-) delete mode 100644 easygraph/nn/convs/graphs/__init__.py delete mode 100644 easygraph/nn/convs/graphs/gat_conv.py delete mode 100644 easygraph/nn/convs/graphs/gcn_conv.py delete mode 100644 easygraph/nn/convs/graphs/gcnii_conv.py delete mode 100644 easygraph/nn/convs/graphs/gin_conv.py delete mode 100644 easygraph/nn/convs/graphs/sage_conv.py delete mode 100644 easygraph/nn/tests/GAT_TESTs/result.out delete mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv.py delete mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py delete mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py delete mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py delete mode 100644 easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/result_gcn.out delete mode 100644 easygraph/nn/tests/GCN_TESTs/test.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_GP.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_NDP.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py delete mode 100644 easygraph/nn/tests/GCN_TESTs/txtReader.py delete mode 100644 easygraph/nn/tests/autodl-tmp/data/mag/raw/mag.zip delete mode 100644 easygraph/nn/tests/dataset_info.py delete mode 100644 easygraph/nn/tests/draw.py delete mode 100644 easygraph/nn/tests/network_scatter_NC10.png rename easygraph/nn/tests/{autodl-tmp/data/OGBN_MAG/mag.zip => test_gatconv.py} (100%) rename easygraph/nn/tests/{autodl-tmp/data/OGBN_MAG/mag/raw/mag.zip => test_gcnconv.py} (100%) diff --git a/easygraph/classes/graph.py b/easygraph/classes/graph.py index e7235a49..07caabde 100644 --- a/easygraph/classes/graph.py +++ b/easygraph/classes/graph.py @@ -495,104 +495,6 @@ def L_GCN(self): ) return self.cache["L_GCN"] - @property - def edge_index(self): - import torch - if "edge_index" not in self.cache: - edge_list = [(u, v) for u, neighbors in self._adj.items() for v in neighbors] - self_loops = [(u, u) for u in self._adj.keys()] - edge_list += self_loops - edge_index = torch.tensor(edge_list, dtype=torch.long, device=self.device).t().contiguous() - self.cache["edge_index"] = edge_index - return self.cache["edge_index"] - - @property - def norm_info(self): - import torch - - if "norm_info" not in self.cache: - edge_index = self.edge_index - row, col = edge_index - deg_dict = self.degree() - deg = torch.tensor([deg_dict[i] for i in range(len(self.nodes))], dtype=torch.float32) - # deg = self.degree_tensor(col, len(self.nodes), dtype=torch.float32) - deg_inv_sqrt = deg.pow(-0.5) - deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0 - norm = deg_inv_sqrt[row] * deg_inv_sqrt[col] - self.cache["norm_info"] = (row, col, norm) - return self.cache["norm_info"] - - @property - def adj_t(self): - from torch_sparse import SparseTensor - if "adj_t" not in self.cache: - row, col, norm = self.norm_info - N = len(self.nodes) - - self.cache["adj_t"] = SparseTensor(row=row, col=col, value=norm, sparse_sizes=(len(self.nodes), len(self.nodes))) - - return self.cache["adj_t"] - - def build_adj_gp(self, nparts: int = 4): - if "adj_gp" not in self.cache: - import ctypes - import numpy as np - import torch - import metis - from torch_sparse import SparseTensor - - row, col, norm = self.norm_info - N = len(self.nodes) - nnz = len(row) - - adj_list = [[] for _ in range(N)] - for u, v in zip(row, col): - adj_list[u].append(v) - - # --- 修改后的划分预处理 --- - idx_t = ctypes.c_int32 - xadj = (idx_t*(N+1))() # shape: (N+1,) - adjncy = (idx_t*(nnz))() # shape: (nnz,) - adjwgt = (idx_t*nnz)() # shape: (nnz,) - xadj[0] = ptr = 0 - for i, adj in enumerate(adj_list): - for j in adj: - adjncy[ptr] = j - adjwgt[ptr] = 1 - ptr += 1 - xadj[i+1] = ptr - - _, parts = metis.part_graph({ - 'nvtxs': idx_t(N), # 节点数 - 'ncon': idx_t(1), - 'xadj': xadj, - 'adjncy': adjncy, - 'vwgt': None, # 节点权重 - 'vsize': None, # 节点大小 默认 None - 'adjwgt': adjwgt # 边权重 - }, nparts=nparts) - - part_to_nodes = [[] for _ in range(nparts)] - for idx, p in enumerate(parts): - part_to_nodes[p].append(idx) - - perm = np.concatenate(part_to_nodes) - - inv_perm = np.argsort(perm) - - row2 = inv_perm[row] - col2 = inv_perm[col] - adj_gp = SparseTensor( - row=torch.tensor(row2, dtype=torch.long), - col=torch.tensor(col2, dtype=torch.long), - value=norm, - sparse_sizes=(N, N) - ) - self.cache['adj_gp'] = adj_gp - self.cache['gp_perm'] = torch.tensor(perm) - self.cache['gp_inv_perm'] = torch.tensor(inv_perm) - return - def smoothing_with_GCN(self, X, drop_rate=0.0): r"""Return the smoothed feature matrix with GCN Laplacian matrix :math:`\mathcal{L}_{GCN}`. diff --git a/easygraph/nn/__init__.py b/easygraph/nn/__init__.py index 88053be0..0c2386cf 100644 --- a/easygraph/nn/__init__.py +++ b/easygraph/nn/__init__.py @@ -21,9 +21,3 @@ " torch_scatter before you use functions related to AllDeepSet and" " AllSetTransformer." ) - -from .convs import GATConv -from .convs import GCNConv -from .convs import GraphSAGEConv - -__all__ = ['GCNConv', 'GATConv', 'GraphSAGEConv'] diff --git a/easygraph/nn/convs/__init__.py b/easygraph/nn/convs/__init__.py index 20cd9dc3..8b137891 100644 --- a/easygraph/nn/convs/__init__.py +++ b/easygraph/nn/convs/__init__.py @@ -1,5 +1 @@ -from .graphs import GCNConv -from .graphs import GATConv -from .graphs import GraphSAGEConv -__all__ = ['GCNConv', 'GATConv', 'GraphSAGEConv'] diff --git a/easygraph/nn/convs/graphs/__init__.py b/easygraph/nn/convs/graphs/__init__.py deleted file mode 100644 index 73bf5727..00000000 --- a/easygraph/nn/convs/graphs/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .gcn_conv import GCNConv -from .gat_conv import GATConv -from .sage_conv import GraphSAGEConv - -__all__ = ['GCNConv', 'GATConv', 'GraphSAGEConv'] diff --git a/easygraph/nn/convs/graphs/gat_conv.py b/easygraph/nn/convs/graphs/gat_conv.py deleted file mode 100644 index 9f5706ae..00000000 --- a/easygraph/nn/convs/graphs/gat_conv.py +++ /dev/null @@ -1,71 +0,0 @@ -import torch -import torch.nn as nn -import torch.nn.functional as F - -class GATConv(nn.Module): - def __init__(self, in_channels, out_channels, heads=4, concat=True, dropout=0.5, bias=True): - super().__init__() - self.in_channels = in_channels - self.out_channels = out_channels - self.heads = heads - self.concat = concat - self.dropout = dropout - - self.weight = nn.Parameter(torch.Tensor(heads, in_channels, out_channels)) - - self.att = nn.Parameter(torch.Tensor(heads, 2 * out_channels)) - - if bias: - self.bias = nn.Parameter(torch.zeros(out_channels * heads if concat else out_channels)) - else: - self.register_parameter('bias', None) - - self.leakyrelu = nn.LeakyReLU(0.2) - self.reset_parameters() - - def reset_parameters(self): - nn.init.xavier_uniform_(self.weight) - nn.init.xavier_uniform_(self.att) - if self.bias is not None: - nn.init.zeros_(self.bias) - - def forward(self, x, g): - - device = x.device - N = x.size(0) - H, C = self.heads, self.out_channels - src, dst = g.edge_index - - h = torch.einsum('nf,hfc->nhc', x, self.weight) - if self.training and self.dropout > 0: - h = F.dropout(h, p=self.dropout, training=True) - - h_src = h[src] - h_dst = h[dst] - - h_cat = torch.cat([h_src, h_dst], dim=-1) - - alpha = (h_cat * self.att.unsqueeze(0)).sum(dim=-1) - alpha = self.leakyrelu(alpha) - - alpha_max = torch.full((N,H), -1e9, device=device) - alpha_max.scatter_reduce_(0, dst[:,None].expand(-1,H), alpha, reduce="amax", include_self=True) - alpha_exp = torch.exp(alpha - alpha_max[dst]) - sum_exp = torch.zeros(N,H,device=device).index_add_(0, dst, alpha_exp) - alpha = alpha_exp / (sum_exp[dst] + 1e-16) - - if self.training and self.dropout > 0: - alpha = F.dropout(alpha, p=self.dropout, training=True) - - out = torch.zeros(N,H,C,device=device) - out.index_add_(0, dst, h_src * alpha.unsqueeze(-1)) - - if self.concat: - out = out.reshape(N, H*C) - else: - out = out.mean(dim=1) - - if self.bias is not None: - out = out + self.bias.view(1,-1) - - return out diff --git a/easygraph/nn/convs/graphs/gcn_conv.py b/easygraph/nn/convs/graphs/gcn_conv.py deleted file mode 100644 index 1a9581d0..00000000 --- a/easygraph/nn/convs/graphs/gcn_conv.py +++ /dev/null @@ -1,197 +0,0 @@ -import torch -import torch.nn as nn -# class GCNConv(nn.Module): -# def __init__(self, in_channels, out_channels, bias=True): -# super(GCNConv, self).__init__() -# self.in_channels = in_channels -# self.out_channels = out_channels -# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) -# if bias: -# self.bias = nn.Parameter(torch.Tensor(out_channels)) -# else: -# self.register_parameter('bias', None) - -# self.reset_parameters() - -# def reset_parameters(self): -# nn.init.xavier_uniform_(self.weight) -# if self.bias is not None: -# nn.init.zeros_(self.bias) - -# def forward(self, x, g): - -# out = g.adj_t.matmul(x @ self.weight) - -# if self.bias is not None: -# out += self.bias - -# return out - -# def __repr__(self): -# return '{}({}, {})'.format(self.__class__.__name__, self.in_channels, self.out_channels) - - -## The version of the GP - -# import torch -# import torch.nn as nn - -# class GCNConv(nn.Module): -# ''' -# GCN with graph partition version -# ''' -# def __init__(self, in_channels, out_channels, bias=True): -# super(GCNConv, self).__init__() -# self.in_channels = in_channels -# self.out_channels = out_channels -# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) -# if bias: -# self.bias = nn.Parameter(torch.Tensor(out_channels)) -# else: -# self.register_parameter('bias', None) - -# self.reset_parameters() - -# def reset_parameters(self): -# nn.init.xavier_uniform_(self.weight) -# if self.bias is not None: -# nn.init.zeros_(self.bias) - -# def forward(self, x, g): - -# out = g.cache['adj_gp'].matmul(x @ self.weight) - -# if self.bias is not None: -# out += self.bias -# return out - -# def __repr__(self): -# return '{}({}, {})'.format(self.__class__.__name__, self.in_channels, self.out_channels) - - - -## The version of the GP+backward - -# class FastGCNConvFn(torch.autograd.Function): -# @staticmethod -# def forward(ctx, x, weight, adj, bias=None): -# AX = adj.matmul(x) -# out = AX @ weight -# # out = adj.matmul(x @ weight) -# if bias is not None: -# out += bias -# # AX, x, weight, bias 是 Tensor,可以用 save_for_backward -# ctx.save_for_backward(AX, weight, bias) -# # adj 是稀疏矩阵,直接赋值给 ctx -# ctx.adj = adj -# return out - -# @staticmethod -# def backward(ctx, grad_out): -# AX, weight, bias = ctx.saved_tensors -# adj = ctx.adj - -# grad_x = grad_w = grad_b = None -# grad_w = AX.T @ grad_out -# grad_x = adj.matmul(grad_out @ weight.T) -# if bias is not None: -# grad_b = grad_out.sum(0) -# return grad_x, grad_w, None, grad_b - -# class GCNConv(nn.Module): -# def __init__(self, in_channels, out_channels, bias=True): -# super().__init__() -# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) -# self.bias = nn.Parameter(torch.Tensor(out_channels)) if bias else None -# self.reset_parameters() - -# def reset_parameters(self): -# nn.init.xavier_uniform_(self.weight) -# if self.bias is not None: -# nn.init.zeros_(self.bias) - -# def forward(self, x, g): -# return FastGCNConvFn.apply(x, self.weight, g.cache['adj_gp'], self.bias) - - - -### The version of C++ BK -# try: -# import cpp_easygraph -# HAS_CPP_BACKEND = True -# except ImportError: -# print("Warning: cpp_easygraph module not found. Using slow Python fallback.") -# HAS_CPP_BACKEND = False - -# class GCNConv(nn.Module): -# def __init__(self, in_channels, out_channels, bias=True): -# super().__init__() -# self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) -# self.bias = nn.Parameter(torch.Tensor(out_channels)) if bias else None -# self.reset_parameters() - -# def reset_parameters(self): -# nn.init.xavier_uniform_(self.weight) -# if self.bias is not None: -# nn.init.zeros_(self.bias) - -# def forward(self, x, g): -# return cpp_easygraph.upscale_gcn_forward(x, self.weight, g.cache['adj_torch'], self.bias) - - -### The version of GP+BW upup -class FastGCNConvFn(torch.autograd.Function): - @staticmethod - def forward(ctx, x, weight, adj, bias=None): - AX = adj.matmul(x) - out = AX @ weight - - if bias is not None: - out += bias - - ctx.save_for_backward(AX, weight, bias) - ctx.adj = adj - - return out - - @staticmethod - def backward(ctx, grad_out): - AX, weight, bias = ctx.saved_tensors - adj = ctx.adj - - grad_x = grad_w = grad_b = None - - if ctx.needs_input_grad[1]: - grad_w = AX.t() @ grad_out - - if ctx.needs_input_grad[0]: - grad_temp = grad_out @ weight.t() - grad_x = adj.t().matmul(grad_temp) - - # 3. 计算 Bias 梯度 - if bias is not None and ctx.needs_input_grad[3]: - grad_b = grad_out.sum(0) - - return grad_x, grad_w, None, grad_b - -class GCNConv(nn.Module): - def __init__(self, in_channels, out_channels, bias=True): - super().__init__() - self.in_channels = in_channels - self.out_channels = out_channels - self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) - self.bias = nn.Parameter(torch.Tensor(out_channels)) if bias else None - self.reset_parameters() - - def reset_parameters(self): - nn.init.xavier_uniform_(self.weight) - if self.bias is not None: - nn.init.zeros_(self.bias) - - def forward(self, x, g): - # 1. 检查 adj_gp 是否存在 - if not hasattr(g, 'cache') or 'adj_gp' not in g.cache: - raise RuntimeError("EasyGraph Error: 'adj_gp' not found in graph cache. Please run g.build_adj_gp() first.") - - - return FastGCNConvFn.apply(x, self.weight, g.cache['adj_gp'], self.bias) \ No newline at end of file diff --git a/easygraph/nn/convs/graphs/gcnii_conv.py b/easygraph/nn/convs/graphs/gcnii_conv.py deleted file mode 100644 index 7521dc97..00000000 --- a/easygraph/nn/convs/graphs/gcnii_conv.py +++ /dev/null @@ -1,51 +0,0 @@ -import torch -import torch.nn as nn - -class GCNIIConv(nn.Module): - def __init__(self, in_channels, out_channels, alpha=0.1, theta=0.5, bias=True, learnable_alpha=False): - - super().__init__() - self.in_channels = in_channels - self.out_channels = out_channels - self.theta = theta - - self.weight = nn.Parameter(torch.Tensor(in_channels, out_channels)) - if learnable_alpha: - self.alpha = nn.Parameter(torch.Tensor([alpha])) - else: - self.register_buffer('alpha', torch.Tensor([alpha])) - if bias: - self.bias = nn.Parameter(torch.Tensor(out_channels)) - else: - self.register_parameter('bias', None) - - self.reset_parameters() - - def reset_parameters(self): - nn.init.xavier_uniform_(self.weight) - if hasattr(self, 'bias') and self.bias is not None: - nn.init.zeros_(self.bias) - if hasattr(self, 'alpha') and isinstance(self.alpha, nn.Parameter): - nn.init.constant_(self.alpha, self.alpha.item()) - - def forward(self, x, g, x0=None): - - if x0 is None: - x0 = x - - agg = g.adj_t.matmul(x @ self.weight) - - out = (1 - self.alpha) * agg + self.alpha * x0 - - out = self.theta * out - - if self.bias is not None: - out = out + self.bias - - return out - - def __repr__(self): - return '{}({}, {}, alpha={}, theta={})'.format( - self.__class__.__name__, self.in_channels, self.out_channels, - self.alpha.item(), self.theta - ) diff --git a/easygraph/nn/convs/graphs/gin_conv.py b/easygraph/nn/convs/graphs/gin_conv.py deleted file mode 100644 index 9253e81f..00000000 --- a/easygraph/nn/convs/graphs/gin_conv.py +++ /dev/null @@ -1,32 +0,0 @@ -import torch -import torch.nn as nn - -class GINConv(nn.Module): - def __init__(self, in_channels, out_channels, eps=0.0, train_eps=True): - super().__init__() - # MLP 层 - self.mlp = nn.Sequential( - nn.Linear(in_channels, out_channels), - nn.ReLU(), - nn.Linear(out_channels, out_channels) - ) - if train_eps: - self.eps = nn.Parameter(torch.Tensor([eps])) - else: - self.register_buffer('eps', torch.Tensor([eps])) - self.reset_parameters() - - def reset_parameters(self): - for layer in self.mlp: - if isinstance(layer, nn.Linear): - nn.init.xavier_uniform_(layer.weight) - nn.init.zeros_(layer.bias) - - def forward(self, x, g): - - agg = g.adj_t.matmul(x) - - out = (1 + self.eps) * x + agg - - out = self.mlp(out) - return out diff --git a/easygraph/nn/convs/graphs/sage_conv.py b/easygraph/nn/convs/graphs/sage_conv.py deleted file mode 100644 index 5280ac68..00000000 --- a/easygraph/nn/convs/graphs/sage_conv.py +++ /dev/null @@ -1,186 +0,0 @@ -import math -import torch -import torch.nn as nn -import torch.nn.functional as F -from torch.nn.parameter import Parameter - - -# class GraphSAGEConv(nn.Module): -# """ -# GraphSAGE convolution layer supporting 'mean' and 'pool' aggregation. - -# Parameters: -# in_channels (int): Input feature dimension. -# out_channels (int): Output feature dimension. -# aggr (str): Aggregation method, either 'mean' or 'pool'. -# bias (bool): Whether to add bias. -# dropout (float): Dropout rate. -# use_bn (bool): Whether to use BatchNorm1d. -# is_last (bool): If True, skip activation and dropout. -# """ - -# def __init__( -# self, -# in_channels: int, -# out_channels: int, -# aggr: str = "mean", -# bias: bool = True, -# dropout: float = 0.5, -# use_bn: bool = False, -# is_last: bool = False, -# ): -# super(GraphSAGEConv, self).__init__() -# assert aggr in ["mean", "pool"] -# self.in_channels = in_channels -# self.out_channels = out_channels -# self.aggr = aggr -# self.dropout = dropout -# self.is_last = is_last -# self.use_bn = use_bn - -# self.weight = Parameter(torch.Tensor(in_channels * 2, out_channels)) -# if aggr == "pool": -# self.fc_pool = nn.Linear(in_channels, in_channels) - -# if bias: -# self.bias = Parameter(torch.Tensor(out_channels)) -# else: -# self.register_parameter('bias', None) - -# if self.use_bn: -# self.bn = nn.BatchNorm1d(out_channels) -# else: -# self.bn = None - -# self.reset_parameters() - -# def reset_parameters(self): -# stdv = 1. / math.sqrt(self.out_channels) -# self.weight.data.uniform_(-stdv, stdv) -# if self.bias is not None: -# self.bias.data.uniform_(-stdv, stdv) -# if self.aggr == "pool": -# nn.init.xavier_uniform_(self.fc_pool.weight) - -# def forward(self, x, adj): -# N = x.size(0) -# if adj.is_sparse: -# adj = adj.to_dense() - -# if self.aggr == "mean": -# deg = adj.sum(dim=1, keepdim=True).clamp(min=1) -# agg = torch.matmul(adj, x) / deg - -# elif self.aggr == "pool": -# x_pool = F.relu(self.fc_pool(x)) - -# masked = adj.unsqueeze(-1) * x_pool.unsqueeze(0) -# masked[adj == 0] = float('-inf') -# agg = torch.max(masked, dim=1)[0] - -# else: -# raise NotImplementedError - -# h = torch.cat([x, agg], dim=1) -# h = torch.matmul(h, self.weight) - -# if self.bias is not None: -# h = h + self.bias - -# if not self.is_last: -# h = F.relu(h) -# if self.bn is not None: -# h = self.bn(h) -# h = F.dropout(h, p=self.dropout, training=self.training) - -# return h - -# def __repr__(self): -# return f"{self.__class__.__name__}({self.in_channels} -> {self.out_channels}, aggr='{self.aggr}')" - -class GraphSAGEConv(nn.Module): - """ - GraphSAGE convolution layer supporting 'mean' and 'pool' aggregation with SparseTensor. - Uses g.adj_t for adjacency to maintain consistent API. - """ - def __init__( - self, - in_channels: int, - out_channels: int, - aggr: str = "mean", - bias: bool = True, - dropout: float = 0.5, - use_bn: bool = False, - is_last: bool = False, - ): - super(GraphSAGEConv, self).__init__() - assert aggr in ["mean", "pool"] - self.in_channels = in_channels - self.out_channels = out_channels - self.aggr = aggr - self.dropout = dropout - self.is_last = is_last - self.use_bn = use_bn - - self.weight = Parameter(torch.Tensor(in_channels * 2, out_channels)) - if aggr == "pool": - self.fc_pool = nn.Linear(in_channels, in_channels) - - if bias: - self.bias = Parameter(torch.Tensor(out_channels)) - else: - self.register_parameter('bias', None) - - if self.use_bn: - self.bn = nn.BatchNorm1d(out_channels) - else: - self.bn = None - - self.reset_parameters() - - def reset_parameters(self): - stdv = 1. / math.sqrt(self.out_channels) - self.weight.data.uniform_(-stdv, stdv) - if self.bias is not None: - self.bias.data.uniform_(-stdv, stdv) - if self.aggr == "pool": - nn.init.xavier_uniform_(self.fc_pool.weight) - - def forward(self, x, g): - """ - x: (N, F_in) - g: Graph object with g.adj_t as torch_sparse.SparseTensor - """ - adj_t: SparseTensor = g.adj_t # 从图对象取稀疏邻接矩阵 - - if self.aggr == "mean": - # 邻居聚合 (稀疏矩阵乘法) - agg = adj_t.matmul(x) - # 计算度并做平均 - deg = adj_t.sum(dim=1).clamp(min=1).unsqueeze(-1) - agg = agg / deg - - elif self.aggr == "pool": - # 非线性映射 - x_pool = F.relu(self.fc_pool(x)) - # 用 torch_scatter 实现基于邻居的 max pooling - row, col, _ = adj_t.coo() # 获取边索引 - agg = scatter(x_pool[col], row, dim=0, reduce='max') - - else: - raise NotImplementedError - - # 拼接自身特征与聚合特征 - h = torch.cat([x, agg], dim=1) - h = torch.matmul(h, self.weight) - - if self.bias is not None: - h = h + self.bias - - if not self.is_last: - h = F.relu(h) - if self.bn is not None: - h = self.bn(h) - h = F.dropout(h, p=self.dropout, training=self.training) - - return h \ No newline at end of file diff --git a/easygraph/nn/tests/GAT_TESTs/result.out b/easygraph/nn/tests/GAT_TESTs/result.out deleted file mode 100644 index 38addc40..00000000 --- a/easygraph/nn/tests/GAT_TESTs/result.out +++ /dev/null @@ -1,178 +0,0 @@ -nohup: ignoring input - Please install Pytorch before use graph-related datasets and hypergraph-related datasets. - -================= 数据集: CS ================= -Train nodes: 10999 | Val nodes: 3667 | Test nodes: 3667 -81894 - - 0%| | 0/50 [00:00= EARLY_STOP_WINDOW: - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py deleted file mode 100644 index ecfa6c45..00000000 --- a/easygraph/nn/tests/GAT_TESTs/test_gatconv_cogdl.py +++ /dev/null @@ -1,229 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics - - -# -------------------- 配置 -------------------- -BACKEND = 'Cogdl' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 500 -HIDDEN_DIM = 8 -DROPOUT = 0.6 -Heads = 8 -EARLY_STOP_WINDOW = 100 -RR = 5 - -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - # ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products') -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -from cogdl.models.nn.gat import GAT -from cogdl.data import Graph - -transform = T.NormalizeFeatures() - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - g = Graph( - x=data.x, - edge_index=data.edge_index, - y=data.y, - num_nodes=num_nodes - ) - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - elif backend_type in 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - # -------------------- 初始化模型 -------------------- - model = GAT(in_feats = dataset.num_node_features, hidden_size = HIDDEN_DIM, - out_features = dataset.num_classes, num_layers = 2, dropout=DROPOUT, alpha=0.2, - attn_drop = DROPOUT, nhead= Heads, residual=False, last_nhead=1, norm=None).to(DEVICE) - model = torch.compile(model, backend="inductor") - optimizer = torch.optim.Adam( - model.parameters(), - lr=0.005, # 学习率 0.005 - weight_decay=5e-4 # L2 正则 - ) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - for epoch in tqdm(range(1, EPOCHS+1)): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(g) - end_fwd = time.perf_counter() - - # loss 计算 - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - if loss_val.item() < best_val_loss: - best_val_loss = loss_val.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py deleted file mode 100644 index 9447ddd2..00000000 --- a/easygraph/nn/tests/GAT_TESTs/test_gatconv_dgl.py +++ /dev/null @@ -1,286 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import torch.nn as nn -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm - -# -------------------- 配置 -------------------- -BACKEND = 'dgl' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 500 -HIDDEN_DIM = 8 -DROPOUT = 0.6 -Heads = 8 -EARLY_STOP_WINDOW = 100 -RR = 1 - -DATASETS = [ - # ('Coauthor', 'CS'), - ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - # ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products') -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- - -from torch_geometric.datasets import Coauthor, Planetoid -import torch_geometric.transforms as T -import dgl -# import dgl.nn.pytorch as dglnn -from dgl.nn import GATConv -from ogb.nodeproppred import PygNodePropPredDataset - -# 解决 torch.load 报错 -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -transform = T.NormalizeFeatures() -# 数据集选择(自行切换) -# dataset = Coauthor(root='data/Coauthor', name='CS') -# dataset = Coauthor(root='data/Coauthor', name='Physics') -# dataset = Planetoid(root='/tmp/Cora', name='Cora') -# dataset = Planetoid(root='/tmp/PubMed', name='PubMed', transform=transform) -# dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='data/OGB') -# dataset = PygNodePropPredDataset(name='ogbn-products', root='data/OGB') -# data = dataset[0] - -# 构建 DGL 图(用 PyG 的 edge_index 转换) -class GAT(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.6, heads=8): - super(GAT, self).__init__() - self.dropout = dropout - - # 第一层: 多头注意力 + concat - self.gat1 = GATConv( - in_feats=in_channels, - out_feats=hidden_channels, - num_heads=heads, - feat_drop=dropout, - attn_drop=dropout, - activation=F.elu - ) - - self.gat2 = GATConv( - in_feats=hidden_channels * heads, - out_feats=out_channels, - num_heads=8, - feat_drop=dropout, - attn_drop=dropout, - activation=None - ) - - def forward(self, g, x): - # 第一层 - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gat1(g, x) - x = x.flatten(1) - # 第二层 - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gat2(g, x) - x = x.mean(1) - - return x - -for backend_type, dataset_name in DATASETS: - - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - # 原始边 - src, dst = data.edge_index - g = dgl.graph((src, dst), num_nodes=data.num_nodes) - g = dgl.add_self_loop(g) - g = g.to(DEVICE) - g.ndata['feat'] = data.x.to(DEVICE) - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - elif backend_type in 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - - g.ndata['label'] = data.y.to(DEVICE) - g.ndata['train_mask'] = train_mask.to(DEVICE) - g.ndata['val_mask'] = val_mask.to(DEVICE) - g.ndata['test_mask'] = test_mask.to(DEVICE) - - - All_forward_times = [] - All_backward_times = [] - All_epoch_times = [] - All_total_train_time = [] - All_test_acc = [] - ALL_f1 = [] - - for R in tqdm(range(RR)): - # -------------------- 设备转移 -------------------- - model = GAT(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT, heads= Heads).to(DEVICE) - # -------------------- 训练配置 -------------------- - optimizer = torch.optim.Adam( - model.parameters(), - lr=0.005, # 学习率 0.005 - weight_decay=5e-4 # L2 正则 - ) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - for epoch in range(1, EPOCHS + 1): - model.train() - optimizer.zero_grad() - epoch_start = time.perf_counter() - - # 前向传播 - start_fwd = time.perf_counter() - out = model(g, g.ndata['feat']) - end_fwd = time.perf_counter() - - # 计算loss - loss = criterion(out[g.ndata['train_mask']], g.ndata['label'][g.ndata['train_mask']].squeeze()) - loss_test = criterion(out[g.ndata['test_mask']], g.ndata['label'][g.ndata['test_mask']].squeeze()) - loss_val = criterion(out[g.ndata['val_mask']], g.ndata['label'][g.ndata['val_mask']].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - epoch_end = time.perf_counter() - - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(epoch_end - epoch_start) - - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - # if loss_val.item() < best_val_loss: - # best_val_loss = loss_val.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试函数 -------------------- - @torch.no_grad() - def evaluate(model, data, mask_key='test_mask'): - model.eval() - out = model(g, g.ndata['feat']) - mask = g.ndata[mask_key] - pred = out.argmax(dim=1) - correct = (pred[mask] == g.ndata['label'][mask].squeeze()).sum() - acc = int(correct) / int(mask.sum()) - macro_f1 = f1_score(g.ndata['label'][mask].squeeze(), pred[mask], average='macro') - return acc, macro_f1 - - test_acc, f1 = evaluate(model, data) - - # -------------------- 结果输出 -------------------- - avg_fwd = sum(forward_times) / len(forward_times) - avg_bwd = sum(backward_times) / len(backward_times) - avg_epoch = sum(epoch_times) / len(epoch_times) - - All_forward_times.append(avg_fwd) - All_backward_times.append(avg_bwd) - All_epoch_times.append(avg_epoch) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(f1) - - # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') - # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') - # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') - - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") \ No newline at end of file diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py deleted file mode 100644 index e22116eb..00000000 --- a/easygraph/nn/tests/GAT_TESTs/test_gatconv_egsampling_dc.py +++ /dev/null @@ -1,322 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -from easygraph.utils.Effective_R import EffectiveResistance -# from easygraph.utils.GraphSampler import FERGraphSampler -from easygraph.utils.GraphSampler import degree_community_sampling -torch.set_num_threads(30) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 500 -HIDDEN_DIM = 8 -DROPOUT = 0.6 -Heads = 8 -EARLY_STOP_WINDOW = 100 -RR = 5 - - - -DATASETS = [ - # ('Coauthor', 'CS'), - ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - # ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products'), - # ('ogb2', 'ogbn-mag'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -# def feature_similarity(x, edge_index): -# src, dst = edge_index -# sim = F.cosine_similarity(x[src], x[dst]) -# sim = (sim + 1.0) / 2.0 # 归一化到 [0,1] -# return sim - -# def FER_score(r_ij, s_ij, alpha=1.0, beta=1.0): -# score = (r_ij ** alpha) * (s_ij ** beta) -# return score / (score.max() + 1e-12) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr, Amazon -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import easygraph as eg - -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -class GAT(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.6, heads=8): - super(GAT, self).__init__() - self.dropout = dropout - - self.gat1 = eg.GATConv( - in_channels=in_channels, - out_channels=hidden_channels, - heads=heads, - concat=True, - dropout=dropout - ) - - self.gat2 = eg.GATConv( - in_channels=hidden_channels * heads, - out_channels=out_channels, - heads=8, - concat=False, - dropout=dropout - ) - - def forward(self, x, g): - - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gat1(x, g) - x = F.elu(x) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gat2(x, g) - - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') - elif backend_type =='ogb2': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/autodl-tmp/zss/data/OGB') - elif backend_type == 'Reddit': - dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') - elif backend_type == 'Flickr': - dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') - elif backend_type == 'Yelp': - dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - - elif backend_type == 'ogb2': - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - - elif backend_type == 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type in ['Reddit', 'Flickr', 'Coauthor']: - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - elif backend_type == 'Yelp': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - - print('开始采样') - g_s, x_s, sampled_nodes_tensor = degree_community_sampling( - data.edge_index, data.x, data.y, num_nodes, sample_ratio=0.4, - min_nodes=150, alpha=0.65, random_ratio=0.08, bridge_ratio=0.1, k=20, nodes_per_class=17) - - print('采样完成') - print(f'节点数量: {len(g_s.nodes)}, 边数:{len(g_s.edges)}') - - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - x_s = x_s.to(DEVICE) - - # -------------------- 构建训练 mask 子集 -------------------- - train_mask_sub = data.train_mask[sampled_nodes_tensor] - val_mask_sub = data.val_mask[sampled_nodes_tensor] - test_mask_sub = data.test_mask[sampled_nodes_tensor] - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - - # sample_g = FERGraphSampler(edge_index, num_nodes, FER) - # -------------------- 初始化模型 -------------------- - model = GAT(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT, heads= Heads).to(DEVICE) - optimizer = torch.optim.Adam( - model.parameters(), - lr=0.005, # 学习率 0.005 - weight_decay=5e-4 # L2 正则 - ) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - train_start = time.perf_counter() - - for epoch in tqdm(range(1, EPOCHS+1)): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(x_s, g_s) - end_fwd = time.perf_counter() - - # -------------------- 子图 loss -------------------- - # loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub].squeeze()) - loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub].squeeze()) - - # -------------------- 全图验证 -------------------- - model.eval() - with torch.no_grad(): - out_full = model(data.x, g) # 全图前向 - loss_val = criterion(out_full[data.val_mask], data.y[data.val_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - - - # early stopping - if loss_val.item() < best_val_loss: - best_val_loss = loss_val.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") - - # -------------------- 输出最终结果 -------------------- - # print(f'\nTrain Loss = {LOSS_LIST}') - # print(f'Valid Loss = {LOSS_LIST_VALID}') - # print(f'Test Loss = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py b/easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py deleted file mode 100644 index 09937da4..00000000 --- a/easygraph/nn/tests/GAT_TESTs/test_gatconv_pyg.py +++ /dev/null @@ -1,275 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -torch.set_num_threads(30) - -# -------------------- 配置 -------------------- -BACKEND = 'PyG' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 500 -HIDDEN_DIM = 256 -DROPOUT = 0.6 -Heads = 8 -EARLY_STOP_WINDOW = 100 -RR = 10 - -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products') -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -from torch_geometric.nn import GATConv -# from torch_geometric.nn.models import GAT -import easygraph as eg - -transform = T.NormalizeFeatures() - - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -class GATNet(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.6): - super(GATNet, self).__init__() - self.dropout = dropout - # 第一层: 8个head, 每个head输出8维 - self.conv1 = GATConv( - in_channels, hidden_channels, - heads = 8, - dropout=dropout, # attention dropout - ) - # 第二层: 单head输出分类结果 - self.conv2 = GATConv( - hidden_channels * 8, # 因为 concat - out_channels, - heads=1, - concat=False, # 原文最后一层不concat - dropout=dropout, - ) - - def forward(self, x, edge_index): - x = F.dropout(x, p=self.dropout, training=self.training) # 输入特征 dropout - x = self.conv1(x, edge_index) - x = F.elu(x) # 原文用 ELU - - x = F.dropout(x, p=self.dropout, training=self.training) # 输入特征 dropout - x = self.conv2(x, edge_index) - - return x # CrossEntropyLoss 里自带 softmax - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - elif backend_type in 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - print(len(g.edges)) - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - # -------------------- 初始化模型 -------------------- - # model = GAT( - # in_channels=dataset.num_node_features, - # hidden_channels=HIDDEN_DIM, - # out_channels=dataset.num_classes, - # num_layers=2, - # heads=Heads, - # dropout=DROPOUT - # ).to(DEVICE) - - model = GATNet( - in_channels=dataset.num_node_features, - hidden_channels=HIDDEN_DIM, - out_channels=dataset.num_classes, - dropout=DROPOUT - ).to(DEVICE) - - # model = torch.compile(model, backend="inductor") - - optimizer = torch.optim.Adam( - model.parameters(), - lr=0.005, # 学习率 0.005 - weight_decay=5e-4 # L2 正则 - ) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(data.x, data.edge_index) - end_fwd = time.perf_counter() - - # loss 计算 - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - # if loss_val.item() < best_val_loss: - # best_val_loss = loss_val.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, data.edge_index) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/result_gcn.out b/easygraph/nn/tests/GCN_TESTs/result_gcn.out deleted file mode 100644 index 30779f09..00000000 --- a/easygraph/nn/tests/GCN_TESTs/result_gcn.out +++ /dev/null @@ -1,216 +0,0 @@ -nohup: ignoring input - Please install Pytorch before use graph-related datasets and hypergraph-related datasets. - -================= 数据集: ogbn-arxiv ================= -Train nodes: 90941 | Val nodes: 29799 | Test nodes: 48603 -1157799 - - 0%| | 0/50 [00:00 0 - deg_inv_sqrt[nz] = 1.0 / np.sqrt(deg[nz]) - val = deg_inv_sqrt[row] * deg_inv_sqrt[col] - val_torch = torch.tensor(val, dtype=torch.float32) - - adj_gp = SparseTensor( - row=torch.tensor(row, dtype=torch.long), - col=torch.tensor(col, dtype=torch.long), - value=val_torch, - sparse_sizes=(num_nodes, num_nodes) - ) - - # =========== 基础实现: out = A.matmul(X @ W) =========== - def baseline_spmm(A, X, W, repeat=200, warmup=2): - # X: (N, in_ch), W: (in_ch, out_ch) - # for _ in range(warmup): - # _ = A.matmul(X @ W) - times = [] - for _ in range(repeat): - t0 = time.perf_counter() - _ = A.matmul(X @ W) - t1 = time.perf_counter() - times.append(t1 - t0) - return float(np.mean(times)), float(np.std(times)) - - # =========== Chunked 实现(feature-dim chunking) =========== - def chunked_spmm(A, X, W, chunk_size=64, repeat=200, warmup=2): - # split output channels into chunks of size chunk_size - out_ch = W.shape[1] - nchunks = (out_ch + chunk_size - 1) // chunk_size - times = [] - for _ in range(repeat): - t0 = time.perf_counter() - outs = [] - for i in range(nchunks): - s = i * chunk_size - e = min((i + 1) * chunk_size, out_ch) - w_chunk = W[:, s:e] - xw = X @ w_chunk - out_chunk = A.matmul(xw) - outs.append(out_chunk) - out = torch.cat(outs, dim=1) - t1 = time.perf_counter() - times.append(t1 - t0) - return float(np.mean(times)), float(np.std(times)) - - # =========== 主测试流程 =========== - def test_chunked_variants(X, A, in_ch, out_ch, chunk_sizes=[16,32,64,128,256,512], repeat=200): - # random weight - W = torch.randn(in_ch, out_ch, dtype=torch.float32) - - # print("\nRunning baseline ...") - t_base_mean, t_base_std = baseline_spmm(A, X, W, repeat=repeat) - # print(f"Baseline avg: {t_base_mean*1000:.3f} ms (std {t_base_std*1000:.3f} ms)") - - results = [] - for cs in chunk_sizes: - # print(f"\nRunning chunked (chunk_size={cs}) ...") - t_mean, t_std = chunked_spmm(A, X, W, chunk_size=cs, repeat=repeat) - speedup = t_base_mean / t_mean if t_mean>0 else float('nan') - # print(f"Chunk {cs} avg: {t_mean*1000:.3f} ms (std {t_std*1000:.3f} ms) | speedup: {speedup:.3f}x") - results.append((cs, t_mean, t_std, speedup)) - return (t_base_mean, t_base_std, results) - - base_mean, base_std, details = test_chunked_variants(X, adj_gp, in_channels, out_channels, - chunk_sizes=[16,32,64,128,256,512,1024], repeat=20) - print(f"network:{dataset_name}") - print(f"Baseline {base_mean*1000:.3f} ms") - for cs, t_mean, t_std, speedup in details: - print(f"chunk={cs}: {t_mean*1000:.3f} ms | speedup {speedup:.3f}x") diff --git a/easygraph/nn/tests/GCN_TESTs/test_NDP.py b/easygraph/nn/tests/GCN_TESTs/test_NDP.py deleted file mode 100644 index 1782d50e..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_NDP.py +++ /dev/null @@ -1,123 +0,0 @@ -import torch -from torch_sparse import SparseTensor -import time -import os -import random -import numpy as np -import scipy.sparse as sp -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -from torch_geometric.datasets import Coauthor, Planetoid -import easygraph as eg -transform = T.NormalizeFeatures() - -# =========== 配置 =========== -DEVICE = torch.device('cpu') -# NUM_THREADS = 32 # 根据你机器调整 -# os.environ["OMP_NUM_THREADS"] = str(NUM_THREADS) -# os.environ["MKL_NUM_THREADS"] = str(NUM_THREADS) -# torch.set_num_threads(NUM_THREADS) -random.seed(42) -np.random.seed(42) -torch.manual_seed(42) - -def spmm_dim_partition(adj_t: SparseTensor, x: torch.Tensor, weight: torch.Tensor, - dim_block: int = 64): - N, Fin = x.shape - Fout = weight.shape[1] - out = torch.zeros(N, Fout, dtype=x.dtype) - - # 分块计算 - for d0 in range(0, Fout, dim_block): - d1 = min(Fout, d0 + dim_block) - xw_block = x @ weight[:, d0:d1] - out[:, d0:d1] = adj_t.matmul(xw_block) - return out - -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - ('ogb', 'ogbn-arxiv'), - -] - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -if __name__ == "__main__": - - _N = 20 - - for backend_type, dataset_name in DATASETS: - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/tmp/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/tmp/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - edge_index = data.edge_index - num_nodes = data.num_nodes - X = data.x.float().to(DEVICE) - in_channels = X.shape[1] - if dataset_name == 'ogbn-arxiv': - out_channels = 256 - else: - out_channels = 16 - -# =========== 构建归一化稀疏矩阵 A = D^{-1/2} A D^{-1/2} =========== - row = edge_index[0].cpu().numpy() - col = edge_index[1].cpu().numpy() - # compute degree and normalization values (like GCN) - deg = np.zeros(num_nodes, dtype=np.float32) - for r in row: - deg[r] += 1 - deg_inv_sqrt = np.zeros_like(deg) - nz = deg > 0 - deg_inv_sqrt[nz] = 1.0 / np.sqrt(deg[nz]) - val = deg_inv_sqrt[row] * deg_inv_sqrt[col] - val_torch = torch.tensor(val, dtype=torch.float32) - - adj_t = SparseTensor( - row=torch.tensor(row, dtype=torch.long), - col=torch.tensor(col, dtype=torch.long), - value=val_torch, - sparse_sizes=(num_nodes, num_nodes) - ) - - W = torch.randn(in_channels, out_channels, dtype=torch.float32) - - T_B = [] - T_P = [] - for _ in range(_N): - # Baseline: 全量计算 - t1 = time.perf_counter() - y_baseline = adj_t.matmul(X @ W) - t2 = time.perf_counter() - - # 分特征维度计算 - t3 = time.perf_counter() - y_part = spmm_dim_partition(adj_t, X, W, dim_block=32) - t4 = time.perf_counter() - - T_B.append(t2-t1) - T_P.append(t4-t3) - - - print(f'dataset:{dataset_name}') - # print(f'TB:{T_B}') - # print(f'TB:{T_P}') - print(f"Baseline time: {np.mean(T_B) :.4f}s") - print(f"Dim-partitioned time: {np.mean(T_P):.4f}s") - print(f"Speedup: { np.mean(T_B) / np.mean(T_P):.2f}x") - print("Result match:", torch.allclose(y_baseline, y_part, atol=1e-5)) diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv.py deleted file mode 100644 index 93e66a09..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv.py +++ /dev/null @@ -1,372 +0,0 @@ -import os - -# ------------------- 引入库 --------------------- -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -# from nodevectors import Node2Vec -from txtReader import TxtGraphReader -# torch.set_num_threads(32) -print(torch.__config__.show()) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 200 -# HIDDEN_DIM = 16 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 2 - -DATASETS = [ - ('Coauthor', 'CS'), - ('Coauthor', 'Physics'), - ('Planetoid', 'Cora'), - ('Planetoid', 'Citeseer'), - ('Planetoid', 'PubMed'), - # ('ogb', 'ogbn-arxiv'), - # ('txt', 'Web_BerkStan'), - # ('txt', 'soc-pokec'), - # ('txt', 'Reddit'), - # # ('ogb', 'ogbn-mag'), - # ('ogb', 'ogbn-products'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import easygraph as eg -import torch - -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -# class GCN(nn.Module): -# def __init__(self, in_channels, hidden_channels, out_channels, dropout): -# super(GCN, self).__init__() -# self.gcn1 = eg.GCNConv(in_channels, hidden_channels) -# self.gcn2 = eg.GCNConv(hidden_channels, out_channels) -# self.dropout = dropout - - -# def forward(self, x, g): -# x = self.gcn1(x, g) -# x = F.relu(x) -# x = F.dropout(x, p=self.dropout, training=self.training) -# x = self.gcn2(x, g) -# return x - -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout, nparts: int=32): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - self.nparts = nparts - self._graph_partition = None - - def forward(self, x, g): - # g.build_adj_gp(nparts=self.nparts) - # x = x[g.cache['gp_perm']] - x = self.gcn1(x, g) - x = F.relu(x) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - # x = x[g.cache['gp_inv_perm']] - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -Result_Chart = {} - -def Show_Result(dataset_name, RR, All_total_train_time, All_epoch_times, All_forward_times, All_backward_times, peak_memory_mb, All_test_acc, ALL_f1): - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒 ({[round(_, 3) for _ in All_total_train_time]})") - print(f"🔥 总训练时间标准差: {statistics.stdev(All_total_train_time):.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") - Result_Chart[dataset_name] = { - "Total_Train_Time": sum(All_total_train_time)/RR, - "Std_Train_Time": statistics.stdev(All_total_train_time), - "Accuracy": sum(All_test_acc)/RR, - "Std_Accuracy": statistics.stdev(All_test_acc), - } - -def main(): - root = "./dataset/" - for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=root, name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=root, name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root=root) - elif backend_type == 'txt': - dataset = TxtGraphReader(root=root, name=dataset_name) - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - elif backend_type in 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - elif backend_type == 'txt': - # 自定义划分方式 - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - # 调整隐藏层 - if dataset_name == 'ogbn-arxiv': - HIDDEN_DIM = 256 - else: - HIDDEN_DIM = 16 - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - - # -------------------- 移动数据到设备 -------------------- - try: - data = data.to(DEVICE) - except: - pass - - # -------------------- 节点特征 和 标签 -------------------- - try: - num_node_features = dataset.num_node_features - except: - num_node_features = data.x.shape[1] - try: - num_classes = dataset.num_classes - except: - num_classes = int(data.y.max().item()) + 1 - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - x_orig = data.x.clone() - y_orig = data.y.clone() - train_mask_orig = train_mask.clone() - val_mask_orig = val_mask.clone() - test_mask_orig = test_mask.clone() - for R in tqdm(range(RR + 1)): - - if 'adj_gp' in g.cache: - del g.cache['adj_gp'] # 删除 adj_gp - g.build_adj_gp(nparts=32) # 重新分割 - - # 3️⃣ 获取新的 perm 并应用到数据和 mask 上 - perm = g.cache['gp_perm'] - # 每次循环用原始数据生成 perm 后的新数据 - data.x = x_orig[perm] - data.y = y_orig[perm] - train_mask = train_mask_orig[perm] - val_mask = val_mask_orig[perm] - test_mask = test_mask_orig[perm] - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - # -------------------- 初始化模型 -------------------- - model = GCN(num_node_features, HIDDEN_DIM, num_classes, dropout=DROPOUT).to(DEVICE) - # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) - # model = torch.compile(model, backend="inductor") - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - # for epoch in tqdm(range(1, EPOCHS+1)): - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - start_fwd = time.perf_counter() - out = model(data.x, g) - end_fwd = time.perf_counter() - - # loss 计算 - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - # if loss_val.item() < best_val_loss: - # best_val_loss = loss_val.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") - - # -------------------- 输出最终结果 -------------------- - # print(f'\nTrain Loss = {LOSS_LIST}') - # print(f'Valid Loss = {LOSS_LIST_VALID}') - # print(f'Test Loss = {LOSS_LIST_TEST}') - - Show_Result(dataset_name, RR, All_total_train_time[-RR:], All_epoch_times[-RR:], All_forward_times[-RR:], All_backward_times[-RR:], peak_memory_mb, All_test_acc[-RR:], ALL_f1[-RR:]) - # print("\n======= 统一测试结果汇总 =======") - # print(f"🔹 后端框架: {BACKEND}") - # print(f"🔹 数据集: {dataset_name}") - # print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒 ({[round(_, 3) for _ in All_total_train_time]})") - # print(f"🔥 总训练时间标准差: {statistics.stdev(All_total_train_time):.3f} 秒") - # print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - # print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - # print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - # # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - # print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - # print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - # print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - # print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") - - - -if __name__ == "__main__": - main() - import pandas as pd - df = pd.DataFrame(Result_Chart).T - df.to_csv("gcn_easygraph_results.csv") \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py deleted file mode 100644 index 48d34e9a..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_cogdl.py +++ /dev/null @@ -1,346 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import statistics - -# -------------------- 配置 -------------------- -BACKEND = 'Cogdl' -DEVICE = torch.device('cpu') -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 16 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 10 - -DATASETS = [ - ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), -# ('Planetoid', 'Cora'), -# ('Planetoid', 'Citeseer'), -# ('Planetoid', 'PubMed'), -# # ('ogb', 'ogbn-arxiv'), -# ('ogb', 'ogbn-products') -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -import torch_geometric.transforms as T -from cogdl.models.nn import GCN -import torch.nn.functional as F -from cogdl.data import Graph -from ogb.nodeproppred import PygNodePropPredDataset - -# 解决 torch.load 报错 -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -transform = T.NormalizeFeatures() - - -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - g = Graph( - x=data.x, - edge_index=data.edge_index, - y=data.y, - num_nodes=num_nodes - ) - # g.sym_norm() - - # -------------------- 数据集划分 -------------------- - if backend_type in 'Planetoid': # 使用原生 mask - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - print(f"Train nodes: {train_mask.sum().item()}") - print(f"Valid nodes: {val_mask.sum().item()}") - print(f"Test nodes: {test_mask.sum().item()}") - elif backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - - for split in ['train', 'valid', 'test']: - idx = split_idx[split] - print(f"{split} nodes: {len(idx)}") - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - data = data.to(DEVICE) - - - All_forward_times = [] - All_backward_times = [] - All_epoch_times = [] - All_total_train_time = [] - All_test_acc = [] - ALL_f1 = [] - - - for R in tqdm(range(RR)): - # -------------------- 设备转移 -------------------- - model = GCN( - in_feats=dataset.num_node_features, - hidden_size=HIDDEN_DIM, - out_feats=dataset.num_classes, - num_layers=2, - dropout=DROPOUT, - activation="relu", - residual=False, - norm=None - ).to(DEVICE) - - # -------------------- 训练配置 -------------------- - optimizer = torch.optim.Adam([ - {'params': model.layers[0].parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.layers[1].parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - process = psutil.Process(os.getpid()) - memory_usage_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = memory_usage_mb - - forward_times = [] - backward_times = [] - epoch_times = [] - - LOSS_LIST = [] - LOSS_LIST_TEST = [] - LOSS_LIST_VALID = [] - - best_val_loss = float('inf') - early_stop_counter = 0 - - train_start = time.time() - for epoch in range(1, EPOCHS + 1): - model.train() - optimizer.zero_grad() - epoch_start = time.time() - - # 前向传播 - start_fwd = time.time() - out = model(g) - end_fwd = time.time() - - # 计算loss - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - loss_valid = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - - # 反向传播 - start_bwd = time.time() - loss.backward() - optimizer.step() - end_bwd = time.time() - - epoch_end = time.time() - - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(epoch_end - epoch_start) - - LOSS_LIST.append(round(loss.item(),3)) - LOSS_LIST_TEST.append(round(loss_test.item(),3)) - LOSS_LIST_VALID.append(round(loss_valid.item(),3)) - - # # early stopping - # if loss_valid.item() < best_val_loss: - # best_val_loss = loss_valid.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # # print(f"Early stopping at epoch {epoch}. Validation loss has not decreased for {EARLY_STOP_WINDOW} epochs.") - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.time() - total_train_time = train_end - train_start - - # -------------------- 测试函数 -------------------- - @torch.no_grad() - def evaluate(model, data, mask_key='test_mask'): - model.eval() - out = model(g) - mask = getattr(data, mask_key) - pred = out.argmax(dim=1) - correct = (pred[mask] == data.y[mask].squeeze()).sum() - acc = int(correct) / int(mask.sum()) - macro_f1 = f1_score(data.y[mask].squeeze(), pred[mask], average='macro') - return acc, macro_f1 - - test_acc, f1 = evaluate(model, data) - - # -------------------- 结果输出 -------------------- - avg_fwd = sum(forward_times) / len(forward_times) - avg_bwd = sum(backward_times) / len(backward_times) - avg_epoch = sum(epoch_times) / len(epoch_times) - - All_forward_times.append(avg_fwd) - All_backward_times.append(avg_bwd) - All_epoch_times.append(avg_epoch) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(f1) - - # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') - # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') - # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") - -# dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='data/OGB') - -# import torch -# import torch.nn.functional as F -# from ogb.nodeproppred import PygNodePropPredDataset -# from torch.serialization import safe_globals -# from cogdl.data import Graph -# from cogdl.models.nn import GCN -# from cogdl.trainer import Trainer -# import torch_geometric -# # ------------------------------- -# # 1. 安全加载 PyG 数据集 (Python 3.10+ / PyTorch 2.6+) -# # ------------------------------- -# with safe_globals([torch_geometric.data.data.DataEdgeAttr]): -# dataset = PygNodePropPredDataset(name="ogbn-arxiv", root="data/OGB") - -# data = dataset[0] -# split_idx = dataset.get_idx_split() - -# x = data.x -# y = data.y.squeeze() -# num_nodes = data.num_nodes - -# # ------------------------------- -# # 2. 构建 EasyGraph 图对象 -# # ------------------------------- -# import easygraph as eg -# g = eg.Graph() -# g.add_nodes_from(range(num_nodes)) -# edge_index = data.edge_index -# edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) -# g.add_edges(edge_list) - -# # ------------------------------- -# # 3. 转换划分为 mask -# # ------------------------------- -# train_mask = torch.zeros(num_nodes, dtype=torch.bool) -# train_mask[split_idx['train']] = True -# val_mask = torch.zeros(num_nodes, dtype=torch.bool) -# val_mask[split_idx['valid']] = True -# test_mask = torch.zeros(num_nodes, dtype=torch.bool) -# test_mask[split_idx['test']] = True - -# # ------------------------------- -# # 4. 初始化 CogDL GCN -# # ------------------------------- -# device = torch.device("cuda" if torch.cuda.is_available() else "cpu") -# model = GCN( -# in_features=x.size(1), -# hidden_size=128, -# out_features=dataset.num_classes, -# num_layers=2, -# dropout=0.5 -# ).to(device) - -# x = x.to(device) -# y = y.to(device) - -# train_idx = torch.where(train_mask)[0].to(device) -# val_idx = torch.where(val_mask)[0].to(device) -# test_idx = torch.where(test_mask)[0].to(device) - -# # ------------------------------- -# # 5. 初始化 Trainer -# # ------------------------------- -# trainer = Trainer( -# model=model, -# task="node_classification", -# epochs=200, -# lr=0.01, -# weight_decay=0 -# ) - -# # ------------------------------- -# # 6. 训练 -# # ------------------------------- -# trainer.train(g, y=y, train_idx=train_idx, val_idx=val_idx, test_idx=test_idx) - -# # ------------------------------- -# # 7. 评估测试集准确率 -# # ------------------------------- -# results = trainer.evaluate(g, y, {'train': train_idx, 'valid': val_idx, 'test': test_idx}) -# print("Final Accuracy:", results) \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py deleted file mode 100644 index c658b740..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_copy.py +++ /dev/null @@ -1,275 +0,0 @@ -import os - -# ------------------- 引入库 --------------------- -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -torch.set_num_threads(32) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 200 -# HIDDEN_DIM = 16 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 11 -Nparts = 32 -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products') -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import easygraph as eg -import torch -import sys -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -# class GCN(nn.Module): -# def __init__(self, in_channels, hidden_channels, out_channels, dropout): -# super(GCN, self).__init__() -# self.gcn1 = eg.GCNConv(in_channels, hidden_channels) -# self.gcn2 = eg.GCNConv(hidden_channels, out_channels) -# self.dropout = dropout - - -# def forward(self, x, g): -# x, t1 = self.gcn1(x, g) -# x = F.relu(x) -# x = F.dropout(x, p=self.dropout, training=self.training) -# x, t2 = self.gcn2(x, g) -# return x , [t1, t2] - - -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout, nparts: int=32): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - self.nparts = nparts - self._graph_partition = None - - def forward(self, x, g): - g.build_adj_gp(nparts=self.nparts) - x = x[g.cache['gp_perm']] - x = self.gcn1(x, g) - x = F.relu(x) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - x = x[g.cache['gp_inv_perm']] - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- - -def main(): - for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/autodl-tmp/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/root/autodl-tmp/data/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/autodl-tmp/data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - # -------------------- 数据类型修正 -------------------- - - data.x = data.x.float() - data.y = data.y.long() - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - elif backend_type in 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - # 调整隐藏层 - if dataset_name == 'ogbn-arxiv': - HIDDEN_DIM = 256 - else: - HIDDEN_DIM = 16 - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - print("data load finished!") - # g.build_adj_gp(nparts=32) - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - # -------------------- 初始化模型 -------------------- - model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) - # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) - # model = torch.compile(model, backend="inductor") - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - for epoch in tqdm(range(1, EPOCHS+1)): - model.train() - optimizer.zero_grad() - start_fwd = time.perf_counter() - out = model(data.x, g) - end_fwd = time.perf_counter() - - # loss 计算 - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - # if loss_val.item() < best_val_loss: - # best_val_loss = loss_val.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time[1:])/(RR-1):.3f} 秒 ({[round(_, 3) for _ in All_total_train_time]})") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - # print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py deleted file mode 100644 index 85b2ae01..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dgl.py +++ /dev/null @@ -1,233 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm - -# -------------------- 配置 -------------------- -BACKEND = 'DGL' -# DATASET = 'ogbn-products' -DEVICE = torch.device('cpu') -SEED = 42 -EPOCHS = 500 -HIDDEN_DIM = 8 - - -DATASETS = [ - ('Coauthor', 'CS'), - ('Coauthor', 'Physics'), - ('Planetoid', 'Cora'), - ('Planetoid', 'PubMed'), - # ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products') -] - - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- - -from torch_geometric.datasets import Coauthor, Planetoid -import torch_geometric.transforms as T -import dgl -import dgl.nn.pytorch as dglnn -from ogb.nodeproppred import PygNodePropPredDataset - -# 解决 torch.load 报错 -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -transform = T.NormalizeFeatures() -# 数据集选择(自行切换) -# dataset = Coauthor(root='data/Coauthor', name='CS') -# dataset = Coauthor(root='data/Coauthor', name='Physics') -# dataset = Planetoid(root='/tmp/Cora', name='Cora') -# dataset = Planetoid(root='/tmp/PubMed', name='PubMed', transform=transform) -# dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='data/OGB') -# dataset = PygNodePropPredDataset(name='ogbn-products', root='data/OGB') -# data = dataset[0] - -# 构建 DGL 图(用 PyG 的 edge_index 转换) - - - -# 模型定义 -class GCN(torch.nn.Module): - def __init__(self, in_dim, hidden_dim, out_dim): - super().__init__() - self.conv1 = dglnn.GraphConv(in_dim, hidden_dim) - self.conv2 = dglnn.GraphConv(hidden_dim, out_dim) - - def forward(self, graph, features): - x = F.relu(self.conv1(graph, features)) - x = F.dropout(x, p=0.6, training=self.training) - x = self.conv2(graph, x) - return x - -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - - # 原始边 - src, dst = data.edge_index - g = dgl.graph((src, dst), num_nodes=data.num_nodes) - g = dgl.add_self_loop(g) - g = g.to(DEVICE) - g.ndata['feat'] = data.x.to(DEVICE) - - # -------------------- 数据预处理(统一) -------------------- - num_nodes = data.num_nodes - indices = torch.randperm(num_nodes) - train_idx = indices[:int(0.6 * num_nodes)] - val_idx = indices[int(0.6 * num_nodes):int(0.8 * num_nodes)] - test_idx = indices[int(0.8 * num_nodes):] - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[train_idx] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[val_idx] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[test_idx] = True - - - g.ndata['label'] = data.y.to(DEVICE) - g.ndata['train_mask'] = train_mask.to(DEVICE) - g.ndata['val_mask'] = val_mask.to(DEVICE) - g.ndata['test_mask'] = test_mask.to(DEVICE) - - - All_forward_times = [] - All_backward_times = [] - All_epoch_times = [] - All_total_train_time = [] - All_test_acc = [] - ALL_f1 = [] - RR = 50 - - for R in tqdm(range(RR)): - # -------------------- 设备转移 -------------------- - model = GCN(g.ndata['feat'].shape[1], HIDDEN_DIM, dataset.num_classes).to(DEVICE) - - # -------------------- 训练配置 -------------------- - optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=5e-4) - criterion = torch.nn.CrossEntropyLoss() - - process = psutil.Process(os.getpid()) - memory_usage_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = memory_usage_mb - - forward_times = [] - backward_times = [] - epoch_times = [] - - # -------------------- 训练循环 -------------------- - LOSS_LIST = [] - LOSS_LIST_TEST = [] - LOSS_LIST_VALID = [] - - train_start = time.time() - for epoch in range(1, EPOCHS + 1): - model.train() - optimizer.zero_grad() - epoch_start = time.time() - - # 前向传播 - start_fwd = time.time() - out = model(g, g.ndata['feat']) - end_fwd = time.time() - - # 计算loss - loss = criterion(out[g.ndata['train_mask']], g.ndata['label'][g.ndata['train_mask']].squeeze()) - loss_test = criterion(out[g.ndata['test_mask']], g.ndata['label'][g.ndata['test_mask']].squeeze()) - loss_valid = criterion(out[g.ndata['val_mask']], g.ndata['label'][g.ndata['val_mask']].squeeze()) - - # 反向传播 - start_bwd = time.time() - loss.backward() - optimizer.step() - end_bwd = time.time() - - epoch_end = time.time() - - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(epoch_end - epoch_start) - - LOSS_LIST.append(round(loss.item(),3)) - LOSS_LIST_TEST.append(round(loss_test.item(),3)) - LOSS_LIST_VALID.append(round(loss_valid.item(),3)) - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.time() - total_train_time = train_end - train_start - - # -------------------- 测试函数 -------------------- - @torch.no_grad() - def evaluate(model, data, mask_key='test_mask'): - model.eval() - out = model(g, g.ndata['feat']) - mask = g.ndata[mask_key] - pred = out.argmax(dim=1) - correct = (pred[mask] == g.ndata['label'][mask].squeeze()).sum() - acc = int(correct) / int(mask.sum()) - macro_f1 = f1_score(g.ndata['label'][mask].squeeze(), pred[mask], average='macro') - return acc, macro_f1 - - test_acc, f1 = evaluate(model, data) - - # -------------------- 结果输出 -------------------- - avg_fwd = sum(forward_times) / len(forward_times) - avg_bwd = sum(backward_times) / len(backward_times) - avg_epoch = sum(epoch_times) / len(epoch_times) - - All_forward_times.append(avg_fwd) - All_backward_times.append(avg_bwd) - All_epoch_times.append(avg_epoch) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(f1) - - # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') - # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') - # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') - - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py deleted file mode 100644 index f1f9776e..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_dim.py +++ /dev/null @@ -1,277 +0,0 @@ -import os -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics - -# ------------------- 引入必要的库 --------------------- -import gensim.models.word2vec # 必须显式引入 -from nodevectors import Node2Vec -from scipy.sparse import csr_matrix -import easygraph as eg -from torch_geometric.datasets import Coauthor, Planetoid, Reddit -from torch_geometric.utils import add_self_loops -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -from txtReader import TxtGraphReader - -# ============================================================================== -# 🩹【Gensim 4.0+ 兼容性补丁】(防止 size 参数报错) -# ============================================================================== -def patch_gensim_4_compatibility(): - original_init = gensim.models.Word2Vec.__init__ - def patched_init(self, *args, **kwargs): - if 'size' in kwargs: kwargs['vector_size'] = kwargs.pop('size') - if 'iter' in kwargs: kwargs['epochs'] = kwargs.pop('iter') - original_init(self, *args, **kwargs) - gensim.models.Word2Vec.__init__ = patched_init - -patch_gensim_4_compatibility() -# ============================================================================== - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') -SEED = 42 -EPOCHS = 200 -DROPOUT = 0.5 -RR = 1 - -DATASETS = [ - ('Coauthor', 'CS'), - ('Coauthor', 'Physics'), - ('Planetoid', 'Cora'), - ('Planetoid', 'Citeseer'), - ('Planetoid', 'PubMed'), -] - -# 【设置】输入特征维度 -NODE2VEC_DIM = 128 -FORCE_NODE2VEC = True - -transform = T.NormalizeFeatures() - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 定义 GCN -------------------- -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout, nparts: int=32): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - - def forward(self, x, g): - x = self.gcn1(x, g) - x = F.relu(x) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 结果展示 -------------------- -Result_Chart = {} -def Show_Result(dataset_name, RR, All_total_train_time, All_epoch_times, All_forward_times, All_backward_times, peak_memory_mb, All_test_acc, ALL_f1): - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 数据集: {dataset_name}") - print(f"🔹 输入维度: {NODE2VEC_DIM}") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 F1-score: {sum(ALL_f1)/RR:.4f}") - - Result_Chart[dataset_name] = { - "Input_Dim": NODE2VEC_DIM, - "Total_Train_Time": sum(All_total_train_time)/RR, - "Accuracy": sum(All_test_acc)/RR, - "F1-Score": sum(ALL_f1)/RR - } - -# -------------------- 主函数 -------------------- -def main(): - root = "./dataset/" - - for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # 1. 加载 - if backend_type == 'Coauthor': - dataset = Coauthor(root=root, name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=root, name=dataset_name, transform=transform) - elif backend_type == 'txt': - dataset = TxtGraphReader(root=root, name=dataset_name) - else: - continue - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 【核心修改】Node2Vec -------------------- - if FORCE_NODE2VEC: - print(f"🔄 [Node2Vec] 正在生成 {NODE2VEC_DIM} 维特征...") - t0 = time.time() - - # A. CSR 矩阵 - row = data.edge_index[0].cpu().numpy() - col = data.edge_index[1].cpu().numpy() - data_ones = np.ones(len(row)) - adj_matrix = csr_matrix((data_ones, (row, col)), shape=(num_nodes, num_nodes)) - - # B. 训练 - n2v_model = Node2Vec(n_components=NODE2VEC_DIM, walklen=20, epochs=1, threads=4) - n2v_model.fit(adj_matrix) - - # C. 提取特征 (修复 KeyError 问题) - embeddings = [] - for i in range(num_nodes): - try: - # 优先尝试 String Key (因为 nodevectors 内部常转为 str) - vec = n2v_model.predict(str(i)) - except KeyError: - try: - # 如果失败,尝试 Int Key - vec = n2v_model.predict(i) - except KeyError: - # 如果还失败 (孤立节点),用 0 填充 - vec = np.zeros(NODE2VEC_DIM) - embeddings.append(vec) - - data.x = torch.tensor(np.array(embeddings), dtype=torch.float) - print(f"✅ 特征生成完毕! 耗时: {time.time()-t0:.2f}s | 维度: {data.x.shape}") - - # -------------------- 后续处理 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - if backend_type == 'Coauthor' or backend_type == 'txt': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - else: - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - if NODE2VEC_DIM >= 128: - HIDDEN_DIM = 64 - else: - HIDDEN_DIM = 16 - - # 构建 EasyGraph - g = eg.Graph() - edge_list = list(zip(data.edge_index[0].tolist(), data.edge_index[1].tolist())) - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - - try: - data = data.to(DEVICE) - except: - pass - - num_node_features = data.x.shape[1] - num_classes = int(data.y.max().item()) + 1 - - # -------------------- 训练循环 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - x_orig = data.x.clone() - y_orig = data.y.clone() - train_mask_orig = train_mask.clone() - val_mask_orig = val_mask.clone() - test_mask_orig = test_mask.clone() - - for R in tqdm(range(RR + 1)): - if 'adj_gp' in g.cache: del g.cache['adj_gp'] - g.build_adj_gp(nparts=32) - perm = g.cache['gp_perm'] - - data.x = x_orig[perm] - data.y = y_orig[perm] - data.train_mask = train_mask_orig[perm] - data.val_mask = val_mask_orig[perm] - data.test_mask = test_mask_orig[perm] - - model = GCN(num_node_features, HIDDEN_DIM, num_classes, dropout=DROPOUT).to(DEVICE) - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - train_start = time.perf_counter() - process = psutil.Process(os.getpid()) - peak_memory_mb = 0 - - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - - t_s = time.perf_counter() - out = model(data.x, g) - t_f = time.perf_counter() - - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - - t_b_s = time.perf_counter() - loss.backward() - optimizer.step() - t_b_e = time.perf_counter() - - if R > 0: - All_forward_times.append(t_f - t_s) - All_backward_times.append(t_b_e - t_b_s) - All_epoch_times.append(t_b_e - t_s) - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - total_train_time = time.perf_counter() - train_start - - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].cpu(), pred[data.test_mask].cpu(), average='macro') - - if R > 0: - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - Show_Result(dataset_name, RR, All_total_train_time, All_epoch_times, All_forward_times, All_backward_times, peak_memory_mb, All_test_acc, ALL_f1) - -if __name__ == "__main__": - main() - import pandas as pd - df = pd.DataFrame(Result_Chart).T - df.to_csv("gcn_node2vec_results.csv") - print("\n所有结果已保存至 gcn_node2vec_results.csv") \ No newline at end of file diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py deleted file mode 100644 index 94544779..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egs_dc_multi.py +++ /dev/null @@ -1,215 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -from easygraph.utils.Effective_R import EffectiveResistance -from easygraph.utils.GraphSampler import degree_community_sampling -torch.set_num_threads(30) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 512 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 50 - -DATASETS = [ - ('Yelp', 'Yelp'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Yelp, Reddit, Flickr -import torch_geometric.transforms as T -import easygraph as eg - -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - - def forward(self, x, g): - x = F.relu(self.gcn1(x, g)) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - return x - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Yelp': - dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') - elif backend_type == 'Reddit': - dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') - elif backend_type == 'Flickr': - dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.float() # 多标签任务 - - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - - print('开始采样') - g_s, x_s, sampled_nodes_tensor = degree_community_sampling( - data.edge_index, data.x, data.y, num_nodes, sample_ratio=0.4, - min_nodes=150, alpha=0.65, random_ratio=0.08, bridge_ratio=0.1, k=20, nodes_per_class=17) - - print('采样完成') - print(f'节点数量: {len(g_s.nodes)}, 边数:{len(g_s.edges)}') - - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - x_s = x_s.to(DEVICE) - - # -------------------- 构建训练 mask 子集 -------------------- - train_mask_sub = data.train_mask[sampled_nodes_tensor] - val_mask_sub = data.val_mask[sampled_nodes_tensor] - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - - # -------------------- 初始化模型 -------------------- - model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = nn.BCEWithLogitsLoss() # 多标签损失 - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - train_start = time.perf_counter() - - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(x_s, g_s) - end_fwd = time.perf_counter() - - # -------------------- 子图 loss -------------------- - loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub]) - - # -------------------- 全图验证 -------------------- - model.eval() - with torch.no_grad(): - out_full = model(data.x, g) - loss_val = criterion(out_full[data.val_mask], data.y[data.val_mask]) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - - # if loss_val.item() < best_val_loss: - # best_val_loss = loss_val.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - prob = torch.sigmoid(out) # logits -> 概率 - pred = (prob > 0.5).int() - - test_y = data.y[data.test_mask].int() - test_pred = pred[data.test_mask] - - # Accuracy (每个标签独立平均) - test_acc = (test_pred == test_y).float().mean().item() - macro_f1 = f1_score(test_y.cpu().numpy(), test_pred.cpu().numpy(), average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # -------------------- 输出最终结果 -------------------- - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py deleted file mode 100644 index f6fe2383..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling.py +++ /dev/null @@ -1,272 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -from easygraph.utils.Effective_R import EffectiveResistance -from easygraph.utils.GraphSampler import FERGraphSampler -# from easygraph.utils.GraphSampler import FullGraphHybridSampler -torch.set_num_threads(30) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 256 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 50 - -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - # ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products') - -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -def feature_similarity(x, edge_index): - src, dst = edge_index - sim = F.cosine_similarity(x[src], x[dst]) - sim = (sim + 1.0) / 2.0 # 归一化到 [0,1] - return sim - -def FER_score(r_ij, s_ij, alpha=1.0, beta=1.0): - score = (r_ij ** alpha) * (s_ij ** beta) - return score / (score.max() + 1e-12) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import easygraph as eg - -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - - def forward(self, x, g): - x = F.relu(self.gcn1(x, g)) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - elif backend_type in 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - - er = EffectiveResistance(edge_index, num_nodes) - resistance_dict = er.compute_resistance_dict() - r_ij = torch.tensor([1 / (resistance_dict[(u,v)] + 1e-12) for u, v in edge_index.T.tolist()]) - r_ij = r_ij / r_ij.max() - - s_ij = feature_similarity(data.x, edge_index) - FER = FER_score(r_ij, s_ij, alpha=1.0, beta=1.0) - # sample_g = FERGraphSampler(edge_index, num_nodes, FER) - # sg = FullGraphHybridSampler(edge_index, num_nodes, 2) - # sample_g = sg(data.x) - # print(len(sample_g.edges)) - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - - sample_g = FERGraphSampler(edge_index, num_nodes, FER) - # -------------------- 初始化模型 -------------------- - model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) - # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(data.x, sample_g) - end_fwd = time.perf_counter() - - # loss 计算 - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - if loss_val.item() < best_val_loss: - best_val_loss = loss_val.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") - - # -------------------- 输出最终结果 -------------------- - # print(f'\nTrain Loss = {LOSS_LIST}') - # print(f'Valid Loss = {LOSS_LIST_VALID}') - # print(f'Test Loss = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py deleted file mode 100644 index 0e9d3517..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_egsampling_dc.py +++ /dev/null @@ -1,314 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -from easygraph.utils.Effective_R import EffectiveResistance -# from easygraph.utils.GraphSampler import FERGraphSampler -from easygraph.utils.GraphSampler import degree_community_sampling -torch.set_num_threads(30) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 256 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 1 - -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products'), - # ('ogb2', 'ogbn-mag'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -# def feature_similarity(x, edge_index): -# src, dst = edge_index -# sim = F.cosine_similarity(x[src], x[dst]) -# sim = (sim + 1.0) / 2.0 # 归一化到 [0,1] -# return sim - -# def FER_score(r_ij, s_ij, alpha=1.0, beta=1.0): -# score = (r_ij ** alpha) * (s_ij ** beta) -# return score / (score.max() + 1e-12) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr, Amazon -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import easygraph as eg - -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - - def forward(self, x, g): - x = F.relu(self.gcn1(x, g)) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='data/OGB') - elif backend_type =='ogb2': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/autodl-tmp/zss/data/OGB') - elif backend_type == 'Reddit': - dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') - elif backend_type == 'Flickr': - dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') - elif backend_type == 'Yelp': - dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - - elif backend_type == 'ogb2': - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - - elif backend_type == 'Planetoid': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - elif backend_type in ['Reddit', 'Flickr', 'Coauthor']: - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - - elif backend_type == 'Yelp': - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - - print('开始采样') - g_s, x_s, sampled_nodes_tensor = degree_community_sampling( - data.edge_index, data.x, data.y, num_nodes, sample_ratio=0.4, - min_nodes=150, alpha=0.65, random_ratio=0.08, bridge_ratio=0.1, k=20, nodes_per_class=17) - - print('采样完成') - print(f'节点数量: {len(g_s.nodes)}, 边数:{len(g_s.edges)}') - - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - - - # er = EffectiveResistance(edge_index, num_nodes) - # resistance_dict = er.compute_resistance_dict() - # r_ij = torch.tensor([1 / (resistance_dict[(u,v)] + 1e-12) for u, v in edge_index.T.tolist()]) - # r_ij = r_ij / r_ij.max() - - # s_ij = feature_similarity(data.x, edge_index) - # FER = FER_score(r_ij, s_ij, alpha=1.0, beta=1.0) - # sample_g = FERGraphSampler(edge_index, num_nodes, FER) - # sg = FullGraphHybridSampler(edge_index, num_nodes, 2) - # sample_g = sg(data.x) - # print(len(sample_g.edges)) - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - x_s = x_s.to(DEVICE) - - # -------------------- 构建训练 mask 子集 -------------------- - train_mask_sub = data.train_mask[sampled_nodes_tensor] - val_mask_sub = data.val_mask[sampled_nodes_tensor] - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - - # sample_g = FERGraphSampler(edge_index, num_nodes, FER) - # -------------------- 初始化模型 -------------------- - model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) - # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - train_start = time.perf_counter() - - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(x_s, g_s) - end_fwd = time.perf_counter() - - # -------------------- 子图 loss -------------------- - loss = criterion(out[train_mask_sub], data.y[sampled_nodes_tensor][train_mask_sub].squeeze()) - # loss_val = criterion(out[val_mask_sub], data.y[sampled_nodes_tensor][val_mask_sub].squeeze()) - # loss_test = criterion(out[test_mask_sub], data.y[sampled_nodes_tensor][test_mask_sub].squeeze()) - - # -------------------- 全图验证 -------------------- - model.eval() - with torch.no_grad(): - out_full = model(data.x, g) # 全图前向 - loss_val = criterion(out_full[data.val_mask], data.y[data.val_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - # LOSS_LIST.append(loss.item()) - # LOSS_LIST_VALID.append(loss_val.item()) - # LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - if loss_val.item() < best_val_loss: - best_val_loss = loss_val.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - pred = out.argmax(dim=1) - test_acc = (pred[data.test_mask] == data.y[data.test_mask].squeeze()).sum().item() / data.test_mask.sum().item() - macro_f1 = f1_score(data.y[data.test_mask].squeeze(), pred[data.test_mask], average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") - - # -------------------- 输出最终结果 -------------------- - # print(f'\nTrain Loss = {LOSS_LIST}') - # print(f'Valid Loss = {LOSS_LIST_VALID}') - # print(f'Test Loss = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py deleted file mode 100644 index 91bb2f71..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_multi.py +++ /dev/null @@ -1,218 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import torch.nn as nn -import statistics -torch.set_num_threads(30) - -# -------------------- 配置 -------------------- -BACKEND = 'EasyGraph' -DEVICE = torch.device('cpu') # 如有 GPU 改成 'cuda' -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 256 -DROPOUT = 0.5 -EARLY_STOP_WINDOW = 10 -RR = 1 - -DATASETS = [ - ('Yelp', 'Yelp'), -] - - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # torch.use_deterministic_algorithms(True) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import easygraph as eg - -transform = T.NormalizeFeatures() - -# -------------------- 定义 GCN -------------------- -class GCN(nn.Module): - def __init__(self, in_channels, hidden_channels, out_channels, dropout): - super(GCN, self).__init__() - self.gcn1 = eg.GCNConv(in_channels, hidden_channels) - self.gcn2 = eg.GCNConv(hidden_channels, out_channels) - self.dropout = dropout - - def forward(self, x, g): - x = F.relu(self.gcn1(x, g)) - x = F.dropout(x, p=self.dropout, training=self.training) - x = self.gcn2(x, g) - return x - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Yelp': - dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') - elif backend_type == 'Reddit': - dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') - elif backend_type == 'Flickr': - dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.float() # 多标签任务 - - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 构建 Easy-Graph 图对象 -------------------- - g = eg.Graph() - edge_index = data.edge_index - edge_list = list(zip(edge_index[0].tolist(), edge_index[1].tolist())) - g.add_nodes_from(range(num_nodes)) - g.add_edges(edge_list) - # print(len(g.edges)) - - # -------------------- 移动数据到设备 -------------------- - data = data.to(DEVICE) - - # -------------------- 结果存储 -------------------- - All_forward_times, All_backward_times, All_epoch_times = [], [], [] - All_total_train_time, All_test_acc, ALL_f1 = [], [], [] - - for R in tqdm(range(RR)): - # -------------------- 初始化模型 -------------------- - model = GCN(dataset.num_node_features, HIDDEN_DIM, dataset.num_classes, dropout=DROPOUT).to(DEVICE) - # optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) - optimizer = torch.optim.Adam([ - {'params': model.gcn1.parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.gcn2.parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - # 每次重复实验初始化 early stopping - best_val_loss = float('inf') - early_stop_counter = 0 - - LOSS_LIST, LOSS_LIST_VALID, LOSS_LIST_TEST = [], [], [] - forward_times, backward_times, epoch_times = [], [], [] - - process = psutil.Process(os.getpid()) - peak_memory_mb = process.memory_info().rss / 1024 / 1024 - - train_start = time.perf_counter() - - for epoch in range(1, EPOCHS+1): - model.train() - optimizer.zero_grad() - - start_fwd = time.perf_counter() - out = model(data.x, g) - end_fwd = time.perf_counter() - - # loss 计算 - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_val = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.perf_counter() - loss.backward() - optimizer.step() - end_bwd = time.perf_counter() - - # 记录 - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(end_fwd - start_fwd + end_bwd - start_bwd) - LOSS_LIST.append(loss.item()) - LOSS_LIST_VALID.append(loss_val.item()) - LOSS_LIST_TEST.append(loss_test.item()) - - # early stopping - if loss_val.item() < best_val_loss: - best_val_loss = loss_val.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.perf_counter() - total_train_time = train_end - train_start - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, g) - prob = torch.sigmoid(out) # logits -> 概率 - pred = (prob > 0.5).int() - - test_y = data.y[data.test_mask].int() - test_pred = pred[data.test_mask] - - # Accuracy (每个标签独立平均) - test_acc = (test_pred == test_y).float().mean().item() - macro_f1 = f1_score(test_y.cpu().numpy(), test_pred.cpu().numpy(), average='macro') - - # -------------------- 保存统计结果 -------------------- - All_forward_times.append(sum(forward_times)/len(forward_times)) - All_backward_times.append(sum(backward_times)/len(backward_times)) - All_epoch_times.append(sum(epoch_times)/len(epoch_times)) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # print(f"Run {R+1}/{RR} finished | Test Accuracy: {test_acc:.4f} | Macro-F1: {macro_f1:.4f}") - - # -------------------- 输出最终结果 -------------------- - # print(f'\nTrain Loss = {LOSS_LIST}') - # print(f'Valid Loss = {LOSS_LIST_VALID}') - # print(f'Test Loss = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集平均准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集平均F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py deleted file mode 100644 index 5518dc0d..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg.py +++ /dev/null @@ -1,256 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import statistics -from torch_sparse import SparseTensor - -# -------------------- 配置 -------------------- -BACKEND = 'PyG' -DEVICE = torch.device('cpu') -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 256 -DROPOUT = 0.5 -LR = 0.01 -WEIGHT_DECAY = 5e-4 -EARLY_STOP_WINDOW = 10 -RR = 5 - -DATASETS = [ - # ('Coauthor', 'CS'), - # ('Coauthor', 'Physics'), - # ('Planetoid', 'Cora'), - # ('Planetoid', 'Citeseer'), - # ('Planetoid', 'PubMed'), - ('ogb', 'ogbn-arxiv'), - # ('ogb', 'ogbn-products'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from torch_geometric.nn.models import GCN # PyG 封装好的 GCN -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T - -transform = T.NormalizeFeatures() - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - elif backend_type == 'ogb': - dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - data.adj_t = SparseTensor.from_edge_index(data.edge_index, sparse_sizes=(data.num_nodes, data.num_nodes)) - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type in 'Planetoid': # 使用原生 mask - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - print(f"Train nodes: {train_mask.sum().item()}") - print(f"Valid nodes: {val_mask.sum().item()}") - print(f"Test nodes: {test_mask.sum().item()}") - elif backend_type == 'ogb': # OGB 用 get_idx_split() - split_idx = dataset.get_idx_split() - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - train_mask[split_idx['train']] = True - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask[split_idx['valid']] = True - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask[split_idx['test']] = True - - for split in ['train', 'valid', 'test']: - idx = split_idx[split] - print(f"{split} nodes: {len(idx)}") - elif backend_type == 'Coauthor': - torch.manual_seed(42) - indices = torch.randperm(num_nodes) - - train_end = int(0.6 * num_nodes) - val_end = int(0.8 * num_nodes) - - train_mask = torch.zeros(num_nodes, dtype=torch.bool) - val_mask = torch.zeros(num_nodes, dtype=torch.bool) - test_mask = torch.zeros(num_nodes, dtype=torch.bool) - - train_mask[indices[:train_end]] = True - val_mask[indices[train_end:val_end]] = True - test_mask[indices[val_end:]] = True - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - data = data.to(DEVICE) - - # -------------------- 结果存储 -------------------- - All_forward_times = [] - All_backward_times = [] - All_epoch_times = [] - All_total_train_time = [] - All_test_acc = [] - ALL_f1 = [] - - # -------------------- 重复实验 -------------------- - for R in tqdm(range(RR)): - # -------------------- 设备转移 -------------------- - model = GCN( - in_channels=dataset.num_node_features, - hidden_channels=HIDDEN_DIM, - out_channels=dataset.num_classes, - num_layers=2, - dropout=DROPOUT - ).to(DEVICE) - - # optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) - optimizer = torch.optim.Adam([ - {'params': model.convs[0].parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.convs[1].parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = torch.nn.CrossEntropyLoss() - - process = psutil.Process(os.getpid()) - memory_usage_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = memory_usage_mb - - forward_times = [] - backward_times = [] - epoch_times = [] - - LOSS_LIST = [] - LOSS_LIST_TEST = [] - LOSS_LIST_VALID = [] - - best_val_loss = float('inf') - early_stop_counter = 0 - - train_start = time.time() - - for epoch in tqdm(range(1, EPOCHS + 1)): - model.train() - optimizer.zero_grad() - epoch_start = time.time() - - # 前向传播 - start_fwd = time.time() - # out = model(data.x, data.edge_index) - out = model(data.x, data.adj_t) - end_fwd = time.time() - - # 计算 loss - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_valid = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.time() - loss.backward() - optimizer.step() - end_bwd = time.time() - - epoch_end = time.time() - - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(epoch_end - epoch_start) - - LOSS_LIST.append(round(loss.item(), 3)) - LOSS_LIST_VALID.append(round(loss_valid.item(), 3)) - LOSS_LIST_TEST.append(round(loss_test.item(), 3)) - - # early stopping - # if loss_valid.item() < best_val_loss: - # best_val_loss = loss_valid.item() - # early_stop_counter = 0 - # else: - # early_stop_counter += 1 - - # if early_stop_counter >= EARLY_STOP_WINDOW: - # # print(f"Early stopping at epoch {epoch}. Validation loss has not decreased for {EARLY_STOP_WINDOW} epochs.") - # break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.time() - total_train_time = train_end - train_start - - # -------------------- 测试函数 -------------------- - @torch.no_grad() - def evaluate(model, data, mask_key='test_mask'): - model.eval() - out = model(data.x, data.edge_index) - mask = getattr(data, mask_key) - pred = out.argmax(dim=1) - correct = (pred[mask] == data.y[mask].squeeze()).sum() - acc = int(correct) / int(mask.sum()) - macro_f1 = f1_score(data.y[mask].squeeze(), pred[mask], average='macro') - return acc, macro_f1 - - test_acc, f1 = evaluate(model, data) - - avg_fwd = sum(forward_times) / len(forward_times) - avg_bwd = sum(backward_times) / len(backward_times) - avg_epoch = sum(epoch_times) / len(epoch_times) - - All_forward_times.append(avg_fwd) - All_backward_times.append(avg_bwd) - All_epoch_times.append(avg_epoch) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(f1) - - # -------------------- 输出结果 -------------------- - # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') - # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') - # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py b/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py deleted file mode 100644 index e5308caa..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_gcnconv_pyg_multi.py +++ /dev/null @@ -1,216 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -import psutil -from sklearn.metrics import f1_score -from tqdm import tqdm -import statistics -import torch.nn as nn - -# -------------------- 配置 -------------------- -BACKEND = 'PyG' -DEVICE = torch.device('cpu') -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 16 -DROPOUT = 0.5 -LR = 0.01 -WEIGHT_DECAY = 5e-4 -EARLY_STOP_WINDOW = 10 -RR = 5 - -DATASETS = [ - ('Yelp', 'Yelp'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - # if torch.cuda.is_available(): - # torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid, Yelp, Reddit, Flickr -from torch_geometric.nn.models import GCN # PyG 封装好的 GCN -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T - -transform = T.NormalizeFeatures() - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # -------------------- 加载数据集 -------------------- - if backend_type == 'Yelp': - dataset = Yelp(root=f'/root/autodl-tmp/data/Yelp') - elif backend_type == 'Reddit': - dataset = Reddit(root=f'/root/autodl-tmp/data/Reddit') - elif backend_type == 'Flickr': - dataset = Flickr(root=f'/root/autodl-tmp/data/Flickr') - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - num_nodes = data.num_nodes - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.float() # 多标签任务 - - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - - data.train_mask = train_mask - data.val_mask = val_mask - data.test_mask = test_mask - - print(f"Train nodes: {train_mask.sum().item()} | Val nodes: {val_mask.sum().item()} | Test nodes: {test_mask.sum().item()}") - - # -------------------- 结果存储 -------------------- - All_forward_times = [] - All_backward_times = [] - All_epoch_times = [] - All_total_train_time = [] - All_test_acc = [] - ALL_f1 = [] - - # -------------------- 重复实验 -------------------- - for R in tqdm(range(RR)): - # -------------------- 设备转移 -------------------- - model = GCN( - in_channels=dataset.num_node_features, - hidden_channels=HIDDEN_DIM, - out_channels=dataset.num_classes, - num_layers=2, - dropout=DROPOUT - ).to(DEVICE) - - # optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) - optimizer = torch.optim.Adam([ - {'params': model.convs[0].parameters(), 'weight_decay': 5e-4}, # 第一层 GCN - {'params': model.convs[1].parameters(), 'weight_decay': 0.0} # 第二层 GCN,不做正则 - ], lr=0.01) - criterion = nn.BCEWithLogitsLoss() # 多标签损失 - - process = psutil.Process(os.getpid()) - memory_usage_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = memory_usage_mb - - forward_times = [] - backward_times = [] - epoch_times = [] - - LOSS_LIST = [] - LOSS_LIST_TEST = [] - LOSS_LIST_VALID = [] - - best_val_loss = float('inf') - early_stop_counter = 0 - - train_start = time.time() - - for epoch in tqdm(range(1, EPOCHS + 1)): - model.train() - optimizer.zero_grad() - epoch_start = time.time() - - # 前向传播 - start_fwd = time.time() - out = model(data.x, data.edge_index) - end_fwd = time.time() - - # 计算 loss - loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) - loss_valid = criterion(out[data.val_mask], data.y[data.val_mask].squeeze()) - loss_test = criterion(out[data.test_mask], data.y[data.test_mask].squeeze()) - - # 反向传播 - start_bwd = time.time() - loss.backward() - optimizer.step() - end_bwd = time.time() - - epoch_end = time.time() - - forward_times.append(end_fwd - start_fwd) - backward_times.append(end_bwd - start_bwd) - epoch_times.append(epoch_end - epoch_start) - - LOSS_LIST.append(round(loss.item(), 3)) - LOSS_LIST_VALID.append(round(loss_valid.item(), 3)) - LOSS_LIST_TEST.append(round(loss_test.item(), 3)) - - # early stopping - if loss_valid.item() < best_val_loss: - best_val_loss = loss_valid.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - # print(f"Early stopping at epoch {epoch}. Validation loss has not decreased for {EARLY_STOP_WINDOW} epochs.") - break - - current_memory_mb = process.memory_info().rss / 1024 / 1024 - peak_memory_mb = max(peak_memory_mb, current_memory_mb) - - train_end = time.time() - total_train_time = train_end - train_start - - # -------------------- 测试函数 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, data.edge_index) - prob = torch.sigmoid(out) # logits -> 概率 - pred = (prob > 0.5).int() - - test_y = data.y[data.test_mask].int() - test_pred = pred[data.test_mask] - - # Accuracy (每个标签独立平均) - test_acc = (test_pred == test_y).float().mean().item() - macro_f1 = f1_score(test_y.cpu().numpy(), test_pred.cpu().numpy(), average='macro') - - avg_fwd = sum(forward_times) / len(forward_times) - avg_bwd = sum(backward_times) / len(backward_times) - avg_epoch = sum(epoch_times) / len(epoch_times) - - All_forward_times.append(avg_fwd) - All_backward_times.append(avg_bwd) - All_epoch_times.append(avg_epoch) - All_total_train_time.append(total_train_time) - All_test_acc.append(test_acc) - ALL_f1.append(macro_f1) - - # -------------------- 输出结果 -------------------- - # print(f'Train_LOSS_{BACKEND} = {LOSS_LIST}') - # print(f'Valid_LOSS_{BACKEND} = {LOSS_LIST_VALID}') - # print(f'Test_LOSS_{BACKEND} = {LOSS_LIST_TEST}') - - print("\n======= 统一测试结果汇总 =======") - print(f"🔹 后端框架: {BACKEND}") - print(f"🔹 数据集: {dataset_name}") - print(f"🔥 总训练时间: {sum(All_total_train_time)/RR:.3f} 秒") - print(f"⏩ 单轮训练平均时间: {sum(All_epoch_times)/RR*1000:.3f} ms") - print(f"🔁 平均前向传播时间: {sum(All_forward_times)/RR*1000:.3f} ms") - print(f"↩️ 平均反向传播时间: {sum(All_backward_times)/RR*1000:.3f} ms") - print(f"📦 内存占用(初始): {memory_usage_mb:.2f} MB") - print(f"📈 运行时峰值内存: {peak_memory_mb:.2f} MB") - print(f"🎯 测试集准确率: {sum(All_test_acc)/RR:.4f}") - print(f"🎯 测试集标准差: {statistics.stdev(All_test_acc):.4f}") - print(f"🎯 测试集F1-score: {sum(ALL_f1)/RR:.4f}") diff --git a/easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py b/easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py deleted file mode 100644 index 9d1e9efc..00000000 --- a/easygraph/nn/tests/GCN_TESTs/test_mix_Precision.py +++ /dev/null @@ -1,95 +0,0 @@ -# test_int8_cpu.py -import os -import time -import numpy as np -import torch -from torch_sparse import SparseTensor -from torch_geometric.datasets import Coauthor, Planetoid -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T - -transform = T.NormalizeFeatures() -DEVICE = torch.device('cpu') -torch.set_num_threads(32) - -_load = torch.load -def load(*args, **kwargs): - kwargs['weights_only'] = False - return _load(*args, **kwargs) -torch.load = load - -# 加载数据集 -# dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name='CS') -dataset = PygNodePropPredDataset(name='ogbn-arxiv', root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') -data = dataset[0] - -X = data.x.float().to(DEVICE) -edge_index = data.edge_index -num_nodes = data.num_nodes -in_channels = X.shape[1] -out_channels = 256 - -# 构造归一化邻接矩阵 -row = edge_index[0].numpy() -col = edge_index[1].numpy() -deg = np.zeros(num_nodes, dtype=np.float32) -for r in row: - deg[r] += 1 -deg_inv_sqrt = np.zeros_like(deg) -nz = deg > 0 -deg_inv_sqrt[nz] = 1.0 / np.sqrt(deg[nz]) -val = deg_inv_sqrt[row] * deg_inv_sqrt[col] -val_torch = torch.tensor(val, dtype=torch.float32) - -A = SparseTensor( - row=torch.tensor(row, dtype=torch.long), - col=torch.tensor(col, dtype=torch.long), - value=val_torch, - sparse_sizes=(num_nodes, num_nodes) -).to(DEVICE) - -# 测试函数 -def measure_time(A, X, W, repeat=100): - times = [] - for _ in range(repeat): - t0 = time.perf_counter() - _ = A.matmul(X @ W) - t1 = time.perf_counter() - times.append(t1 - t0) - return np.mean(times), np.std(times) - -# ===== FP32 baseline ===== -W32 = torch.randn(in_channels, out_channels, dtype=torch.float32, device=DEVICE) -mean32, std32 = measure_time(A, X, W32) - -# ===== INT8 动态量化 ===== -# PyTorch 量化方式: 先用 nn.Linear 包装,再动态量化 -linear_fp32 = torch.nn.Linear(in_channels, out_channels, bias=False) -linear_fp32.weight.data = W32.t().contiguous() # nn.Linear weight shape: (out_features, in_features) - -# ===== INT8 动态量化 ===== -linear_int8 = torch.quantization.quantize_dynamic( - linear_fp32, {torch.nn.Linear}, dtype=torch.qint8 -) - -# 测试 INT8 -def measure_time_int8(A, X, linear_int8, repeat=100): - times = [] - for _ in range(repeat): - t0 = time.perf_counter() - # 直接访问 weight 属性,不要加括号 - W_int8 = linear_int8.weight.dequantize().t() - _ = A.matmul(X @ W_int8) - t1 = time.perf_counter() - times.append(t1 - t0) - return np.mean(times), np.std(times) - - -mean_int8, std_int8 = measure_time_int8(A, X, linear_int8) - -speedup = mean32 / mean_int8 if mean_int8 > 0 else float('nan') - -print(f"CPU-only INT8 test on {DEVICE}") -print(f"FP32 : {mean32*1000:.3f} ms ± {std32*1000:.3f}") -print(f"INT8 : {mean_int8*1000:.3f} ms ± {std_int8*1000:.3f}") -print(f"Speedup: {speedup:.3f}x") diff --git a/easygraph/nn/tests/GCN_TESTs/txtReader.py b/easygraph/nn/tests/GCN_TESTs/txtReader.py deleted file mode 100644 index e4e597b0..00000000 --- a/easygraph/nn/tests/GCN_TESTs/txtReader.py +++ /dev/null @@ -1,42 +0,0 @@ -import os -import pandas as pd -import numpy as np -from node2vec import Node2Vec -import networkx as nx -import torch - -class TxtDataset: - def __init__(self, X, Y, edge_index=None): - self.x = torch.tensor(X) - self.y = torch.tensor(Y) - self.num_nodes = Y.shape[0] - self.edge_index = edge_index - -def TxtGraphReader(root, name): - path = os.path.join(root, name, name + '.txt') - edges = np.loadtxt(path, comments='#', dtype=int) - unique_edges = set() - for u, v in edges: - if u == v: - continue - if (u,v) not in unique_edges and (v,u) not in unique_edges: - unique_edges.add((u, v)) - - edges = edges.T - - num_nodes = max(edges.flatten()) + 1 - feature_dim = 128 - class_num = 3 - - if os.path.exists(os.path.join(root, name, 'X.npy')) and os.path.exists(os.path.join(root, name, 'Y.npy')): - X = np.load(os.path.join(root, name, 'X.npy')) - Y = np.load(os.path.join(root, name, 'Y.npy')) - else: - X = np.random.uniform(-10, 10, size=(num_nodes, feature_dim)) - Y = np.random.randint(0, class_num, size=(num_nodes, )) - np.save(os.path.join(root, name, 'X.npy'), X) - np.save(os.path.join(root, name, 'Y.npy'), Y) - - dataset = TxtDataset(X=X, Y=Y, edge_index=edges) - - return [dataset] diff --git a/easygraph/nn/tests/autodl-tmp/data/mag/raw/mag.zip b/easygraph/nn/tests/autodl-tmp/data/mag/raw/mag.zip deleted file mode 100644 index e69de29b..00000000 diff --git a/easygraph/nn/tests/dataset_info.py b/easygraph/nn/tests/dataset_info.py deleted file mode 100644 index 307c2f86..00000000 --- a/easygraph/nn/tests/dataset_info.py +++ /dev/null @@ -1,127 +0,0 @@ -import time -import torch -import torch.nn.functional as F -import random -import numpy as np -import os -from sklearn.metrics import f1_score -from tqdm import tqdm - -# -------------------- 配置 -------------------- -BACKEND = 'PyG' -DEVICE = torch.device('cpu') # 如果有GPU改成 'cuda' -SEED = 42 -EPOCHS = 200 -HIDDEN_DIM = 16 -DROPOUT = 0.5 -LR = 0.01 -WEIGHT_DECAY = 5e-4 -EARLY_STOP_WINDOW = 10 -RR = 1 # 测试先只跑1次重复实验 - -DATASETS = [ - ('Planetoid', 'Cora'), -] - -# -------------------- 固定随机种子 -------------------- -def set_seed(seed=42): - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed(seed) - -set_seed(SEED) - -# -------------------- 包和数据导入 -------------------- -from torch_geometric.datasets import Coauthor, Planetoid -from torch_geometric.nn.models import GCN # PyG封装的GCN -import torch_geometric.transforms as T - -transform = T.NormalizeFeatures() - -# -------------------- 主循环 -------------------- -for backend_type, dataset_name in DATASETS: - print(f"\n================= 数据集: {dataset_name} =================") - - # 加载数据集 - if backend_type == 'Coauthor': - dataset = Coauthor(root=f'data/Coauthor', name=dataset_name) - elif backend_type == 'Planetoid': - dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = dataset[0] - - # -------------------- 数据类型修正 -------------------- - data.x = data.x.float() - data.y = data.y.long() - - # -------------------- 数据集划分 -------------------- - if backend_type in ['Coauthor', 'Planetoid']: - train_mask = data.train_mask - val_mask = data.val_mask - test_mask = data.test_mask - print(f"Train nodes: {train_mask.sum().item()}") - print(f"Valid nodes: {val_mask.sum().item()}") - print(f"Test nodes: {test_mask.sum().item()}") - else: - raise ValueError(f"Unknown dataset type {backend_type}") - - data = data.to(DEVICE) - - # -------------------- 训练循环 -------------------- - for R in range(RR): - model = GCN( - in_channels=dataset.num_node_features, - hidden_channels=HIDDEN_DIM, - out_channels=dataset.num_classes, - num_layers=2, - dropout=DROPOUT - ).to(DEVICE) - - optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) - criterion = torch.nn.CrossEntropyLoss() - - best_val_loss = float('inf') - early_stop_counter = 0 - - for epoch in range(1, EPOCHS + 1): - model.train() - optimizer.zero_grad() - - out = model(data.x, data.edge_index) - loss_train = criterion(out[train_mask], data.y[train_mask].squeeze()) - loss_val = criterion(out[val_mask], data.y[val_mask].squeeze()) - - loss_train.backward() - optimizer.step() - - # early stopping - if loss_val.item() < best_val_loss: - best_val_loss = loss_val.item() - early_stop_counter = 0 - else: - early_stop_counter += 1 - - if early_stop_counter >= EARLY_STOP_WINDOW: - print(f"Early stopping at epoch {epoch}") - break - - # 打印每轮信息 - model.eval() - with torch.no_grad(): - pred = out.argmax(dim=1) - acc_train = (pred[train_mask] == data.y[train_mask].squeeze()).sum().item() / train_mask.sum().item() - acc_val = (pred[val_mask] == data.y[val_mask].squeeze()).sum().item() / val_mask.sum().item() - print(f"Epoch {epoch:03d} | Train Loss: {loss_train.item():.4f} | Val Loss: {loss_val.item():.4f} | Train Acc: {acc_train:.4f} | Val Acc: {acc_val:.4f}") - - # -------------------- 测试 -------------------- - model.eval() - with torch.no_grad(): - out = model(data.x, data.edge_index) - pred = out.argmax(dim=1) - test_acc = (pred[test_mask] == data.y[test_mask].squeeze()).sum().item() / test_mask.sum().item() - macro_f1 = f1_score(data.y[test_mask].squeeze(), pred[test_mask], average='macro') - print(f"Test Accuracy: {test_acc:.4f} | Test Macro-F1: {macro_f1:.4f}") diff --git a/easygraph/nn/tests/draw.py b/easygraph/nn/tests/draw.py deleted file mode 100644 index 730a96f0..00000000 --- a/easygraph/nn/tests/draw.py +++ /dev/null @@ -1,194 +0,0 @@ -import networkx as nx -import matplotlib.pyplot as plt -import numpy as np -from torch_geometric.datasets import Coauthor, Planetoid, Reddit, Flickr, Yelp, Amazon -from ogb.nodeproppred import PygNodePropPredDataset -import torch_geometric.transforms as T -import torch -import igraph as ig - -# ----------------------------- -# 指标计算函数 -# ----------------------------- -# def degree_heterogeneity(G): -# degrees = np.array([d for _, d in G.degree()]) -# mean_k = degrees.mean() -# mean_k2 = np.mean(degrees ** 2) -# H = mean_k2 / (mean_k ** 2) if mean_k > 0 else 0 -# return H - -# def num_com(G, min_size=10): -# """ -# 使用 igraph 统计 NetworkX 图的社区数Louvain / Multilevel。 -# G: networkx.Graph -# 返回: 社区数量 -# """ -# ig_g = ig.Graph.from_networkx(G) -# communities = ig_g.community_multilevel() -# # 过滤掉太小的社区 -# filtered_coms = [c for c in communities if len(c) >= min_size] -# return len(filtered_coms) - -# def load_snap_graph(dataset_name, root='/root/autodl-tmp/data'): -# path_map = { -# 'com-lj': 'com-lj/soc-LiveJournal1.txt', -# 'com-amazon': 'com-amazon/com-amazon.ungraph.txt', -# 'pokec': 'pokec/soc-pokec-relationships.txt' -# } -# path = f"{root}/{path_map[dataset_name]}" -# G = nx.Graph() -# with open(path, 'r') as f: -# for line in f: -# if line.startswith('#'): # 跳过注释 -# continue -# u, v = map(int, line.strip().split()) -# G.add_edge(u, v) -# return G - -# # ----------------------------- -# # 需要处理的数据集 -# # ----------------------------- -# DATASETS = [ -# ('Coauthor', 'CS'), -# ('Coauthor', 'Physics'), -# ('Planetoid', 'Cora'), -# ('Planetoid', 'Citeseer'), -# ('Planetoid', 'PubMed'), -# ('ogb', 'ogbn-arxiv'), -# ('ogb', 'ogbn-products'), -# ('Reddit', 'Reddit'), -# ('Flickr', 'Flickr'), -# ('Yelp', 'Yelp'), -# ('snap', 'com-lj'), -# ('snap', 'com-amazon'), -# ('snap', 'pokec') -# ] - -# # ----------------------------- -# # 主程序 -# # ----------------------------- -# transform = T.NormalizeFeatures() - -# _load = torch.load -# def load(*args, **kwargs): -# kwargs['weights_only'] = False -# return _load(*args, **kwargs) -# torch.load = load - -# results = [] -# for backend_type, dataset_name in DATASETS: -# print(f"\n================= 数据集: {dataset_name} =================") - -# # -------------------- 加载数据集 -------------------- -# if backend_type == 'Coauthor': -# dataset = Coauthor(root=f'/root/Easy-Graph/easygraph/nn/tests/data/Coauthor', name=dataset_name) -# edge_index = dataset[0].edge_index.numpy() -# G = nx.Graph() -# G.add_edges_from(edge_index.T) - -# elif backend_type == 'Planetoid': -# dataset = Planetoid(root=f'/tmp/{dataset_name}', name=dataset_name, transform=transform) -# edge_index = dataset[0].edge_index.numpy() -# G = nx.Graph() -# G.add_edges_from(edge_index.T) - -# elif backend_type in ['Reddit', 'Flickr', 'Yelp']: -# cls_map = {'Reddit': Reddit, 'Flickr': Flickr, 'Yelp': Yelp} -# dataset = cls_map[backend_type](root=f'/root/autodl-tmp/data/{backend_type}') -# edge_index = dataset[0].edge_index.numpy() -# G = nx.Graph() -# G.add_edges_from(edge_index.T) - -# elif backend_type == 'ogb': -# dataset = PygNodePropPredDataset(name=dataset_name, root='/root/Easy-Graph/easygraph/nn/tests/data/OGB') -# edge_index = dataset[0].edge_index.numpy() -# G = nx.Graph() -# G.add_edges_from(edge_index.T) - -# elif backend_type == 'snap': -# # SNAP 格式的 txt 文件 -# G = load_snap_graph(dataset_name, root='/root/autodl-tmp/data') - -# else: -# raise ValueError(f"Unknown dataset type {backend_type} or dataset {dataset_name}") - -# # -------------------- 计算指标 -------------------- -# print(f'dataset: {dataset_name} load success!') -# H = degree_heterogeneity(G) -# NC = num_com(G) -# N = G.number_of_nodes() - -# results.append((dataset_name, H, NC, N)) -# print(f"{dataset_name}: H={H:.4f}, Num_Communities={NC}, N={N}") - -# N = G.number_of_nodes() # 节点数 -# E = G.number_of_edges() # 边数 - -# # 新增的指标 -# avg_degree = 2 * E / N if N > 0 else 0 # 平均度 -# density = 2 * E / (N * (N - 1)) if N > 1 else 0 # 密度 -# edge_node_ratio = E / N if N > 0 else 0 # 边节点比 - -# print(f"{dataset_name}: " -# f"AvgDeg={avg_degree:.4f}, " -# f"Density={density:.6f}, " -# f"E/N={edge_node_ratio:.4f}" -# f"E={E}") - - -# 度异质性,社区数量,节点数,平均度,密度,边节点比, 边数 - -# results = [ -# ("Cora", 2.7986, 104, 2708, 3.8981, 0.001440, 1.9490, 5278), -# ("Citeseer", 2.4900, 420, 3279, 2.7765, 0.000847, 1.3882, 4552), -# ("CS", 2.0390, 28, 18333, 8.9341, 0.000487, 4.4670, 81894), -# ("PubMed", 3.7317, 47, 19717, 4.4960, 0.000228, 2.2480, 44324), -# ("Physics", 2.1732, 22, 34493, 14.3775, 0.000417, 7.1888, 247962), -# ("Flickr", 10.9179, 25, 89250, 10.0813, 0.000113, 5.0406,449878), -# ("ogbn-arxiv", 26.1964, 143, 169343, 13.6740, 0.000081, 6.8370, 1157799), -# ("Reddit", 3.6429, 26, 232965, 491.9876, 0.002112, 245.9938, 57307946), -# ("com-amazon", 2.5221, 153, 403394, 12.1143, 0.000030, 6.0571, 2443408), -# ("Yelp", 12.9901, 21759, 716847, 20.4669, 0.000029, 10.2335, 7335833), -# ("pokec", 3.4607, 35, 1632803, 27.3174, 0.000017, 13.6587, 22301964), -# ("ogbn-products", 4.5131, 4427, 2400608, 51.5362, 0.000021, 25.7681, 61859140), -# ("com-lj", 9.4915, 5247, 4847571, 17.8933, 0.000004, 8.9467, 7335833) -# ] - -# 度异质性,社区数量(过滤小社区后),节点数,平均度,密度,边节点比, 边数 -results = [ - ("Cora", 2.7986, 24, 2708, 3.8981, 0.001440, 1.9490, 5278), - ("Citeseer", 2.4900, 39, 3279, 2.7765, 0.000847, 1.3882, 4552), - ("CS", 2.0390, 23, 18333, 8.9341, 0.000487, 4.4670, 81894), - ("PubMed", 3.7317, 43, 19717, 4.4960, 0.000228, 2.2480, 44324), - ("Physics", 2.1732, 21, 34493, 14.3775, 0.000417, 7.1888, 247962), - # ("Flickr", 10.9179, 26, 89250, 10.0813, 0.000113, 5.0406,449878), - ("ogbn-arxiv", 26.1964, 103, 169343, 13.6740, 0.000081, 6.8370, 1157799), - ("Reddit", 3.6429, 27, 232965, 491.9876, 0.002112, 245.9938, 57307946), - # ("com-amazon", 2.5221, 164, 403394, 12.1143, 0.000030, 6.0571, 2443408), - ("Yelp", 12.9901, 313, 716847, 20.4669, 0.000029, 10.2335, 7335833), - # ("pokec", 3.4607, 33, 1632803, 27.3174, 0.000017, 13.6587, 22301964), - ("ogbn-products", 4.5131, 204, 2400608, 51.5362, 0.000021, 25.7681, 61859140), - ("com-lj", 9.4915, 1235, 4847571, 17.8933, 0.000004, 8.9467, 7335833) -] - - - -plt.figure(figsize=(8,6)) - -for name, H, NC, N, AvgDeg, Density, ENR, E in results: - plt.scatter(np.log1p(H), np.log10(NC), s=np.sqrt(N)/2, alpha=0.7, label=name) - # plt.scatter(H, np.log10(NC), s=AvgDeg*10, alpha=0.7, label=name) # 放大一点方便区分 - # plt.scatter(H, np.log10(NC), s= np.sqrt(E)/2 , alpha=0.7, label=name) - -plt.xlabel("Degree Heterogeneity (H)") -plt.ylabel("Number of Communities") -plt.title("Real-world Network Analysis") -plt.grid(True, linestyle="--", alpha=0.6) - -# 添加图例 -plt.legend(fontsize=8, ncol=2, markerscale=0.5) - -# 保存图片 -plt.savefig("network_scatter_NC10.png", dpi=300, bbox_inches='tight') -plt.show() - diff --git a/easygraph/nn/tests/network_scatter_NC10.png b/easygraph/nn/tests/network_scatter_NC10.png deleted file mode 100644 index fc0104ddbcdbe9046f303924a51762d8658b78a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174379 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 z

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%0DsPvP67~CEHY7PFz#L3e1CbEVed&~QxjijFPU(1 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�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 D@?zvV diff --git a/easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag.zip b/easygraph/nn/tests/test_gatconv.py similarity index 100% rename from easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag.zip rename to easygraph/nn/tests/test_gatconv.py diff --git a/easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag/raw/mag.zip b/easygraph/nn/tests/test_gcnconv.py similarity index 100% rename from easygraph/nn/tests/autodl-tmp/data/OGBN_MAG/mag/raw/mag.zip rename to easygraph/nn/tests/test_gcnconv.py From 9245dec4bb9543ca7438011cda020fcaa1e41765 Mon Sep 17 00:00:00 2001 From: sama Date: Tue, 27 Jan 2026 06:33:04 -0700 Subject: [PATCH 32/33] modify intital weight --- cpp_easygraph/cpp_easygraph.cpp | 4 +- .../functions/centrality/eigenvector.cpp | 179 ++++++++---------- cpp_easygraph/functions/pagerank/pagerank.cpp | 143 ++++++-------- 3 files changed, 141 insertions(+), 185 deletions(-) diff --git a/cpp_easygraph/cpp_easygraph.cpp b/cpp_easygraph/cpp_easygraph.cpp index 1faf8ee5..144e09df 100644 --- a/cpp_easygraph/cpp_easygraph.cpp +++ b/cpp_easygraph/cpp_easygraph.cpp @@ -81,14 +81,14 @@ PYBIND11_MODULE(cpp_easygraph, m) { m.def("cpp_closeness_centrality", &closeness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(), py::arg("sources") = py::none()); m.def("cpp_betweenness_centrality", &betweenness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(),py::arg("sources") = py::none(), py::arg("normalized") = py::bool_(true), py::arg("endpoints") = py::bool_(false)); m.def("cpp_katz_centrality", &cpp_katz_centrality, py::arg("G"), py::arg("alpha") = 0.1, py::arg("beta") = 1.0, py::arg("max_iter") = 1000, py::arg("tol") = 1e-6, py::arg("normalized") = true); - m.def("cpp_eigenvector_centrality", &cpp_eigenvector_centrality, py::arg("G"), py::arg("max_iter") = 100, py::arg("tol") = 1.0e-6, py::arg("nstart") = py::none(), py::arg("weight") = py::none()); + m.def("cpp_eigenvector_centrality", &cpp_eigenvector_centrality, py::arg("G"), py::arg("max_iter") = 100, py::arg("tol") = 1.0e-6, py::arg("nstart") = py::none(), py::arg("weight") = "weight"); m.def("cpp_k_core", &core_decomposition, py::arg("G")); m.def("cpp_density", &density, py::arg("G")); m.def("cpp_constraint", &constraint, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); m.def("cpp_effective_size", &effective_size, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); m.def("cpp_efficiency", &efficiency, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); m.def("cpp_hierarchy", &hierarchy, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); - m.def("cpp_pagerank", &_pagerank, py::arg("G"), py::arg("alpha") = 0.85, py::arg("max_iterator") = 500, py::arg("threshold") = 1e-6, py::arg("weight") = py::none()); + m.def("cpp_pagerank", &_pagerank, py::arg("G"), py::arg("alpha") = 0.85, py::arg("max_iterator") = 500, py::arg("threshold") = 1e-6, py::arg("weight") = "weight"); m.def("cpp_dijkstra_multisource", &_dijkstra_multisource, py::arg("G"), py::arg("sources"), py::arg("weight") = "weight", py::arg("target") = py::none()); m.def("cpp_spfa", &_spfa, py::arg("G"), py::arg("source"), py::arg("weight") = "weight"); m.def("cpp_clustering", &clustering, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none()); diff --git a/cpp_easygraph/functions/centrality/eigenvector.cpp b/cpp_easygraph/functions/centrality/eigenvector.cpp index 7b6e63b1..a5f61729 100644 --- a/cpp_easygraph/functions/centrality/eigenvector.cpp +++ b/cpp_easygraph/functions/centrality/eigenvector.cpp @@ -2,17 +2,16 @@ #include #include #include -#include -#include #include #include #include + #ifdef _OPENMP #include #endif -#include "centrality.h" #include "../../classes/graph.h" +#include "../../common/utils.h" namespace py = pybind11; @@ -20,16 +19,16 @@ class CSRMatrix { public: std::vector indptr; std::vector indices; - std::vector data; - int rows; - int cols; + std::vector data; // Empty if unweighted (all 1.0) + int rows, cols; + bool is_weighted; - CSRMatrix() : rows(0), cols(0) {} - CSRMatrix(int r, int c) : rows(r), cols(c) { + CSRMatrix(int r, int c) : rows(r), cols(c), is_weighted(false) { indptr.assign(r + 1, 0); } }; +// Power iteration with branch optimization for weighted/unweighted paths std::vector power_iteration_optimized( const CSRMatrix& A, int max_iter, @@ -38,7 +37,9 @@ std::vector power_iteration_optimized( ) { const int n = A.rows; std::vector x_next(n); - + bool use_weight = A.is_weighted && !A.data.empty(); + + // Initial normalization double norm = 0.0; #pragma omp parallel for reduction(+:norm) for (int i = 0; i < n; ++i) norm += x[i] * x[i]; @@ -53,7 +54,6 @@ std::vector power_iteration_optimized( } double delta = tol + 1.0; - for (int iter = 0; iter < max_iter && delta >= tol; ++iter) { double next_norm_sq = 0.0; @@ -63,8 +63,14 @@ std::vector power_iteration_optimized( const int start = A.indptr[i]; const int end = A.indptr[i+1]; - for (int j = start; j < end; ++j) { - sum += A.data[j] * x[A.indices[j]]; + if (use_weight) { + for (int j = start; j < end; ++j) { + sum += A.data[j] * x[A.indices[j]]; + } + } else { + for (int j = start; j < end; ++j) { + sum += x[A.indices[j]]; + } } x_next[i] = sum; @@ -83,73 +89,62 @@ std::vector power_iteration_optimized( delta += std::abs(val - x[i]); x_next[i] = val; } - x.swap(x_next); } - return x; } -CSRMatrix build_transpose_matrix(Graph& graph, const std::vector& nodes, const std::string& weight_key) { - try { - std::shared_ptr csr_ptr; - if (weight_key.empty()) { - csr_ptr = graph.gen_CSR(); - } else { - csr_ptr = graph.gen_CSR(weight_key); - } - - if (!csr_ptr) return CSRMatrix(nodes.size(), nodes.size()); - - const int n = static_cast(nodes.size()); - const auto& src_indptr = csr_ptr->V; - const auto& src_indices = csr_ptr->E; - std::vector src_data; - - if (weight_key.empty()) { - src_data = csr_ptr->unweighted_W.empty() ? - std::vector(csr_ptr->E.size(), 1.0) : - csr_ptr->unweighted_W; - } else { - auto it = csr_ptr->W_map.find(weight_key); - if (it != csr_ptr->W_map.end() && it->second) { - src_data = *(it->second); - } else { - src_data = std::vector(csr_ptr->E.size(), 1.0); +// Build transpose CSR with fallback logic for missing weight keys +CSRMatrix build_transpose_matrix_smart(Graph& graph, const std::vector& nodes, const std::string& weight_key) { + std::shared_ptr csr_ptr = weight_key.empty() ? graph.gen_CSR() : graph.gen_CSR(weight_key); + + int n = static_cast(nodes.size()); + CSRMatrix At(n, n); + if (!csr_ptr) return At; + + const auto& src_indptr = csr_ptr->V; + const auto& src_indices = csr_ptr->E; + std::vector src_data; + bool actually_weighted = false; + + // Detect if weighted calculation is required + if (!weight_key.empty()) { + auto it = csr_ptr->W_map.find(weight_key); + if (it != csr_ptr->W_map.end() && it->second) { + src_data = *(it->second); + for (double w : src_data) { + if (std::abs(w - 1.0) > 1e-9) { + actually_weighted = true; + break; + } } } + } - int rows = n; - int cols = n; - CSRMatrix At(cols, rows); - - for (int x : src_indices) { - if (x >= 0 && x < cols) At.indptr[x + 1]++; - } - for (int i = 0; i < cols; ++i) { - At.indptr[i + 1] += At.indptr[i]; - } + At.is_weighted = actually_weighted; - size_t nnz = src_indices.size(); - At.indices.resize(nnz); - At.data.resize(nnz); - std::vector cur_pos(At.indptr.begin(), At.indptr.end()); - - for (int r = 0; r < rows; ++r) { - int start = src_indptr[r]; - int end = src_indptr[r+1]; - for (int p = start; p < end; ++p) { - int c = src_indices[p]; - if (c < 0 || c >= cols) continue; - int dest = cur_pos[c]++; - At.indices[dest] = r; - At.data[dest] = (p < static_cast(src_data.size())) ? src_data[p] : 1.0; - } + // Calculate row counts for transpose + for (int x_idx : src_indices) { + if (x_idx >= 0 && x_idx < n) At.indptr[x_idx + 1]++; + } + for (int i = 0; i < n; ++i) At.indptr[i + 1] += At.indptr[i]; + + At.indices.resize(src_indices.size()); + if (actually_weighted) At.data.resize(src_indices.size()); + + std::vector cur_pos(At.indptr.begin(), At.indptr.end()); + + // Populate transpose CSR data + for (int r = 0; r < n; ++r) { + for (int p = src_indptr[r]; p < src_indptr[r+1]; ++p) { + int c = src_indices[p]; + if (c < 0 || c >= n) continue; + int dest = cur_pos[c]++; + At.indices[dest] = r; + if (actually_weighted) At.data[dest] = src_data[p]; } - return At; - } catch (...) { - return CSRMatrix(nodes.size(), nodes.size()); } + return At; } py::object cpp_eigenvector_centrality( @@ -163,55 +158,41 @@ py::object cpp_eigenvector_centrality( Graph& graph = G.cast(); int max_iter = py_max_iter.cast(); double tol = py_tol.cast(); - std::string weight_key = ""; - if (!py_weight.is_none()) { - weight_key = py_weight.cast(); - } + std::string weight_key = py_weight.is_none() ? "" : py_weight.cast(); if (graph.node.empty()) return py::dict(); std::vector nodes; - nodes.reserve(graph.node.size()); - for (auto& node_pair : graph.node) { - nodes.push_back(node_pair.first); - } - const int n = nodes.size(); - - CSRMatrix A_transpose = build_transpose_matrix(graph, nodes, weight_key); + for (auto& pair : graph.node) nodes.push_back(pair.first); + int n = nodes.size(); - std::vector x(n, 0.0); + CSRMatrix A_transpose = build_transpose_matrix_smart(graph, nodes, weight_key); - if (py_nstart.is_none()) { - #pragma omp parallel for + // Initialize x vector (prefer degree-based or uniform) + std::vector x(n, 1.0 / n); + if (!py_nstart.is_none()) { + py::dict nstart = py_nstart.cast(); for (int i = 0; i < n; i++) { - if (A_transpose.indptr[i + 1] != A_transpose.indptr[i]) { - x[i] = static_cast(A_transpose.indptr[i+1] - A_transpose.indptr[i]); - } else { - x[i] = 1.0 / n; - } + py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; + if (nstart.contains(node_obj)) x[i] = nstart[node_obj].cast(); } } else { - py::dict nstart = py_nstart.cast(); - for (size_t i = 0; i < nodes.size(); i++) { - py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; - if (nstart.contains(node_obj)) { - x[i] = nstart[node_obj].cast(); - } else { - x[i] = 0.0; - } + for (int i = 0; i < n; i++) { + int degree = A_transpose.indptr[i+1] - A_transpose.indptr[i]; + x[i] = (degree > 0) ? (double)degree : 1.0/n; } } - std::vector centrality = power_iteration_optimized(A_transpose, max_iter, tol, x); + std::vector res = power_iteration_optimized(A_transpose, max_iter, tol, x); py::dict result; - for (size_t i = 0; i < nodes.size(); i++) { + for (int i = 0; i < n; i++) { py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; - result[node_obj] = centrality[i]; + result[node_obj] = res[i]; } return result; } catch (const std::exception& e) { - throw std::runtime_error(std::string("C++ exception: ") + e.what()); + throw std::runtime_error(std::string("C++ Eigenvector Error: ") + e.what()); } } \ No newline at end of file diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp index cb42abae..dcc489a6 100644 --- a/cpp_easygraph/functions/pagerank/pagerank.cpp +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -1,144 +1,119 @@ -#ifdef _OPENMP -#include -#endif #include #include -#include #include +#include +#include +#ifdef _OPENMP +#include +#endif + #include "pagerank.h" #include "../../classes/directed_graph.h" #include "../../classes/graph.h" #include "../../common/utils.h" #include "../../classes/linkgraph.h" -struct Page { - Page() {} - Page(const double &_newPR, const double &_oldPR) { newPR = _newPR; oldPR = _oldPR; } - double newPR, oldPR; -}; +namespace py = pybind11; -py::object _pagerank(py::object G, double alpha=0.85, int max_iterator=500, double threshold=1e-6, py::object weight=py::none()) { +py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold, py::object weight) { bool is_directed = G.attr("is_directed")().cast(); - - bool use_weights = !weight.is_none(); - std::string weight_key = ""; - if (use_weights) { - weight_key = weight_to_string(weight); - } + std::string weight_key = weight_to_string(weight); + bool has_weight_key = !weight.is_none() && !weight_key.empty(); Graph_L* G_l_ptr = nullptr; int N = 0; - if (is_directed) { DiGraph& G_ = G.cast(); N = G_.node.size(); - if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); G_.linkgraph_dirty = false; } - G_l_ptr = &G_.linkgraph_structure; } else { Graph& G_ = G.cast(); N = G_.node.size(); - if (G_.linkgraph_dirty) { G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); G_.linkgraph_dirty = false; } - G_l_ptr = &G_.linkgraph_structure; } - std::vector& E = G_l_ptr->edges; - std::vector& outDegree = G_l_ptr->degree; - std::vector& head = G_l_ptr->head; - - std::vector outWeightSum; - if (use_weights) { - outWeightSum.resize(N + 1, 0.0); - #pragma omp parallel for - for (int i = 1; i < N + 1; ++i) { - if (outDegree[i] > 0) { - double sum_w = 0.0; - for (int p = head[i]; p != -1; p = E[p].next) { - sum_w += E[p].w; + const std::vector& E = G_l_ptr->edges; + const std::vector& outDegree = G_l_ptr->degree; + const std::vector& head = G_l_ptr->head; + + bool actually_weighted = false; + std::vector outWeightSum(N + 1, 0.0); + + if (has_weight_key) { + #pragma omp parallel for reduction(|:actually_weighted) + for (int i = 1; i <= N; ++i) { + double sum_w = 0.0; + for (int p = head[i]; p != -1; p = E[p].next) { + sum_w += E[p].w; + if (!actually_weighted && std::abs(E[p].w - 1.0) > 1e-9) { + actually_weighted = true; } - outWeightSum[i] = sum_w; } + outWeightSum[i] = sum_w; } } + bool use_weighted_logic = has_weight_key && actually_weighted; - std::vector page(N + 1); - #pragma omp parallel for - for (int i = 1; i < N + 1; ++i) { - page[i] = Page(0.0, 1.0 / N); - } - + std::vector oldPR(N + 1, 1.0 / N); + std::vector newPR(N + 1, 0.0); int cnt = 0; - int shouldStop = 0; - - while (!shouldStop) { - shouldStop = 1; - double res = 0.0; - - #pragma omp parallel for reduction(+:res) - for (int i = 1; i < N + 1; ++i) { - bool is_dangling = false; - if (use_weights) { - if (outDegree[i] == 0 || outWeightSum[i] == 0.0) is_dangling = true; - } else { - if (outDegree[i] == 0) is_dangling = true; - } - if (is_dangling) res += page[i].oldPR; + + while (cnt < max_iterator) { + double dangling_sum = 0.0; + + #pragma omp parallel for reduction(+:dangling_sum) + for (int i = 1; i <= N; ++i) { + bool is_dangling = use_weighted_logic ? (outWeightSum[i] < 1e-15) : (outDegree[i] == 0); + if (is_dangling) dangling_sum += oldPR[i]; } - #pragma omp parallel for schedule(dynamic, 128) - for (int i = 1; i < N + 1; ++i) { - if (use_weights) { - if (outDegree[i] == 0 || outWeightSum[i] == 0.0) continue; - } else { + if (!use_weighted_logic) { + #pragma omp parallel for schedule(dynamic, 128) + for (int i = 1; i <= N; ++i) { if (outDegree[i] == 0) continue; - } - - if (!use_weights) { - double tmpPR = (page[i].oldPR / outDegree[i]) * alpha; + double out_val = (oldPR[i] / outDegree[i]) * alpha; for (int p = head[i]; p != -1; p = E[p].next) { #pragma omp atomic - page[E[p].to].newPR += tmpPR; + newPR[E[p].to] += out_val; } - } else { - double basePR = page[i].oldPR * alpha; - double inv_sum = 1.0 / outWeightSum[i]; + } + } else { + #pragma omp parallel for schedule(dynamic, 128) + for (int i = 1; i <= N; ++i) { + if (outWeightSum[i] < 1e-15) continue; + double out_val = (oldPR[i] / outWeightSum[i]) * alpha; for (int p = head[i]; p != -1; p = E[p].next) { - double contribution = basePR * (E[p].w * inv_sum); #pragma omp atomic - page[E[p].to].newPR += contribution; + newPR[E[p].to] += out_val * E[p].w; } } } - double sum = 0.0; + double diff_sum = 0.0; + double jump_val = (1.0 - alpha) / N + (dangling_sum / N) * alpha; - #pragma omp parallel for reduction(+:sum) - for (int i = 1; i < N + 1; ++i) { - page[i].newPR += (1.0 - alpha) / N + (res / N) * alpha; - sum += std::fabs(page[i].newPR - page[i].oldPR); - page[i].oldPR = page[i].newPR; - page[i].newPR = 0.0; + #pragma omp parallel for reduction(+:diff_sum) + for (int i = 1; i <= N; ++i) { + double final_pr = newPR[i] + jump_val; + diff_sum += std::fabs(final_pr - oldPR[i]); + oldPR[i] = final_pr; + newPR[i] = 0.0; } - if (sum > threshold * N) shouldStop = 0; + if (diff_sum < threshold * N) break; cnt++; - if (cnt >= max_iterator) break; } py::list res_lst; - for (int i = 1; i < N + 1; ++i) { - res_lst.append(page[i].oldPR); - } - + for (int i = 1; i <= N; ++i) res_lst.append(oldPR[i]); return res_lst; } \ No newline at end of file From 98c843ce53dfa6b73f75b499a6fac204c66fcb82 Mon Sep 17 00:00:00 2001 From: sama Date: Tue, 27 Jan 2026 23:13:21 -0700 Subject: [PATCH 33/33] support mac OS --- cpp_easygraph/CMakeLists.txt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cpp_easygraph/CMakeLists.txt b/cpp_easygraph/CMakeLists.txt index d7c123eb..ae15d7c9 100644 --- a/cpp_easygraph/CMakeLists.txt +++ b/cpp_easygraph/CMakeLists.txt @@ -1,12 +1,6 @@ cmake_minimum_required(VERSION 3.23) - project(cpp_easygraph) - set(CMAKE_CXX_STANDARD 11) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -option(EASYGRAPH_ENABLE_OPENMP "Enable OpenMP acceleration (auto-detect)" ON) -option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) file(GLOB SOURCES classes/*.cpp @@ -17,6 +11,9 @@ file(GLOB SOURCES add_subdirectory(pybind11) +option(EASYGRAPH_ENABLE_OPENMP "Enable OpenMP acceleration (auto-detect)" ON) +option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) + if (EASYGRAPH_ENABLE_GPU) pybind11_add_module(cpp_easygraph