From a1ef1deb5a9f68c891f49cd695c26de7abb14159 Mon Sep 17 00:00:00 2001 From: Joseph Nke <76006812+jnke2016@users.noreply.github.com> Date: Fri, 29 Jul 2022 08:18:22 -0500 Subject: [PATCH 01/19] Update cugraph python build (#2378) This PR updates cugraph python build to use scikit-build instead of setuptools. Scikit-build leverages cmake to build the python extension closes #2333 Authors: - Joseph Nke (https://github.com/jnke2016) - Chuck Hastings (https://github.com/ChuckHastings) - Vyas Ramasubramani (https://github.com/vyasr) Approvers: - Brad Rees (https://github.com/BradReesWork) - Sevag H (https://github.com/sevagh) - Rick Ratzel (https://github.com/rlratzel) - Vyas Ramasubramani (https://github.com/vyasr) URL: https://github.com/rapidsai/cugraph/pull/2378 --- .gitignore | 4 + build.sh | 8 +- ci/release/update-version.sh | 3 + conda/environments/cugraph_dev_cuda11.2.yml | 1 + conda/environments/cugraph_dev_cuda11.4.yml | 1 + conda/environments/cugraph_dev_cuda11.5.yml | 1 + conda/recipes/cugraph/conda_build_config.yaml | 3 + conda/recipes/cugraph/meta.yaml | 2 + .../pylibcugraph/conda_build_config.yaml | 3 + conda/recipes/pylibcugraph/meta.yaml | 2 + cpp/CMakeLists.txt | 4 +- cpp/cmake/thirdparty/get_libcudacxx.cmake | 3 +- fetch_rapids.cmake | 17 +++ python/cugraph/CMakeLists.txt | 84 ++++++++++++ .../cugraph/cugraph/centrality/CMakeLists.txt | 29 ++++ .../cugraph/cugraph/community/CMakeLists.txt | 34 +++++ .../cugraph/cugraph/components/CMakeLists.txt | 25 ++++ python/cugraph/cugraph/cores/CMakeLists.txt | 25 ++++ .../cugraph/dask/centrality/CMakeLists.txt | 25 ++++ .../cugraph/cugraph/dask/comms/CMakeLists.txt | 25 ++++ .../cugraph/dask/community/CMakeLists.txt | 25 ++++ .../cugraph/dask/components/CMakeLists.txt | 25 ++++ .../cugraph/dask/link_analysis/CMakeLists.txt | 25 ++++ .../cugraph/dask/structure/CMakeLists.txt | 25 ++++ .../cugraph/cugraph/generators/CMakeLists.txt | 25 ++++ .../cugraph/cugraph/internals/CMakeLists.txt | 27 ++++ python/cugraph/cugraph/layout/CMakeLists.txt | 25 ++++ .../cugraph/linear_assignment/CMakeLists.txt | 25 ++++ .../cugraph/link_analysis/CMakeLists.txt | 25 ++++ .../cugraph/link_prediction/CMakeLists.txt | 25 ++++ .../cugraph/cugraph/sampling/CMakeLists.txt | 25 ++++ .../cugraph/cugraph/structure/CMakeLists.txt | 25 ++++ python/cugraph/cugraph/tree/CMakeLists.txt | 25 ++++ .../cugraph/cugraph/utilities/CMakeLists.txt | 25 ++++ python/cugraph/pyproject.toml | 12 ++ python/cugraph/setup.py | 127 ++---------------- python/cugraph/setuputils.py | 20 +++ python/pylibcugraph/CMakeLists.txt | 69 ++++++++++ .../pylibcugraph/pylibcugraph/CMakeLists.txt | 42 ++++++ .../pylibcugraph/components/CMakeLists.txt | 28 ++++ .../pylibcugraph/raft/common/CMakeLists.txt | 29 ++++ .../pylibcugraph/utilities/api_tools.py | 6 +- python/pylibcugraph/pyproject.toml | 12 ++ python/pylibcugraph/setup.py | 83 ++---------- python/pylibcugraph/setuputils.py | 20 +++ 45 files changed, 901 insertions(+), 198 deletions(-) create mode 100644 fetch_rapids.cmake create mode 100644 python/cugraph/CMakeLists.txt create mode 100644 python/cugraph/cugraph/centrality/CMakeLists.txt create mode 100644 python/cugraph/cugraph/community/CMakeLists.txt create mode 100644 python/cugraph/cugraph/components/CMakeLists.txt create mode 100644 python/cugraph/cugraph/cores/CMakeLists.txt create mode 100644 python/cugraph/cugraph/dask/centrality/CMakeLists.txt create mode 100644 python/cugraph/cugraph/dask/comms/CMakeLists.txt create mode 100644 python/cugraph/cugraph/dask/community/CMakeLists.txt create mode 100644 python/cugraph/cugraph/dask/components/CMakeLists.txt create mode 100644 python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt create mode 100644 python/cugraph/cugraph/dask/structure/CMakeLists.txt create mode 100644 python/cugraph/cugraph/generators/CMakeLists.txt create mode 100644 python/cugraph/cugraph/internals/CMakeLists.txt create mode 100644 python/cugraph/cugraph/layout/CMakeLists.txt create mode 100644 python/cugraph/cugraph/linear_assignment/CMakeLists.txt create mode 100644 python/cugraph/cugraph/link_analysis/CMakeLists.txt create mode 100644 python/cugraph/cugraph/link_prediction/CMakeLists.txt create mode 100644 python/cugraph/cugraph/sampling/CMakeLists.txt create mode 100644 python/cugraph/cugraph/structure/CMakeLists.txt create mode 100644 python/cugraph/cugraph/tree/CMakeLists.txt create mode 100644 python/cugraph/cugraph/utilities/CMakeLists.txt create mode 100644 python/cugraph/pyproject.toml create mode 100644 python/pylibcugraph/CMakeLists.txt create mode 100644 python/pylibcugraph/pylibcugraph/CMakeLists.txt create mode 100644 python/pylibcugraph/pylibcugraph/components/CMakeLists.txt create mode 100644 python/pylibcugraph/pylibcugraph/raft/common/CMakeLists.txt create mode 100644 python/pylibcugraph/pyproject.toml diff --git a/.gitignore b/.gitignore index 44a1471fefc..7bd759bbc10 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,15 @@ junit-cugraph.xml test-results ## Python build directories & artifacts +dask-worker-space/ htmlcov dist/ cugraph.egg-info/ python/build python/cugraph/bindings/*.cpp +wheels/ +_skbuild/ +cufile.log ## pylibcugraph build directories & artifacts python/pylibcugraph/pylibcugraph.egg-info diff --git a/build.sh b/build.sh index 1182adfa0ef..79dd0f91dce 100755 --- a/build.sh +++ b/build.sh @@ -159,6 +159,8 @@ if hasArg clean; then pushd ${REPODIR}/python > /dev/null rm -rf dist dask-worker-space cugraph/raft *.egg-info find . -name "__pycache__" -type d -exec rm -rf {} \; > /dev/null 2>&1 + find . -type d -name _skbuild -exec rm -rf {} \; > /dev/null 2>&1 + find . -type d -name dist -exec rm -rf {} \; > /dev/null 2>&1 find . -name "*.cpp" -type f -delete find . -name "*.cpython*.so" -type f -delete find . -type d -name _external_repositories -exec rm -rf {} \; > /dev/null 2>&1 @@ -230,7 +232,8 @@ if buildAll || hasArg pylibcugraph; then # setup.py references an env var CUGRAPH_BUILD_PATH to find the libcugraph # build. If not set by the user, set it to LIBCUGRAPH_BUILD_DIR CUGRAPH_BUILD_PATH=${CUGRAPH_BUILD_PATH:=${LIBCUGRAPH_BUILD_DIR}} - env CUGRAPH_BUILD_PATH=${CUGRAPH_BUILD_PATH} python setup.py build_ext --inplace --library-dir=${LIBCUGRAPH_BUILD_DIR} + python setup.py build_ext --inplace -- -DFIND_CUGRAPH_CPP=ON \ + -Dcugraph_ROOT=${LIBCUGRAPH_BUILD_DIR} -- -j${PARALLEL_LEVEL:-1} if [[ ${INSTALL_TARGET} != "" ]]; then env CUGRAPH_BUILD_PATH=${CUGRAPH_BUILD_PATH} python setup.py install fi @@ -243,7 +246,8 @@ if buildAll || hasArg cugraph; then # setup.py references an env var CUGRAPH_BUILD_PATH to find the libcugraph # build. If not set by the user, set it to LIBCUGRAPH_BUILD_DIR CUGRAPH_BUILD_PATH=${CUGRAPH_BUILD_PATH:=${LIBCUGRAPH_BUILD_DIR}} - env CUGRAPH_BUILD_PATH=${CUGRAPH_BUILD_PATH} python setup.py build_ext --inplace --library-dir=${LIBCUGRAPH_BUILD_DIR} + python setup.py build_ext --inplace -- -DFIND_CUGRAPH_CPP=ON \ + -Dcugraph_ROOT=${LIBCUGRAPH_BUILD_DIR} -- -j${PARALLEL_LEVEL:-1} if [[ ${INSTALL_TARGET} != "" ]]; then env CUGRAPH_BUILD_PATH=${CUGRAPH_BUILD_PATH} python setup.py install fi diff --git a/ci/release/update-version.sh b/ci/release/update-version.sh index c2062a170bd..9aec4bd7a25 100755 --- a/ci/release/update-version.sh +++ b/ci/release/update-version.sh @@ -39,6 +39,9 @@ function sed_runner() { sed -i.bak ''"$1"'' $2 && rm -f ${2}.bak } +# rapids-cmake version +sed_runner 's/'"branch-.*\/RAPIDS.cmake"'/'"branch-${NEXT_SHORT_TAG}\/RAPIDS.cmake"'/g' fetch_rapids.cmake + # CMakeLists update sed_runner 's/'"CUGRAPH VERSION .* LANGUAGES C CXX CUDA)"'/'"CUGRAPH VERSION ${NEXT_FULL_TAG} LANGUAGES C CXX CUDA)"'/g' cpp/CMakeLists.txt sed_runner 's|'"branch-.*/RAPIDS.cmake"'|'"branch-${NEXT_SHORT_TAG}/RAPIDS.cmake"'|g' cpp/CMakeLists.txt diff --git a/conda/environments/cugraph_dev_cuda11.2.yml b/conda/environments/cugraph_dev_cuda11.2.yml index 29f4d4d8873..f1f4c0e4570 100644 --- a/conda/environments/cugraph_dev_cuda11.2.yml +++ b/conda/environments/cugraph_dev_cuda11.2.yml @@ -28,6 +28,7 @@ dependencies: - clang=11.1.0 - clang-tools=11.1.0 - cmake>=3.20.1,!=3.23.0 +- scikit-build>=0.13.1 - python>=3.8,<3.10 - notebook>=0.5.0 - boost diff --git a/conda/environments/cugraph_dev_cuda11.4.yml b/conda/environments/cugraph_dev_cuda11.4.yml index 3a387268ff4..fc1ca620e6d 100644 --- a/conda/environments/cugraph_dev_cuda11.4.yml +++ b/conda/environments/cugraph_dev_cuda11.4.yml @@ -28,6 +28,7 @@ dependencies: - clang=11.1.0 - clang-tools=11.1.0 - cmake>=3.20.1,!=3.23.0 +- scikit-build>=0.13.1 - python>=3.8,<3.10 - notebook>=0.5.0 - boost diff --git a/conda/environments/cugraph_dev_cuda11.5.yml b/conda/environments/cugraph_dev_cuda11.5.yml index b8a50a79596..1c901e712cd 100644 --- a/conda/environments/cugraph_dev_cuda11.5.yml +++ b/conda/environments/cugraph_dev_cuda11.5.yml @@ -28,6 +28,7 @@ dependencies: - clang=11.1.0 - clang-tools=11.1.0 - cmake>=3.20.1,!=3.23.0 +- scikit-build>=0.13.1 - python>=3.8,<3.10 - notebook>=0.5.0 - boost diff --git a/conda/recipes/cugraph/conda_build_config.yaml b/conda/recipes/cugraph/conda_build_config.yaml index 322fe6faacf..8db7dbb7923 100644 --- a/conda/recipes/cugraph/conda_build_config.yaml +++ b/conda/recipes/cugraph/conda_build_config.yaml @@ -7,5 +7,8 @@ cxx_compiler_version: cuda_compiler: - nvcc +cmake_version: + - ">=3.20.1,!=3.23.0" + sysroot_version: - "2.17" diff --git a/conda/recipes/cugraph/meta.yaml b/conda/recipes/cugraph/meta.yaml index 37ec823dc87..022618fa22a 100644 --- a/conda/recipes/cugraph/meta.yaml +++ b/conda/recipes/cugraph/meta.yaml @@ -25,6 +25,7 @@ build: requirements: build: + - cmake {{ cmake_version }} - {{ compiler('c') }} - {{ compiler('cxx') }} - {{ compiler('cuda') }} {{ cuda_version }} @@ -32,6 +33,7 @@ requirements: host: - python x.x - cython>=0.29,<0.30 + - scikit-build>=0.13.1 - libcugraph={{ version }} - libraft-headers {{ minor_version }} - pyraft {{ minor_version }} diff --git a/conda/recipes/pylibcugraph/conda_build_config.yaml b/conda/recipes/pylibcugraph/conda_build_config.yaml index 322fe6faacf..8db7dbb7923 100644 --- a/conda/recipes/pylibcugraph/conda_build_config.yaml +++ b/conda/recipes/pylibcugraph/conda_build_config.yaml @@ -7,5 +7,8 @@ cxx_compiler_version: cuda_compiler: - nvcc +cmake_version: + - ">=3.20.1,!=3.23.0" + sysroot_version: - "2.17" diff --git a/conda/recipes/pylibcugraph/meta.yaml b/conda/recipes/pylibcugraph/meta.yaml index a6dec1d50ba..4a2a178516a 100644 --- a/conda/recipes/pylibcugraph/meta.yaml +++ b/conda/recipes/pylibcugraph/meta.yaml @@ -25,6 +25,7 @@ build: requirements: build: + - cmake {{ cmake_version }} - {{ compiler('c') }} - {{ compiler('cxx') }} - {{ compiler('cuda') }} {{ cuda_version }} @@ -32,6 +33,7 @@ requirements: host: - python x.x - cython>=0.29,<0.30 + - scikit-build>=0.13.1 - libcugraph={{ version }} - ucx-py {{ ucx_py_version }} - ucx-proc=*=gpu diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 7a681c9a99f..7f2feb11cdf 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -15,10 +15,8 @@ #============================================================================= cmake_minimum_required(VERSION 3.20.1 FATAL_ERROR) -file(DOWNLOAD https://raw.githubusercontent.com/rapidsai/rapids-cmake/branch-22.08/RAPIDS.cmake - ${CMAKE_BINARY_DIR}/RAPIDS.cmake) -include(${CMAKE_BINARY_DIR}/RAPIDS.cmake) +include(../fetch_rapids.cmake) include(rapids-cmake) include(rapids-cpm) include(rapids-cuda) diff --git a/cpp/cmake/thirdparty/get_libcudacxx.cmake b/cpp/cmake/thirdparty/get_libcudacxx.cmake index 41e5998a448..1c51c5a84a9 100644 --- a/cpp/cmake/thirdparty/get_libcudacxx.cmake +++ b/cpp/cmake/thirdparty/get_libcudacxx.cmake @@ -16,8 +16,7 @@ function(find_and_configure_libcudacxx) include(${rapids-cmake-dir}/cpm/libcudacxx.cmake) - rapids_cpm_libcudacxx(BUILD_EXPORT_SET cugraph-exports - INSTALL_EXPORT_SET cugraph-exports) + rapids_cpm_libcudacxx(BUILD_EXPORT_SET cugraph-exports) endfunction() diff --git a/fetch_rapids.cmake b/fetch_rapids.cmake new file mode 100644 index 00000000000..2b5c7e9d352 --- /dev/null +++ b/fetch_rapids.cmake @@ -0,0 +1,17 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= +file(DOWNLOAD https://raw.githubusercontent.com/rapidsai/rapids-cmake/branch-22.08/RAPIDS.cmake + ${CMAKE_BINARY_DIR}/RAPIDS.cmake +) +include(${CMAKE_BINARY_DIR}/RAPIDS.cmake) diff --git a/python/cugraph/CMakeLists.txt b/python/cugraph/CMakeLists.txt new file mode 100644 index 00000000000..f90035f9460 --- /dev/null +++ b/python/cugraph/CMakeLists.txt @@ -0,0 +1,84 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +cmake_minimum_required(VERSION 3.20.1 FATAL_ERROR) + +set(cugraph_version 22.08.00) + +include(../../fetch_rapids.cmake) + +project( + cugraph-python + VERSION ${cugraph_version} + LANGUAGES # TODO: Building Python extension modules via the python_extension_module requires the C + # language to be enabled here. The test project that is built in scikit-build to verify + # various linking options for the python library is hardcoded to build with C, so until + # that is fixed we need to keep C. + C CXX +) + +################################################################################ +# - User Options -------------------------------------------------------------- +option(FIND_CUGRAPH_CPP "Search for existing CUGRAPH C++ installations before defaulting to local files" + OFF +) + +# If the user requested it, we attempt to find CUGRAPH. +if(FIND_CUGRAPH_CPP) + find_package(cugraph ${cugraph_version} REQUIRED) +else() + set(cugraph_FOUND OFF) +endif() + +if(NOT cugraph_FOUND) + # TODO: This will not be necessary once we upgrade to CMake 3.22, which will pull in the required + # languages for the C++ project even if this project does not require those languges. + include(rapids-cuda) + rapids_cuda_init_architectures(CUGRAPH) + enable_language(CUDA) + + # Since cugraph only enables CUDA optionally, we need to manually include the file that + # rapids_cuda_init_architectures relies on `project` including. + + include("${CMAKE_PROJECT_cugraph-python_INCLUDE}") + + add_subdirectory(../../cpp cugraph-cpp) + + install(TARGETS cugraph DESTINATION cugraph/library) +endif() + + +include(rapids-cython) +rapids_cython_init() + +add_subdirectory(cugraph/centrality) +add_subdirectory(cugraph/community) +add_subdirectory(cugraph/components) +add_subdirectory(cugraph/cores) +add_subdirectory(cugraph/dask/centrality) +add_subdirectory(cugraph/dask/comms) +add_subdirectory(cugraph/dask/community) +add_subdirectory(cugraph/dask/components) +add_subdirectory(cugraph/dask/link_analysis) +add_subdirectory(cugraph/dask/structure) +add_subdirectory(cugraph/generators) +add_subdirectory(cugraph/internals) +add_subdirectory(cugraph/layout) +add_subdirectory(cugraph/linear_assignment) +add_subdirectory(cugraph/link_analysis) +add_subdirectory(cugraph/link_prediction) +add_subdirectory(cugraph/sampling) +add_subdirectory(cugraph/structure) +add_subdirectory(cugraph/tree) +add_subdirectory(cugraph/utilities) diff --git a/python/cugraph/cugraph/centrality/CMakeLists.txt b/python/cugraph/cugraph/centrality/CMakeLists.txt new file mode 100644 index 00000000000..68b9c244690 --- /dev/null +++ b/python/cugraph/cugraph/centrality/CMakeLists.txt @@ -0,0 +1,29 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources + betweenness_centrality_wrapper.pyx + edge_betweenness_centrality_wrapper.pyx +) +set(linked_libraries cugraph::cugraph) + +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX centrality_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/community/CMakeLists.txt b/python/cugraph/cugraph/community/CMakeLists.txt new file mode 100644 index 00000000000..db5f9a8a3b1 --- /dev/null +++ b/python/cugraph/cugraph/community/CMakeLists.txt @@ -0,0 +1,34 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources + ecg_wrapper.pyx egonet_wrapper.pyx + ktruss_subgraph_wrapper.pyx + leiden_wrapper.pyx + louvain_wrapper.pyx + spectral_clustering_wrapper.pyx + subgraph_extraction_wrapper.pyx + triangle_count_wrapper.pyx +) + +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX community_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/components/CMakeLists.txt b/python/cugraph/cugraph/components/CMakeLists.txt new file mode 100644 index 00000000000..4a6efa3bdb1 --- /dev/null +++ b/python/cugraph/cugraph/components/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources connectivity_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX components_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/cores/CMakeLists.txt b/python/cugraph/cugraph/cores/CMakeLists.txt new file mode 100644 index 00000000000..c87a8a9c134 --- /dev/null +++ b/python/cugraph/cugraph/cores/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources k_core_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX cores_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/dask/centrality/CMakeLists.txt b/python/cugraph/cugraph/dask/centrality/CMakeLists.txt new file mode 100644 index 00000000000..035e93f33c0 --- /dev/null +++ b/python/cugraph/cugraph/dask/centrality/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources mg_katz_centrality_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX centrality_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/cugraph/cugraph/dask/comms/CMakeLists.txt b/python/cugraph/cugraph/dask/comms/CMakeLists.txt new file mode 100644 index 00000000000..2a287abed6a --- /dev/null +++ b/python/cugraph/cugraph/dask/comms/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources comms_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX comms_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/cugraph/cugraph/dask/community/CMakeLists.txt b/python/cugraph/cugraph/dask/community/CMakeLists.txt new file mode 100644 index 00000000000..be33135abfa --- /dev/null +++ b/python/cugraph/cugraph/dask/community/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources louvain_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX dask_community_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/cugraph/cugraph/dask/components/CMakeLists.txt b/python/cugraph/cugraph/dask/components/CMakeLists.txt new file mode 100644 index 00000000000..36ef1e216a2 --- /dev/null +++ b/python/cugraph/cugraph/dask/components/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources mg_connectivity_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX components_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt b/python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt new file mode 100644 index 00000000000..b204a6b6927 --- /dev/null +++ b/python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources mg_pagerank_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX link_analysis_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/cugraph/cugraph/dask/structure/CMakeLists.txt b/python/cugraph/cugraph/dask/structure/CMakeLists.txt new file mode 100644 index 00000000000..afc597cb5d6 --- /dev/null +++ b/python/cugraph/cugraph/dask/structure/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources replication.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX structure_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/cugraph/cugraph/generators/CMakeLists.txt b/python/cugraph/cugraph/generators/CMakeLists.txt new file mode 100644 index 00000000000..6edf6acc903 --- /dev/null +++ b/python/cugraph/cugraph/generators/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources rmat_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX generators_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/internals/CMakeLists.txt b/python/cugraph/cugraph/internals/CMakeLists.txt new file mode 100644 index 00000000000..461a96615a7 --- /dev/null +++ b/python/cugraph/cugraph/internals/CMakeLists.txt @@ -0,0 +1,27 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources internals.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX internals_ +) + +target_include_directories(internals_internals PRIVATE "${CMAKE_CURRENT_LIST_DIR}") + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/layout/CMakeLists.txt b/python/cugraph/cugraph/layout/CMakeLists.txt new file mode 100644 index 00000000000..96f425cc1ed --- /dev/null +++ b/python/cugraph/cugraph/layout/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources force_atlas2_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX layout_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/linear_assignment/CMakeLists.txt b/python/cugraph/cugraph/linear_assignment/CMakeLists.txt new file mode 100644 index 00000000000..618c04d1f0a --- /dev/null +++ b/python/cugraph/cugraph/linear_assignment/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources lap_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX linear_assignment_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/link_analysis/CMakeLists.txt b/python/cugraph/cugraph/link_analysis/CMakeLists.txt new file mode 100644 index 00000000000..30dbe239ea9 --- /dev/null +++ b/python/cugraph/cugraph/link_analysis/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources pagerank_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX link_analysis_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/link_prediction/CMakeLists.txt b/python/cugraph/cugraph/link_prediction/CMakeLists.txt new file mode 100644 index 00000000000..6b5931775a3 --- /dev/null +++ b/python/cugraph/cugraph/link_prediction/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources jaccard_wrapper.pyx overlap_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX link_prediction_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/sampling/CMakeLists.txt b/python/cugraph/cugraph/sampling/CMakeLists.txt new file mode 100644 index 00000000000..bb2cee3a6ec --- /dev/null +++ b/python/cugraph/cugraph/sampling/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources random_walks_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX sampling_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/structure/CMakeLists.txt b/python/cugraph/cugraph/structure/CMakeLists.txt new file mode 100644 index 00000000000..8dbd9e29c71 --- /dev/null +++ b/python/cugraph/cugraph/structure/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources graph_primtypes_wrapper.pyx graph_primtypes.pyx renumber_wrapper.pyx utils_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX structure_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/tree/CMakeLists.txt b/python/cugraph/cugraph/tree/CMakeLists.txt new file mode 100644 index 00000000000..389a1825117 --- /dev/null +++ b/python/cugraph/cugraph/tree/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources minimum_spanning_tree_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX tree_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/cugraph/utilities/CMakeLists.txt b/python/cugraph/cugraph/utilities/CMakeLists.txt new file mode 100644 index 00000000000..b4e5a7195ee --- /dev/null +++ b/python/cugraph/cugraph/utilities/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources path_retrieval_wrapper.pyx) +set(linked_libraries cugraph::cugraph) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX utilities_ +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/cugraph/pyproject.toml b/python/cugraph/pyproject.toml new file mode 100644 index 00000000000..ac4538c41f7 --- /dev/null +++ b/python/cugraph/pyproject.toml @@ -0,0 +1,12 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. + +[build-system] + +requires = [ + "wheel", + "setuptools", + "cython>=0.29,<0.30", + "scikit-build>=0.13.1", + "cmake>=3.20.1,!=3.23.0", + "ninja", +] diff --git a/python/cugraph/setup.py b/python/cugraph/setup.py index c2d994969c0..ec50090cd43 100644 --- a/python/cugraph/setup.py +++ b/python/cugraph/setup.py @@ -12,26 +12,10 @@ # limitations under the License. import os -import sys -import sysconfig import shutil -# Must import in this order: -# setuptools -> Cython.Distutils.build_ext -> setuptools.command.build_ext -# Otherwise, setuptools.command.build_ext ends up inheriting from -# Cython.Distutils.old_build_ext which we do not want -import setuptools - -try: - from Cython.Distutils.build_ext import new_build_ext as _build_ext -except ImportError: - from setuptools.command.build_ext import build_ext as _build_ext - -from distutils.sysconfig import get_python_lib - -import setuptools.command.build_ext -from setuptools import find_packages, setup, Command -from setuptools.extension import Extension +from setuptools import find_packages, Command +from skbuild import setup from setuputils import get_environment_option @@ -39,24 +23,8 @@ INSTALL_REQUIRES = ['numba', 'cython'] -CYTHON_FILES = ['cugraph/**/*.pyx'] -UCX_HOME = get_environment_option("UCX_HOME") CUDA_HOME = get_environment_option('CUDA_HOME') -CONDA_PREFIX = get_environment_option('CONDA_PREFIX') - -conda_lib_dir = os.path.normpath(sys.prefix) + '/lib' -conda_include_dir = os.path.normpath(sys.prefix) + '/include' - -if CONDA_PREFIX: - conda_include_dir = CONDA_PREFIX + '/include' - conda_lib_dir = CONDA_PREFIX + '/lib' - -if not UCX_HOME: - UCX_HOME = CONDA_PREFIX if CONDA_PREFIX else os.sys.prefix - -ucx_include_dir = os.path.join(UCX_HOME, "include") -ucx_lib_dir = os.path.join(UCX_HOME, "lib") if not CUDA_HOME: path_to_cuda_gdb = shutil.which("cuda-gdb") @@ -74,40 +42,6 @@ "Invalid CUDA_HOME: " "directory does not exist: {CUDA_HOME}" ) -cuda_include_dir = os.path.join(CUDA_HOME, "include") -cuda_lib_dir = os.path.join(CUDA_HOME, "lib64") - -# Optional location of C++ build folder that can be configured by the user -libcugraph_path = get_environment_option('CUGRAPH_BUILD_PATH') - -if not libcugraph_path: - libcugraph_path = conda_lib_dir - -extensions = [ - Extension("*", - sources=CYTHON_FILES, - include_dirs=[ - conda_include_dir, - ucx_include_dir, - '../cpp/include', - "../thirdparty/cub", - os.path.join(conda_include_dir, "libcudacxx"), - cuda_include_dir, - os.path.dirname(sysconfig.get_path("include")) - ], - library_dirs=[ - get_python_lib(), - conda_lib_dir, - libcugraph_path, - ucx_lib_dir, - cuda_lib_dir, - os.path.join(os.sys.prefix, "lib") - ], - libraries=['cudart', 'cusparse', 'cusolver', 'cugraph', 'nccl'], - language='c++', - extra_compile_args=['-std=c++17']) -] - class CleanCommand(Command): """Custom clean command to tidy up the project root.""" @@ -129,51 +63,18 @@ def run(self): os.system('rm -rf *.egg-info') os.system('find . -name "*.cpp" -type f -delete') os.system('find . -name "*.cpython*.so" -type f -delete') + os.system('rm -rf _skbuild') -class build_ext_no_debug(_build_ext): - - def build_extensions(self): - def remove_flags(compiler, *flags): - for flag in flags: - try: - compiler.compiler_so = list( - filter((flag).__ne__, compiler.compiler_so) - ) - except Exception: - pass - # Full optimization - self.compiler.compiler_so.append("-O3") - # No debug symbols, full optimization, no '-Wstrict-prototypes' warning - remove_flags( - self.compiler, "-g", "-G", "-O1", "-O2", "-Wstrict-prototypes" - ) - super().build_extensions() - - def finalize_options(self): - if self.distribution.ext_modules: - # Delay import this to allow for Cython-less installs - from Cython.Build.Dependencies import cythonize - - nthreads = getattr(self, "parallel", None) # -j option in Py3.5+ - nthreads = int(nthreads) if nthreads else None - self.distribution.ext_modules = cythonize( - self.distribution.ext_modules, - nthreads=nthreads, - force=self.force, - gdb_debug=False, - compiler_directives=dict( - profile=False, language_level=3, embedsignature=True - ), - ) - # Skip calling super() and jump straight to setuptools - setuptools.command.build_ext.build_ext.finalize_options(self) - - -cmdclass = dict() -cmdclass.update(versioneer.get_cmdclass()) -cmdclass["build_ext"] = build_ext_no_debug +cmdclass = versioneer.get_cmdclass() cmdclass["clean"] = CleanCommand +PACKAGE_DATA = { + key: ["*.pxd"] for key in find_packages(include=["cugraph*"])} + +PACKAGE_DATA['cugraph.experimental.datasets'].extend( + ['cugraph/experimental/datasets/metadata/*.yaml', + 'cugraph/experimental/datasets/*.yaml']) + setup(name='cugraph', description="cuGraph - RAPIDS GPU Graph Analytics", @@ -189,13 +90,9 @@ def finalize_options(self): # Include the separately-compiled shared library author="NVIDIA Corporation", setup_requires=['Cython>=0.29,<0.30'], - ext_modules=extensions, packages=find_packages(include=['cugraph', 'cugraph.*']), + package_data=PACKAGE_DATA, include_package_data=True, - package_data={ - '': ['python/cugraph/cugraph/experimental/datasets/metadata/*.yaml', - 'python/cugraph/cugraph/experimental/datasets/*.yaml'], - }, install_requires=INSTALL_REQUIRES, license="Apache", cmdclass=cmdclass, diff --git a/python/cugraph/setuputils.py b/python/cugraph/setuputils.py index 09ae5dbd31f..af3ea1e83ef 100644 --- a/python/cugraph/setuputils.py +++ b/python/cugraph/setuputils.py @@ -245,3 +245,23 @@ def get_repo_cmake_info(names, file_path): def _get_repo_path(): python_dir = Path(__file__).resolve().parent return str(python_dir.parent.parent.absolute()) + + +def get_cuda_version_from_header(cuda_include_dir, delimiter=""): + + cuda_version = None + + with open(os.path.join(cuda_include_dir, "cuda.h"), encoding="utf-8") as f: + for line in f.readlines(): + if re.search(r"#define CUDA_VERSION ", line) is not None: + cuda_version = line + break + + if cuda_version is None: + raise TypeError("CUDA_VERSION not found in cuda.h") + cuda_version = int(cuda_version.split()[2]) + return "%d%s%d" % ( + cuda_version // 1000, + delimiter, + (cuda_version % 1000) // 10, + ) diff --git a/python/pylibcugraph/CMakeLists.txt b/python/pylibcugraph/CMakeLists.txt new file mode 100644 index 00000000000..030da9c3e38 --- /dev/null +++ b/python/pylibcugraph/CMakeLists.txt @@ -0,0 +1,69 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +cmake_minimum_required(VERSION 3.20.1 FATAL_ERROR) + +set(pylibcugraph_version 22.08.00) + +include(../../fetch_rapids.cmake) + +project( + pylibcugraph-python + VERSION ${pylibcugraph_version} + LANGUAGES # TODO: Building Python extension modules via the python_extension_module requires the C + # language to be enabled here. The test project that is built in scikit-build to verify + # various linking options for the python library is hardcoded to build with C, so until + # that is fixed we need to keep C. + C CXX +) + +################################################################################ +# - User Options -------------------------------------------------------------- +option(FIND_CUGRAPH_CPP "Search for existing CUGRAPH C++ installations before defaulting to local files" + OFF +) + +# If the user requested it we attempt to find CUGRAPH. + +if(FIND_CUGRAPH_CPP) + message(STATUS "Trying to find the package") + find_package(cugraph ${cugraph_version} REQUIRED) +else() + set(cugraph_FOUND OFF) +endif() + +message(STATUS "check if it was found ${cugraph_FOUND}") + +if(NOT cugraph_FOUND) + # TODO: This will not be necessary once we upgrade to CMake 3.22, which will pull in the required + # languages for the C++ project even if this project does not require those languges. + include(rapids-cuda) + rapids_cuda_init_architectures(CUGRAPH) + enable_language(CUDA) + + # Since cugraph only enables CUDA optionally, we need to manually include the file that + # rapids_cuda_init_architectures relies on `project` including. + + include("${CMAKE_PROJECT_cugraph-python_INCLUDE}") + + add_subdirectory(../../cpp cugraph-cpp) + + install(TARGETS cugraph DESTINATION pylibcugraph/library) +endif() + + +include(rapids-cython) +rapids_cython_init() + +add_subdirectory(pylibcugraph) diff --git a/python/pylibcugraph/pylibcugraph/CMakeLists.txt b/python/pylibcugraph/pylibcugraph/CMakeLists.txt new file mode 100644 index 00000000000..c5ae32a0b2a --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/CMakeLists.txt @@ -0,0 +1,42 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= +add_subdirectory(components) +add_subdirectory(raft/common) +set(cython_sources + bfs.pyx + core_number.pyx + eigenvector_centrality.pyx + graph_properties.pyx + graphs.pyx + hits.pyx + katz_centrality.pyx + node2vec.pyx + pagerank.pyx + resource_handle.pyx + sssp.pyx + triangle_count.pyx + uniform_neighbor_sample.pyx + utils.pyx +) +set(linked_libraries cugraph::cugraph;cugraph_c) + +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES ${linked_libraries} +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/library") +endforeach() diff --git a/python/pylibcugraph/pylibcugraph/components/CMakeLists.txt b/python/pylibcugraph/pylibcugraph/components/CMakeLists.txt new file mode 100644 index 00000000000..8f156c0e6d2 --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/components/CMakeLists.txt @@ -0,0 +1,28 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources + _connectivity.pyx +) +set(linked_libraries cugraph::cugraph) + +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") +endforeach() diff --git a/python/pylibcugraph/pylibcugraph/raft/common/CMakeLists.txt b/python/pylibcugraph/pylibcugraph/raft/common/CMakeLists.txt new file mode 100644 index 00000000000..18d5e59c664 --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/raft/common/CMakeLists.txt @@ -0,0 +1,29 @@ +# ============================================================================= +# Copyright (c) 2022, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources + cuda.pyx + handle.pyx +) +set(linked_libraries cugraph::cugraph) + +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" +) + +foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) + set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") +endforeach() diff --git a/python/pylibcugraph/pylibcugraph/utilities/api_tools.py b/python/pylibcugraph/pylibcugraph/utilities/api_tools.py index 0cee609c730..dfb646a0784 100644 --- a/python/pylibcugraph/pylibcugraph/utilities/api_tools.py +++ b/python/pylibcugraph/pylibcugraph/utilities/api_tools.py @@ -33,7 +33,7 @@ def experimental_warning_wrapper(obj): discovered and used. """ obj_type = type(obj) - if obj_type not in [type, types.FunctionType, types.BuiltinFunctionType]: + if not callable(obj): raise TypeError("obj must be a class or a function type, got " f"{obj_type}") @@ -102,7 +102,7 @@ def promoted_experimental_warning_wrapper(obj): have the experimental namespace. """ obj_type = type(obj) - if obj_type not in [type, types.FunctionType, types.BuiltinFunctionType]: + if not callable(obj): raise TypeError("obj must be a class or a function type, got " f"{obj_type}") @@ -154,7 +154,7 @@ def deprecated_warning_wrapper(obj): by a refactored version), prior to calling obj and returning its value. """ obj_type = type(obj) - if obj_type not in [type, types.FunctionType, types.BuiltinFunctionType]: + if not callable(obj): raise TypeError("obj must be a class or a function type, got " f"{obj_type}") diff --git a/python/pylibcugraph/pyproject.toml b/python/pylibcugraph/pyproject.toml new file mode 100644 index 00000000000..ac4538c41f7 --- /dev/null +++ b/python/pylibcugraph/pyproject.toml @@ -0,0 +1,12 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. + +[build-system] + +requires = [ + "wheel", + "setuptools", + "cython>=0.29,<0.30", + "scikit-build>=0.13.1", + "cmake>=3.20.1,!=3.23.0", + "ninja", +] diff --git a/python/pylibcugraph/setup.py b/python/pylibcugraph/setup.py index 40bce1c3b09..8ea4337407b 100644 --- a/python/pylibcugraph/setup.py +++ b/python/pylibcugraph/setup.py @@ -12,41 +12,16 @@ # limitations under the License. import os -import sys -import sysconfig import shutil -from setuptools import setup, find_packages, Command -from setuptools.extension import Extension -from setuputils import get_environment_option +from setuptools import find_packages, Command +from skbuild import setup -try: - from Cython.Distutils.build_ext import new_build_ext as build_ext -except ImportError: - from setuptools.command.build_ext import build_ext +from setuputils import get_environment_option import versioneer -from distutils.sysconfig import get_python_lib - - -CYTHON_FILES = ['pylibcugraph/**/*.pyx'] -UCX_HOME = get_environment_option("UCX_HOME") CUDA_HOME = get_environment_option('CUDA_HOME') -CONDA_PREFIX = get_environment_option('CONDA_PREFIX') - -conda_lib_dir = os.path.normpath(sys.prefix) + '/lib' -conda_include_dir = os.path.normpath(sys.prefix) + '/include' - -if CONDA_PREFIX: - conda_include_dir = CONDA_PREFIX + '/include' - conda_lib_dir = CONDA_PREFIX + '/lib' - -if not UCX_HOME: - UCX_HOME = CONDA_PREFIX if CONDA_PREFIX else os.sys.prefix - -ucx_include_dir = os.path.join(UCX_HOME, "include") -ucx_lib_dir = os.path.join(UCX_HOME, "lib") if not CUDA_HOME: path_to_cuda_gdb = shutil.which("cuda-gdb") @@ -64,15 +39,6 @@ "Invalid CUDA_HOME: " "directory does not exist: {CUDA_HOME}" ) -cuda_include_dir = os.path.join(CUDA_HOME, "include") -cuda_lib_dir = os.path.join(CUDA_HOME, "lib64") - -# Optional location of C++ build folder that can be configured by the user -libcugraph_path = get_environment_option('CUGRAPH_BUILD_PATH') - -if not libcugraph_path: - libcugraph_path = conda_lib_dir - class CleanCommand(Command): """Custom clean command to tidy up the project root.""" @@ -94,46 +60,15 @@ def run(self): os.system('rm -rf *.egg-info') os.system('find . -name "*.cpp" -type f -delete') os.system('find . -name "*.cpython*.so" -type f -delete') + os.system('rm -rf _skbuild') -cmdclass = dict() +cmdclass = versioneer.get_cmdclass() cmdclass.update(versioneer.get_cmdclass()) -cmdclass["build_ext"] = build_ext cmdclass["clean"] = CleanCommand -EXTENSIONS = [ - Extension("*", - sources=CYTHON_FILES, - include_dirs=[ - conda_include_dir, - ucx_include_dir, - "../../cpp/include", - "../../thirdparty/cub", - os.path.join(conda_include_dir, "libcudacxx"), - cuda_include_dir, - os.path.dirname(sysconfig.get_path("include")) - ], - library_dirs=[ - get_python_lib(), - conda_lib_dir, - libcugraph_path, - ucx_lib_dir, - cuda_lib_dir, - os.path.join(os.sys.prefix, "lib") - ], - libraries=['cudart', 'cusparse', 'cusolver', 'cugraph', 'nccl', - 'cugraph_c', 'cublas'], - language='c++', - extra_compile_args=['-std=c++17']) -] - -for e in EXTENSIONS: - e.cython_directives = dict( - profile=False, language_level=3, embedsignature=True - ) - setup(name='pylibcugraph', - description="pylibcugraph - GPU Graph Analytics", + description="pylibcuGraph - RAPIDS GPU Graph Analytics", version=versioneer.get_version(), classifiers=[ # "Development Status :: 4 - Beta", @@ -145,9 +80,11 @@ def run(self): ], # Include the separately-compiled shared library author="NVIDIA Corporation", - setup_requires=['cython'], - ext_modules=EXTENSIONS, + setup_requires=['Cython>=0.29,<0.30'], packages=find_packages(include=['pylibcugraph', 'pylibcugraph.*']), + package_data={ + key: ["*.pxd"] for key in find_packages(include=["pylibcugraph*"]) + }, license="Apache", cmdclass=cmdclass, zip_safe=False) diff --git a/python/pylibcugraph/setuputils.py b/python/pylibcugraph/setuputils.py index d2251a80af7..a808165f432 100644 --- a/python/pylibcugraph/setuputils.py +++ b/python/pylibcugraph/setuputils.py @@ -245,3 +245,23 @@ def get_repo_cmake_info(names, file_path): def _get_repo_path(): python_dir = Path(__file__).resolve().parent return str(python_dir.parent.parent.absolute()) + + +def get_cuda_version_from_header(cuda_include_dir, delimiter=""): + + cuda_version = None + + with open(os.path.join(cuda_include_dir, "cuda.h"), encoding="utf-8") as f: + for line in f.readlines(): + if re.search(r"#define CUDA_VERSION ", line) is not None: + cuda_version = line + break + + if cuda_version is None: + raise TypeError("CUDA_VERSION not found in cuda.h") + cuda_version = int(cuda_version.split()[2]) + return "%d%s%d" % ( + cuda_version // 1000, + delimiter, + (cuda_version % 1000) // 10, + ) From 6b62002dabdbe4176858cf33fcbe14eec33b5e39 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 29 Jul 2022 08:20:37 -0500 Subject: [PATCH 02/19] Add get_num_vertices and get_num_edges methods to PropertyGraph. (#2434) Closes #2422. I'll add this to MGPG when we finalize the API and behavior. Authors: - Erik Welch (https://github.com/eriknw) Approvers: - Alex Barghi (https://github.com/alexbarghi-nv) - Brad Rees (https://github.com/BradReesWork) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2434 --- .../dask/structure/mg_property_graph.py | 176 +++++++++++++---- python/cugraph/cugraph/gnn/graph_store.py | 14 +- .../cugraph/structure/property_graph.py | 177 ++++++++++++++---- .../tests/mg/test_mg_property_graph.py | 14 +- .../cugraph/cugraph/tests/test_graph_store.py | 4 +- .../cugraph/tests/test_property_graph.py | 155 ++++++++++++--- 6 files changed, 415 insertions(+), 125 deletions(-) diff --git a/python/cugraph/cugraph/dask/structure/mg_property_graph.py b/python/cugraph/cugraph/dask/structure/mg_property_graph.py index 5b064a49c04..7399d818d23 100644 --- a/python/cugraph/cugraph/dask/structure/mg_property_graph.py +++ b/python/cugraph/cugraph/dask/structure/mg_property_graph.py @@ -52,7 +52,6 @@ class EXPERIMENTAL__MGPropertyGraph: Graphs from individual property selections and used later to annotate graph algorithm results with corresponding properties. """ - # column name constants used in internal DataFrames vertex_col_name = "_VERTEX_" src_col_name = "_SRC_" @@ -61,6 +60,7 @@ class EXPERIMENTAL__MGPropertyGraph: edge_id_col_name = "_EDGE_ID_" vertex_id_col_name = "_VERTEX_ID_" weight_col_name = "_WEIGHT_" + _default_type_name = "" def __init__(self, num_workers=None): # The dataframe containing the properties for each vertex. @@ -126,7 +126,8 @@ def __init__(self, num_workers=None): # Cached property values self.__num_vertices = None - self.__num_vertices_with_properties = None + self.__vertex_type_value_counts = None + self.__edge_type_value_counts = None # number of gpu's to use if num_workers is None: @@ -134,37 +135,7 @@ def __init__(self, num_workers=None): else: self.__num_workers = num_workers - @property - def num_vertices(self): - if self.__num_vertices is not None: - return self.__num_vertices - self.__num_vertices = 0 - vert_sers = self.__get_all_vertices_series() - if vert_sers: - if self.__series_type is dask_cudf.Series: - vert_count = dask_cudf.concat(vert_sers).nunique() - self.__num_vertices = vert_count.compute() - return self.__num_vertices - - @property - def num_vertices_with_properties(self): - if self.__num_vertices_with_properties is not None: - return self.__num_vertices_with_properties - - if self.__vertex_prop_dataframe is not None: - self.__num_vertices_with_properties = \ - len(self.__vertex_prop_dataframe) - return self.__num_vertices_with_properties - - return 0 - - @property - def num_edges(self): - if self.__edge_prop_dataframe is not None: - return len(self.__edge_prop_dataframe) - else: - return 0 - + # PropertyGraph read-only attributes @property def edges(self): if self.__edge_prop_dataframe is not None: @@ -195,6 +166,33 @@ def edge_property_names(self): return props return [] + @property + def vertex_types(self): + """The set of vertex type names""" + value_counts = self._vertex_type_value_counts + if value_counts is None: + names = set() + elif self.__series_type is dask_cudf.Series: + names = set(value_counts.index.to_arrow().to_pylist()) + else: + names = set(value_counts.index) + default = self._default_type_name + if default not in names and self.get_num_vertices(default) > 0: + # include "" from vertices that only exist in edge data + names.add(default) + return names + + @property + def edge_types(self): + """The set of edge type names""" + value_counts = self._edge_type_value_counts + if value_counts is None: + return set() + elif self.__series_type is dask_cudf.Series: + return set(value_counts.index.to_arrow().to_pylist()) + else: + return set(value_counts.index) + # PropertyGraph read-only attributes for debugging @property def _vertex_prop_dataframe(self): @@ -204,6 +202,104 @@ def _vertex_prop_dataframe(self): def _edge_prop_dataframe(self): return self.__edge_prop_dataframe + @property + def _vertex_type_value_counts(self): + """A Series of the counts of types in __vertex_prop_dataframe""" + if self.__vertex_prop_dataframe is None: + return + if self.__vertex_type_value_counts is None: + # Types should all be strings; what should we do if we see NaN? + self.__vertex_type_value_counts = ( + self.__vertex_prop_dataframe[self.type_col_name] + .value_counts(sort=False, dropna=False) + .compute() + ) + return self.__vertex_type_value_counts + + @property + def _edge_type_value_counts(self): + """A Series of the counts of types in __edge_prop_dataframe""" + if self.__edge_prop_dataframe is None: + return + if self.__edge_type_value_counts is None: + # Types should all be strings; what should we do if we see NaN? + self.__edge_type_value_counts = ( + self.__edge_prop_dataframe[self.type_col_name] + .value_counts(sort=False, dropna=False) + .compute() + ) + return self.__edge_type_value_counts + + def get_num_vertices(self, type=None, *, include_edge_data=True): + """Return the number of all vertices or vertices of a given type. + + Parameters + ---------- + type : string, optional + If type is None (the default), return the total number of vertices, + otherwise return the number of vertices of the specified type. + include_edge_data : bool (default True) + If True, include vertices that were added in vertex and edge data. + If False, only include vertices that were added in vertex data. + Note that vertices that only exist in edge data are assumed to have + the default type. + + See Also + -------- + PropertyGraph.get_num_edges + """ + if type is None: + if not include_edge_data: + if self.__vertex_prop_dataframe is None: + return 0 + return len(self.__vertex_prop_dataframe) + if self.__num_vertices is not None: + return self.__num_vertices + self.__num_vertices = 0 + vert_sers = self.__get_all_vertices_series() + if vert_sers: + if self.__series_type is dask_cudf.Series: + vert_count = dask_cudf.concat(vert_sers).nunique() + self.__num_vertices = vert_count.compute() + return self.__num_vertices + + value_counts = self._vertex_type_value_counts + if type == self._default_type_name and include_edge_data: + # The default type, "", can refer to both vertex and edge data + if self.__vertex_prop_dataframe is None: + return self.get_num_vertices() + return ( + self.get_num_vertices() + - len(self.__vertex_prop_dataframe) + + (value_counts[type] if type in value_counts else 0) + ) + if self.__vertex_prop_dataframe is None: + return 0 + return value_counts[type] if type in value_counts else 0 + + def get_num_edges(self, type=None): + """Return the number of all edges or edges of a given type. + + Parameters + ---------- + type : string, optional + If type is None (the default), return the total number of edges, + otherwise return the number of edges of the specified type. + + See Also + -------- + PropertyGraph.get_num_vertices + """ + if type is None: + if self.__edge_prop_dataframe is not None: + return len(self.__edge_prop_dataframe) + else: + return 0 + if self.__edge_prop_dataframe is None: + return 0 + value_counts = self._edge_type_value_counts + return value_counts[type] if type in value_counts else 0 + def get_vertices(self, selection=None): """ Return a Series containing the unique vertex IDs contained in both @@ -243,7 +339,7 @@ def add_vertex_data(self, The name to be assigned to the type of property being added. For example, if dataframe contains data about users, type_name might be "users". If not specified, the type of properties will be added as - None or NA + the empty string, "". property_columns : list of strings List of column names in dataframe to be added as properties. All other columns in dataframe will be ignored. If not specified, all @@ -265,6 +361,8 @@ def add_vertex_data(self, if (type_name is not None) and not(isinstance(type_name, str)): raise TypeError("type_name must be a string, got: " f"{type(type_name)}") + if type_name is None: + type_name = self._default_type_name if property_columns: if type(property_columns) is not list: raise TypeError("property_columns must be a list, got: " @@ -279,7 +377,7 @@ def add_vertex_data(self, # Clear the cached values related to the number of vertices since more # could be added in this method. self.__num_vertices = None - self.__num_vertices_with_properties = None + self.__vertex_type_value_counts = None # Could update instead # Initialize the __vertex_prop_dataframe if necessary using the same # type as the incoming dataframe. @@ -352,7 +450,7 @@ def add_edge_data(self, The name to be assigned to the type of property being added. For example, if dataframe contains data about transactions, type_name might be "transactions". If not specified, the type of properties - will be added as None or NA + will be added as the empty string "". property_columns : list of strings List of column names in dataframe to be added as properties. All other columns in dataframe will be ignored. If not specified, all @@ -378,6 +476,8 @@ def add_edge_data(self, if (type_name is not None) and not(isinstance(type_name, str)): raise TypeError("type_name must be a string, got: " f"{type(type_name)}") + if type_name is None: + type_name = self._default_type_name if property_columns: if type(property_columns) is not list: raise TypeError("property_columns must be a list, got: " @@ -390,8 +490,9 @@ def add_edge_data(self, f"{list(invalid_columns)}") # Clear the cached value for num_vertices since more could be added in - # this method. This method cannot affect num_vertices_with_properties + # this method. This method cannot affect __node_type_value_counts self.__num_vertices = None + self.__edge_type_value_counts = None # Could update instead default_edge_columns = [self.src_col_name, self.dst_col_name, @@ -521,6 +622,7 @@ def extract_subgraph(self, add_edge_data : bool (default is True) If True, add meta data about the edges contained in the extracted graph which are required for future calls to annotate_dataframe(). + Returns ------- A Graph instance of the same type as create_using containing only the diff --git a/python/cugraph/cugraph/gnn/graph_store.py b/python/cugraph/cugraph/gnn/graph_store.py index 7e77ffcf594..ed78e81d204 100644 --- a/python/cugraph/cugraph/gnn/graph_store.py +++ b/python/cugraph/cugraph/gnn/graph_store.py @@ -117,18 +117,10 @@ def get_edge_storage(self, key, etype=None): ) def num_nodes(self, ntype=None): - if ntype is not None: - s = self.gdata._vertex_prop_dataframe[type_n] == ntype - return s.sum() - else: - return self.gdata.num_vertices + return self.gdata.get_num_vertices(ntype) def num_edges(self, etype=None): - if etype is not None: - s = self.gdata._edge_prop_dataframe[type_n] == etype - return s.sum() - else: - return self.gdata.num_edges + return self.gdata.get_num_edges(etype) @property def ntypes(self): @@ -165,7 +157,7 @@ def gdata(self): ###################################### @property def num_vertices(self): - return self.gdata.num_vertices + return self.gdata.get_num_vertices() def get_vertex_ids(self): return self.gdata.vertices_ids() diff --git a/python/cugraph/cugraph/structure/property_graph.py b/python/cugraph/cugraph/structure/property_graph.py index f5d2cac8823..6137b6952a0 100644 --- a/python/cugraph/cugraph/structure/property_graph.py +++ b/python/cugraph/cugraph/structure/property_graph.py @@ -27,7 +27,7 @@ class EXPERIMENTAL__PropertySelection: """ Instances of this class are returned from the PropertyGraph.select_*() methods and can be used by the PropertyGraph.extract_subgraph() method to - extrac a Graph containing vertices and edges with only the selected + extract a Graph containing vertices and edges with only the selected properties. """ def __init__(self, @@ -65,6 +65,7 @@ class EXPERIMENTAL__PropertyGraph: edge_id_col_name = "_EDGE_ID_" vertex_id_col_name = "_VERTEX_ID_" weight_col_name = "_WEIGHT_" + _default_type_name = "" def __init__(self): # The dataframe containing the properties for each vertex. @@ -135,43 +136,10 @@ def __init__(self): # Cached property values self.__num_vertices = None - self.__num_vertices_with_properties = None + self.__vertex_type_value_counts = None + self.__edge_type_value_counts = None # PropertyGraph read-only attributes - @property - def num_vertices(self): - if self.__num_vertices is not None: - return self.__num_vertices - - self.__num_vertices = 0 - vert_sers = self.__get_all_vertices_series() - if vert_sers: - if self.__series_type is cudf.Series: - self.__num_vertices = cudf.concat(vert_sers).nunique() - else: - self.__num_vertices = pd.concat(vert_sers).nunique() - - return self.__num_vertices - - @property - def num_vertices_with_properties(self): - if self.__num_vertices_with_properties is not None: - return self.__num_vertices_with_properties - - if self.__vertex_prop_dataframe is not None: - self.__num_vertices_with_properties = \ - len(self.__vertex_prop_dataframe) - return self.__num_vertices_with_properties - - return 0 - - @property - def num_edges(self): - if self.__edge_prop_dataframe is not None: - return len(self.__edge_prop_dataframe) - else: - return 0 - @property def edges(self): if self.__edge_prop_dataframe is not None: @@ -201,6 +169,33 @@ def edge_property_names(self): return props return [] + @property + def vertex_types(self): + """The set of vertex type names""" + value_counts = self._vertex_type_value_counts + if value_counts is None: + names = set() + elif self.__series_type is cudf.Series: + names = set(value_counts.index.to_arrow().to_pylist()) + else: + names = set(value_counts.index) + default = self._default_type_name + if default not in names and self.get_num_vertices(default) > 0: + # include "" from vertices that only exist in edge data + names.add(default) + return names + + @property + def edge_types(self): + """The set of edge type names""" + value_counts = self._edge_type_value_counts + if value_counts is None: + return set() + elif self.__series_type is cudf.Series: + return set(value_counts.index.to_arrow().to_pylist()) + else: + return set(value_counts.index) + # PropertyGraph read-only attributes for debugging @property def _vertex_prop_dataframe(self): @@ -210,6 +205,102 @@ def _vertex_prop_dataframe(self): def _edge_prop_dataframe(self): return self.__edge_prop_dataframe + @property + def _vertex_type_value_counts(self): + """A Series of the counts of types in __vertex_prop_dataframe""" + if self.__vertex_prop_dataframe is None: + return + if self.__vertex_type_value_counts is None: + # Types should all be strings; what should we do if we see NaN? + self.__vertex_type_value_counts = ( + self.__vertex_prop_dataframe[self.type_col_name] + .value_counts(sort=False, dropna=False) + ) + return self.__vertex_type_value_counts + + @property + def _edge_type_value_counts(self): + """A Series of the counts of types in __edge_prop_dataframe""" + if self.__edge_prop_dataframe is None: + return + if self.__edge_type_value_counts is None: + # Types should all be strings; what should we do if we see NaN? + self.__edge_type_value_counts = ( + self.__edge_prop_dataframe[self.type_col_name] + .value_counts(sort=False, dropna=False) + ) + return self.__edge_type_value_counts + + def get_num_vertices(self, type=None, *, include_edge_data=True): + """Return the number of all vertices or vertices of a given type. + + Parameters + ---------- + type : string, optional + If type is None (the default), return the total number of vertices, + otherwise return the number of vertices of the specified type. + include_edge_data : bool (default True) + If True, include vertices that were added in vertex and edge data. + If False, only include vertices that were added in vertex data. + Note that vertices that only exist in edge data are assumed to have + the default type. + + See Also + -------- + PropertyGraph.get_num_edges + """ + if type is None: + if not include_edge_data: + if self.__vertex_prop_dataframe is None: + return 0 + return len(self.__vertex_prop_dataframe) + if self.__num_vertices is not None: + return self.__num_vertices + self.__num_vertices = 0 + vert_sers = self.__get_all_vertices_series() + if vert_sers: + if self.__series_type is cudf.Series: + self.__num_vertices = cudf.concat(vert_sers).nunique() + else: + self.__num_vertices = pd.concat(vert_sers).nunique() + return self.__num_vertices + value_counts = self._vertex_type_value_counts + if type == self._default_type_name and include_edge_data: + # The default type, "", can refer to both vertex and edge data + if self.__vertex_prop_dataframe is None: + return self.get_num_vertices() + return ( + self.get_num_vertices() + - len(self.__vertex_prop_dataframe) + + (value_counts[type] if type in value_counts else 0) + ) + if self.__vertex_prop_dataframe is None: + return 0 + return value_counts[type] if type in value_counts else 0 + + def get_num_edges(self, type=None): + """Return the number of all edges or edges of a given type. + + Parameters + ---------- + type : string, optional + If type is None (the default), return the total number of edges, + otherwise return the number of edges of the specified type. + + See Also + -------- + PropertyGraph.get_num_vertices + """ + if type is None: + if self.__edge_prop_dataframe is not None: + return len(self.__edge_prop_dataframe) + else: + return 0 + if self.__edge_prop_dataframe is None: + return 0 + value_counts = self._edge_type_value_counts + return value_counts[type] if type in value_counts else 0 + def get_vertices(self, selection=None): """ Return a Series containing the unique vertex IDs contained in both @@ -249,7 +340,7 @@ def add_vertex_data(self, The name to be assigned to the type of property being added. For example, if dataframe contains data about users, type_name might be "users". If not specified, the type of properties will be added as - None or NA + the empty string, "". property_columns : list of strings List of column names in dataframe to be added as properties. All other columns in dataframe will be ignored. If not specified, all @@ -272,6 +363,8 @@ def add_vertex_data(self, if (type_name is not None) and not(isinstance(type_name, str)): raise TypeError("type_name must be a string, got: " f"{type(type_name)}") + if type_name is None: + type_name = self._default_type_name if property_columns: if type(property_columns) is not list: raise TypeError("property_columns must be a list, got: " @@ -296,7 +389,7 @@ def add_vertex_data(self, # Clear the cached values related to the number of vertices since more # could be added in this method. self.__num_vertices = None - self.__num_vertices_with_properties = None + self.__vertex_type_value_counts = None # Could update instead # Initialize the __vertex_prop_dataframe if necessary using the same # type as the incoming dataframe. @@ -367,7 +460,7 @@ def add_edge_data(self, The name to be assigned to the type of property being added. For example, if dataframe contains data about transactions, type_name might be "transactions". If not specified, the type of properties - will be added as None or NA + will be added as the empty string "". property_columns : list of strings List of column names in dataframe to be added as properties. All other columns in dataframe will be ignored. If not specified, all @@ -394,6 +487,8 @@ def add_edge_data(self, if (type_name is not None) and not(isinstance(type_name, str)): raise TypeError("type_name must be a string, got: " f"{type(type_name)}") + if type_name is None: + type_name = self._default_type_name if property_columns: if type(property_columns) is not list: raise TypeError("property_columns must be a list, got: " @@ -416,8 +511,9 @@ def add_edge_data(self, f"using type {self.__dataframe_type}") # Clear the cached value for num_vertices since more could be added in - # this method. This method cannot affect num_vertices_with_properties + # this method. This method cannot affect __node_type_value_counts self.__num_vertices = None + self.__edge_type_value_counts = None # Could update instead default_edge_columns = [self.src_col_name, self.dst_col_name, @@ -754,7 +850,6 @@ def edge_props_to_graph(self, Create and return a Graph from the edges in edge_prop_df. """ # FIXME: check default_edge_weight is valid - if edge_weight_property: if edge_weight_property not in edge_prop_df.columns: raise ValueError("edge_weight_property " diff --git a/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py b/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py index d69cb600873..bae807d5e3a 100644 --- a/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py +++ b/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py @@ -327,8 +327,8 @@ def test_extract_subgraph_no_query(net_MGPropertyGraph, net_PropertyGraph): """ dpG = net_MGPropertyGraph pG = net_PropertyGraph - assert pG.num_edges == dpG.num_edges - assert pG.num_vertices == dpG.num_vertices + assert pG.get_num_edges() == dpG.get_num_edges() + assert pG.get_num_vertices() == dpG.get_num_vertices() # tests that the edges are the same in the sg and mg property graph sg_df = \ pG.edges.sort_values(by=['_SRC_', '_DST_']).reset_index(drop=True) @@ -448,8 +448,9 @@ def test_num_vertices_with_properties(dataset2_MGPropertyGraph): """ (pG, data) = dataset2_MGPropertyGraph - assert pG.num_vertices == len(data[1]) * 2 # assume no repeated vertices - assert pG.num_vertices_with_properties == 0 + # assume no repeated vertices + assert pG.get_num_vertices() == len(data[1]) * 2 + assert pG.get_num_vertices(include_edge_data=False) == 0 df = cudf.DataFrame({"vertex": [98, 97], "some_property": ["a", "b"], @@ -457,8 +458,9 @@ def test_num_vertices_with_properties(dataset2_MGPropertyGraph): mgdf = dask_cudf.from_cudf(df, npartitions=2) pG.add_vertex_data(mgdf, vertex_col_name="vertex") - assert pG.num_vertices == len(data[1]) * 2 # assume no repeated vertices - assert pG.num_vertices_with_properties == 2 + # assume no repeated vertices + assert pG.get_num_vertices() == len(data[1]) * 2 + assert pG.get_num_vertices(include_edge_data=False) == 2 def test_edges_attr(dataset2_MGPropertyGraph): diff --git a/python/cugraph/cugraph/tests/test_graph_store.py b/python/cugraph/cugraph/tests/test_graph_store.py index 7cb535da5da..12c825dbb3a 100644 --- a/python/cugraph/cugraph/tests/test_graph_store.py +++ b/python/cugraph/cugraph/tests/test_graph_store.py @@ -60,9 +60,9 @@ def test_using_pgraph(graph_file): gstore = cugraph.gnn.CuGraphStore(graph=pG) - assert g.number_of_edges() == pG.num_edges + assert g.number_of_edges() == pG.get_num_edges() assert g.number_of_edges() == gstore.num_edges() - assert g.number_of_vertices() == pG.num_vertices + assert g.number_of_vertices() == pG.get_num_vertices() assert g.number_of_vertices() == gstore.num_vertices diff --git a/python/cugraph/cugraph/tests/test_property_graph.py b/python/cugraph/cugraph/tests/test_property_graph.py index a85f8df25fe..c0fb2299224 100644 --- a/python/cugraph/cugraph/tests/test_property_graph.py +++ b/python/cugraph/cugraph/tests/test_property_graph.py @@ -214,7 +214,6 @@ def dataset1_PropertyGraph(request): vertex_col_names=("user_id_1", "user_id_2"), property_columns=None) - return pG @@ -303,8 +302,9 @@ def test_add_vertex_data(df_type): vertex_col_name="merchant_id", property_columns=None) - assert pG.num_vertices == 5 - assert pG.num_edges == 0 + assert pG.get_num_vertices() == 5 + assert pG.get_num_vertices('merchants') == 5 + assert pG.get_num_edges() == 0 expected_props = merchants[0].copy() assert sorted(pG.vertex_property_names) == sorted(expected_props) @@ -312,7 +312,7 @@ def test_add_vertex_data(df_type): @pytest.mark.parametrize("df_type", df_types, ids=df_type_id) def test_num_vertices(df_type): """ - Ensures num_vertices is correct after various additions of specific data. + Ensures get_num_vertices is correct after various additions of data. """ from cugraph.experimental import PropertyGraph @@ -321,6 +321,9 @@ def test_num_vertices(df_type): data=merchants[1]) pG = PropertyGraph() + assert pG.get_num_vertices() == 0 + assert pG.get_num_vertices('unknown_type') == 0 + assert pG.get_num_edges('unknown_type') == 0 pG.add_vertex_data(merchants_df, type_name="merchants", vertex_col_name="merchant_id", @@ -328,12 +331,12 @@ def test_num_vertices(df_type): # Test caching - the second retrieval should always be faster st = time.time() - assert pG.num_vertices == 5 + assert pG.get_num_vertices() == 5 compute_time = time.time() - st - assert pG.num_edges == 0 + assert pG.get_num_edges() == 0 st = time.time() - assert pG.num_vertices == 5 + assert pG.get_num_vertices() == 5 cache_retrieval_time = time.time() - st assert cache_retrieval_time < compute_time @@ -345,8 +348,10 @@ def test_num_vertices(df_type): vertex_col_name="user_id", property_columns=None) - assert pG.num_vertices == 9 - assert pG.num_edges == 0 + assert pG.get_num_vertices() == 9 + assert pG.get_num_vertices('merchants') == 5 + assert pG.get_num_vertices('users') == 4 + assert pG.get_num_edges() == 0 # The taxpayers table does not add new unique vertices, it only adds # properties to vertices already present in the merchants and users @@ -360,8 +365,90 @@ def test_num_vertices(df_type): vertex_col_name="payer_id", property_columns=None) - assert pG.num_vertices == 9 - assert pG.num_edges == 0 + assert pG.get_num_vertices() == 9 + assert pG.get_num_vertices('merchants') == 5 + assert pG.get_num_vertices('users') == 4 + assert pG.get_num_vertices('unknown_type') == 0 + assert pG.get_num_edges() == 0 + + +@pytest.mark.parametrize("df_type", df_types, ids=df_type_id) +def test_type_names(df_type): + from cugraph.experimental import PropertyGraph + + pG = PropertyGraph() + assert pG.edge_types == set() + assert pG.vertex_types == set() + + df = df_type({"src": [99, 98, 97], + "dst": [22, 34, 56], + "some_property": ["a", "b", "c"], + }) + pG.add_edge_data(df, vertex_col_names=("src", "dst")) + assert pG.edge_types == set([""]) + assert pG.vertex_types == set([""]) + + df = df_type({"vertex": [98, 97], + "some_property": ["a", "b"], + }) + pG.add_vertex_data(df, type_name="vtype", vertex_col_name="vertex") + assert pG.edge_types == set([""]) + assert pG.vertex_types == set(["", "vtype"]) + + df = df_type({"src": [199, 98, 197], + "dst": [22, 134, 56], + "some_property": ["a", "b", "c"], + }) + pG.add_edge_data(df, type_name="etype", vertex_col_names=("src", "dst")) + assert pG.edge_types == set(["", "etype"]) + assert pG.vertex_types == set(["", "vtype"]) + + +@pytest.mark.parametrize("df_type", df_types, ids=df_type_id) +def test_num_vertices_include_edge_data(df_type): + """ + Ensures get_num_vertices is correct after various additions of data. + """ + from cugraph.experimental import PropertyGraph + + (merchants, users, taxpayers, + transactions, relationships, referrals) = dataset1.values() + + pG = PropertyGraph() + assert pG.get_num_vertices(include_edge_data=False) == 0 + assert pG.get_num_vertices("", include_edge_data=False) == 0 + + pG.add_edge_data(df_type(columns=transactions[0], + data=transactions[1]), + type_name="transactions", + vertex_col_names=("user_id", "merchant_id"), + property_columns=None) + + assert pG.get_num_vertices(include_edge_data=False) == 0 + assert pG.get_num_vertices("", include_edge_data=False) == 0 + assert pG.get_num_vertices(include_edge_data=True) == 7 + assert pG.get_num_vertices("", include_edge_data=True) == 7 + pG.add_vertex_data(df_type(columns=merchants[0], + data=merchants[1]), + # type_name="merchants", # Use default! + vertex_col_name="merchant_id", + property_columns=None) + assert pG.get_num_vertices(include_edge_data=False) == 5 + assert pG.get_num_vertices("", include_edge_data=False) == 5 + assert pG.get_num_vertices(include_edge_data=True) == 9 + assert pG.get_num_vertices("", include_edge_data=True) == 9 + pG.add_vertex_data(df_type(columns=users[0], + data=users[1]), + type_name="users", + vertex_col_name="user_id", + property_columns=None) + assert pG.get_num_vertices(include_edge_data=False) == 9 + assert pG.get_num_vertices("", include_edge_data=False) == 5 + assert pG.get_num_vertices("users", include_edge_data=False) == 4 + # All vertices now have vertex data, so this should match + assert pG.get_num_vertices(include_edge_data=True) == 9 + assert pG.get_num_vertices("", include_edge_data=True) == 5 + assert pG.get_num_vertices("users", include_edge_data=True) == 4 @pytest.mark.parametrize("df_type", df_types, ids=df_type_id) @@ -380,16 +467,16 @@ def test_num_vertices_with_properties(df_type): }) pG.add_edge_data(df, vertex_col_names=("src", "dst")) - assert pG.num_vertices == 6 - assert pG.num_vertices_with_properties == 0 + assert pG.get_num_vertices() == 6 + assert pG.get_num_vertices(include_edge_data=False) == 0 df = df_type({"vertex": [98, 97], "some_property": ["a", "b"], }) pG.add_vertex_data(df, vertex_col_name="vertex") - assert pG.num_vertices == 6 - assert pG.num_vertices_with_properties == 2 + assert pG.get_num_vertices() == 6 + assert pG.get_num_vertices(include_edge_data=False) == 2 @pytest.mark.parametrize("df_type", df_types, ids=df_type_id) @@ -401,8 +488,8 @@ def test_null_data(df_type): pG = PropertyGraph() - assert pG.num_vertices == 0 - assert pG.num_edges == 0 + assert pG.get_num_vertices() == 0 + assert pG.get_num_edges() == 0 assert sorted(pG.vertex_property_names) == sorted([]) @@ -424,8 +511,9 @@ def test_add_vertex_data_prop_columns(df_type): vertex_col_name="merchant_id", property_columns=expected_props) - assert pG.num_vertices == 5 - assert pG.num_edges == 0 + assert pG.get_num_vertices() == 5 + assert pG.get_num_vertices('merchants') == 5 + assert pG.get_num_edges() == 0 assert sorted(pG.vertex_property_names) == sorted(expected_props) @@ -486,8 +574,11 @@ def test_add_edge_data(df_type): vertex_col_names=("user_id", "merchant_id"), property_columns=None) - assert pG.num_vertices == 7 - assert pG.num_edges == 4 + assert pG.get_num_vertices() == 7 + # 'transactions' is edge type, not vertex type + assert pG.get_num_vertices('transactions') == 0 + assert pG.get_num_edges() == 4 + assert pG.get_num_edges('transactions') == 4 expected_props = ["merchant_id", "user_id", "volume", "time", "card_num", "card_type"] assert sorted(pG.edge_property_names) == sorted(expected_props) @@ -511,8 +602,11 @@ def test_add_edge_data_prop_columns(df_type): vertex_col_names=("user_id", "merchant_id"), property_columns=expected_props) - assert pG.num_vertices == 7 - assert pG.num_edges == 4 + assert pG.get_num_vertices() == 7 + # 'transactions' is edge type, not vertex type + assert pG.get_num_vertices('transactions') == 0 + assert pG.get_num_edges() == 4 + assert pG.get_num_edges('transactions') == 4 assert sorted(pG.edge_property_names) == sorted(expected_props) @@ -928,7 +1022,15 @@ def test_graph_edge_data_added(dataset1_PropertyGraph): len(dataset1["relationships"][-1]) + \ len(dataset1["referrals"][-1]) - assert pG.num_edges == expected_num_edges + assert pG.get_num_edges() == expected_num_edges + assert ( + pG.get_num_edges("transactions") == len(dataset1["transactions"][-1]) + ) + assert ( + pG.get_num_edges("relationships") == len(dataset1["relationships"][-1]) + ) + assert pG.get_num_edges("referrals") == len(dataset1["referrals"][-1]) + assert pG.get_num_edges("unknown_type") == 0 # extract_subgraph() should return a directed Graph object with additional # meta-data, which includes edge IDs. @@ -1119,10 +1221,7 @@ def test_extract_subgraph_with_vertex_ids(): def bench_num_vertices(gpubenchmark, dataset1_PropertyGraph): pG = dataset1_PropertyGraph - def get_num_vertices(): - return pG.num_vertices - - assert gpubenchmark(get_num_vertices) == 9 + assert gpubenchmark(pG.get_num_vertices) == 9 def bench_get_vertices(gpubenchmark, dataset1_PropertyGraph): From 2263011bd2fcb25b178d8cb097dc5a251a3c6237 Mon Sep 17 00:00:00 2001 From: Rick Ratzel <3039903+rlratzel@users.noreply.github.com> Date: Fri, 29 Jul 2022 12:58:10 -0400 Subject: [PATCH 03/19] Added `get_vertex_data()` and `get_edge_data()` to SG/MG PropertyGraph (#2444) closes #2421 Added `get_vertex_data()` and `get_edge_data()` to SG and MG PropertyGraph, and corresponding tests. Prior to these methods, users had to either call `pG.annotate_dataframe()` to get properties for edges or access the internal debug dataframes directly via `pG._vertex_prop_dataframe` and `pG._edge_prop_dataframe`. Users can now call `pG.get_vertex_data(vertex_ids, types, columns)` to get vertex properties for the vertices specified by `vertex_ids`, and 'types', with data for each column specified. All args are optional and default to "all" for each category. `pG.get_edge_data(edge_ids, types, columns)` works the same for edges. The return value for both is a dataframe. Authors: - Rick Ratzel (https://github.com/rlratzel) Approvers: - Vibhu Jawa (https://github.com/VibhuJawa) - Alex Barghi (https://github.com/alexbarghi-nv) - Erik Welch (https://github.com/eriknw) - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2444 --- .../dask/structure/mg_property_graph.py | 126 +++++++--- .../cugraph/structure/property_graph.py | 107 ++++++-- .../tests/mg/test_mg_property_graph.py | 181 ++++++++++++-- .../cugraph/tests/test_property_graph.py | 230 ++++++++++++++++-- 4 files changed, 550 insertions(+), 94 deletions(-) diff --git a/python/cugraph/cugraph/dask/structure/mg_property_graph.py b/python/cugraph/cugraph/dask/structure/mg_property_graph.py index 7399d818d23..541360e64ec 100644 --- a/python/cugraph/cugraph/dask/structure/mg_property_graph.py +++ b/python/cugraph/cugraph/dask/structure/mg_property_graph.py @@ -429,6 +429,38 @@ def add_vertex_data(self, for n in self.__vertex_prop_dataframe.columns]) self.__vertex_prop_eval_dict.update(latest) + def get_vertex_data(self, vertex_ids=None, types=None, columns=None): + """ + Return a dataframe containing vertex properties for only the specified + vertex_ids, columns, and/or types, or all vertex IDs if not specified. + """ + if self.__vertex_prop_dataframe is not None: + if vertex_ids is not None: + df_mask = ( + self.__vertex_prop_dataframe[self.vertex_col_name] + .isin(vertex_ids) + ) + df = self.__vertex_prop_dataframe.loc[df_mask] + else: + df = self.__vertex_prop_dataframe + + if types is not None: + # FIXME: coerce types to a list-like if not? + df_mask = df[self.type_col_name].isin(types) + df = df.loc[df_mask] + + # The "internal" pG.vertex_col_name and pG.type_col_name columns + # are also included/added since they are assumed to be needed by + # the caller. + if columns is None: + return df + else: + # FIXME: invalid columns will result in a KeyError, should a + # check be done here and a more PG-specific error raised? + return df[[self.vertex_col_name, self.type_col_name] + columns] + + return None + def add_edge_data(self, dataframe, vertex_col_names, @@ -512,22 +544,22 @@ def add_edge_data(self, # columns. The copied DataFrame is then merged (another copy) and then # deleted when out-of-scope. tmp_df = dataframe.copy() - # FIXME: Find a better way to create the edge id - prev_eid = -1 if self.__last_edge_id is None else self.__last_edge_id tmp_df[self.src_col_name] = tmp_df[vertex_col_names[0]] tmp_df[self.dst_col_name] = tmp_df[vertex_col_names[1]] - starting_eid = prev_eid + 1 - data_size = len(tmp_df.compute().index) - cudf_series = \ - cudf.Series(range(starting_eid, starting_eid + data_size)) - dask_series =\ - dask_cudf.from_cudf(cudf_series, self.__num_workers) - dask_series = dask_series.reset_index(drop=True) - self.__last_edge_id = starting_eid + data_size - tmp_df = tmp_df.reset_index(drop=True) - tmp_df[self.edge_id_col_name] = dask_series tmp_df[self.type_col_name] = type_name + + # Add unique edge IDs to the new rows. This is just a count for each + # row starting from the last edge ID value, with initial edge ID 0. + starting_eid = ( + -1 if self.__last_edge_id is None else self.__last_edge_id + ) + tmp_df[self.edge_id_col_name] = 1 + tmp_df[self.edge_id_col_name] = ( + tmp_df[self.edge_id_col_name].cumsum() + starting_eid + ) + self.__last_edge_id = starting_eid + len(tmp_df.index) tmp_df.persist() + if property_columns: # all columns column_names_to_drop = set(tmp_df.columns) @@ -542,13 +574,50 @@ def add_edge_data(self, new_col_info = self.__get_new_column_dtypes( tmp_df, self.__edge_prop_dataframe) self.__edge_prop_dtypes.update(new_col_info) + self.__edge_prop_dataframe = \ self.__edge_prop_dataframe.merge(tmp_df, how="outer") + # Update the vertex eval dict with the latest column instances latest = dict([(n, self.__edge_prop_dataframe[n]) for n in self.__edge_prop_dataframe.columns]) self.__edge_prop_eval_dict.update(latest) + def get_edge_data(self, edge_ids=None, types=None, columns=None): + """ + Return a dataframe containing edge properties for only the specified + edge_ids, columns, and/or edge type, or all edge IDs if not specified. + """ + if self.__edge_prop_dataframe is not None: + if edge_ids is not None: + df_mask = self.__edge_prop_dataframe[self.edge_id_col_name]\ + .isin(edge_ids) + df = self.__edge_prop_dataframe.loc[df_mask] + else: + df = self.__edge_prop_dataframe + + if types is not None: + # FIXME: coerce types to a list-like if not? + df_mask = df[self.type_col_name].isin(types) + df = df.loc[df_mask] + + # The "internal" src, dst, edge_id, and type columns are also + # included/added since they are assumed to be needed by the caller. + if columns is None: + # remove the "internal" weight column if one was added + all_columns = list(self.__edge_prop_dataframe.columns) + if self.weight_col_name in all_columns: + all_columns.remove(self.weight_col_name) + return df[all_columns] + else: + # FIXME: invalid columns will result in a KeyError, should a + # check be done here and a more PG-specific error raised? + return df[[self.src_col_name, self.dst_col_name, + self.edge_id_col_name, self.type_col_name] + + columns] + + return None + def select_vertices(self, expr, from_previous_selection=None): raise NotImplementedError @@ -766,16 +835,21 @@ def edge_props_to_graph(self, raise RuntimeError("query resulted in duplicate edges which " f"cannot be represented with the {msg}") - # FIXME: MNMG Graphs required renumber to be True due to requirements - # on legacy code that needed segment offsets, partition offsets, - # etc. which were previously computed during the "legacy" C - # renumbering. The workaround is to pass renumber=True, then manually - # call G.compute_renumber_edge_list(legacy_renum_only=True) to compute - # the required meta-data without changing vertex IDs. + # FIXME: This forces the renumbering code to run a python-only + # renumbering without the newer C++ renumbering step. This is + # required since the newest graph algos which are using the + # pylibcugraph library will crash if passed data renumbered using the + # C++ renumbering. The consequence of this is that these extracted + # subgraphs can only be used with newer pylibcugraph-based MG algos. + # + # NOTE: if the vertices are integers (int32 or int64), renumbering is + # actually skipped with the assumption that the C renumbering will + # take place. The C renumbering only occurs for pylibcugraph algos, + # hence the reason these extracted subgraphs only work with PLC algos. if renumber_graph is False: - renumber = True - else: - renumber = renumber_graph + raise ValueError("currently, renumber_graph must be set to True " + "for MG") + legacy_renum_only = True col_names = [self.src_col_name, self.dst_col_name] if edge_attr is not None: @@ -785,14 +859,8 @@ def edge_props_to_graph(self, source=self.src_col_name, destination=self.dst_col_name, edge_attr=edge_attr, - renumber=renumber) - # FIXME: see FIXME above - to generate the edgelist, - # compute_renumber_edge_list() must be called, but legacy mode needs to - # be used based on if renumbering was to be done or not. - if renumber_graph is False: - G.compute_renumber_edge_list(legacy_renum_only=True) - else: - G.compute_renumber_edge_list(legacy_renum_only=False) + renumber=renumber_graph, + legacy_renum_only=legacy_renum_only) if add_edge_data: # Set the edge_data on the resulting Graph to a DataFrame diff --git a/python/cugraph/cugraph/structure/property_graph.py b/python/cugraph/cugraph/structure/property_graph.py index 6137b6952a0..feeafd32026 100644 --- a/python/cugraph/cugraph/structure/property_graph.py +++ b/python/cugraph/cugraph/structure/property_graph.py @@ -23,6 +23,7 @@ _dataframe_types.append(pd.DataFrame) +# FIXME: remove leading EXPERIMENTAL__ when no longer experimental class EXPERIMENTAL__PropertySelection: """ Instances of this class are returned from the PropertyGraph.select_*() @@ -50,7 +51,7 @@ def __add__(self, other): return EXPERIMENTAL__PropertySelection(vs, es) -# FIXME: remove leading __ when no longer experimental +# FIXME: remove leading EXPERIMENTAL__ when no longer experimental class EXPERIMENTAL__PropertyGraph: """ Class which stores vertex and edge properties that can be used to construct @@ -144,7 +145,8 @@ def __init__(self): def edges(self): if self.__edge_prop_dataframe is not None: return self.__edge_prop_dataframe[[self.src_col_name, - self.dst_col_name]] + self.dst_col_name, + self.edge_id_col_name]] return None @property @@ -439,6 +441,38 @@ def add_vertex_data(self, for n in self.__vertex_prop_dataframe.columns]) self.__vertex_prop_eval_dict.update(latest) + def get_vertex_data(self, vertex_ids=None, types=None, columns=None): + """ + Return a dataframe containing vertex properties for only the specified + vertex_ids, columns, and/or types, or all vertex IDs if not specified. + """ + if self.__vertex_prop_dataframe is not None: + if vertex_ids is not None: + df_mask = ( + self.__vertex_prop_dataframe[self.vertex_col_name] + .isin(vertex_ids) + ) + df = self.__vertex_prop_dataframe.loc[df_mask] + else: + df = self.__vertex_prop_dataframe + + if types is not None: + # FIXME: coerce types to a list-like if not? + df_mask = df[self.type_col_name].isin(types) + df = df.loc[df_mask] + + # The "internal" pG.vertex_col_name and pG.type_col_name columns + # are also included/added since they are assumed to be needed by + # the caller. + if columns is None: + return df + else: + # FIXME: invalid columns will result in a KeyError, should a + # check be done here and a more PG-specific error raised? + return df[[self.vertex_col_name, self.type_col_name] + columns] + + return None + def add_edge_data(self, dataframe, vertex_col_names, @@ -538,9 +572,19 @@ def add_edge_data(self, tmp_df = dataframe.copy(deep=True) tmp_df[self.src_col_name] = tmp_df[vertex_col_names[0]] tmp_df[self.dst_col_name] = tmp_df[vertex_col_names[1]] - # FIXME: handle case of a type_name column already being in tmp_df tmp_df[self.type_col_name] = type_name + # Add unique edge IDs to the new rows. This is just a count for each + # row starting from the last edge ID value, with initial edge ID 0. + starting_eid = ( + -1 if self.__last_edge_id is None else self.__last_edge_id + ) + tmp_df[self.edge_id_col_name] = 1 + tmp_df[self.edge_id_col_name] = ( + tmp_df[self.edge_id_col_name].cumsum() + starting_eid + ) + self.__last_edge_id = starting_eid + len(tmp_df.index) + if property_columns: # all columns column_names_to_drop = set(tmp_df.columns) @@ -559,13 +603,46 @@ def add_edge_data(self, self.__edge_prop_dataframe = \ self.__edge_prop_dataframe.merge(tmp_df, how="outer") - self.__add_edge_ids() - # Update the vertex eval dict with the latest column instances latest = dict([(n, self.__edge_prop_dataframe[n]) for n in self.__edge_prop_dataframe.columns]) self.__edge_prop_eval_dict.update(latest) + def get_edge_data(self, edge_ids=None, types=None, columns=None): + """ + Return a dataframe containing edge properties for only the specified + edge_ids, columns, and/or edge type, or all edge IDs if not specified. + """ + if self.__edge_prop_dataframe is not None: + if edge_ids is not None: + df_mask = self.__edge_prop_dataframe[self.edge_id_col_name]\ + .isin(edge_ids) + df = self.__edge_prop_dataframe.loc[df_mask] + else: + df = self.__edge_prop_dataframe + + if types is not None: + # FIXME: coerce types to a list-like if not? + df_mask = df[self.type_col_name].isin(types) + df = df.loc[df_mask] + + # The "internal" src, dst, edge_id, and type columns are also + # included/added since they are assumed to be needed by the caller. + if columns is None: + # remove the "internal" weight column if one was added + all_columns = list(self.__edge_prop_dataframe.columns) + if self.weight_col_name in all_columns: + all_columns.remove(self.weight_col_name) + return df[all_columns] + else: + # FIXME: invalid columns will result in a KeyError, should a + # check be done here and a more PG-specific error raised? + return df[[self.src_col_name, self.dst_col_name, + self.edge_id_col_name, self.type_col_name] + + columns] + + return None + def select_vertices(self, expr, from_previous_selection=None): """ Evaluate expr and return a PropertySelection object representing the @@ -957,26 +1034,6 @@ def __create_property_lookup_table(self, edge_prop_df): self.dst_col_name: dst, self.edge_id_col_name: edge_id}) - def __add_edge_ids(self): - """ - Replace nans with unique edge IDs. Edge IDs are simply numbers - incremented by 1 for each edge. - """ - prev_eid = -1 if self.__last_edge_id is None else self.__last_edge_id - nans = self.__edge_prop_dataframe[self.edge_id_col_name].isna() - - if nans.any(): - indices = nans.index[nans] - num_indices = len(indices) - starting_eid = prev_eid + 1 - new_eids = self.__series_type( - range(starting_eid, starting_eid + num_indices)) - - self.__edge_prop_dataframe[self.edge_id_col_name]\ - .iloc[indices] = new_eids - - self.__last_edge_id = starting_eid + num_indices - 1 - def __get_all_vertices_series(self): """ Return a list of all Series objects that contain vertices from all diff --git a/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py b/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py index bae807d5e3a..eceec8b658f 100644 --- a/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py +++ b/python/cugraph/cugraph/tests/mg/test_mg_property_graph.py @@ -215,7 +215,7 @@ def dataset1_PropertyGraph(request): vertex_col_names=("user_id_1", "user_id_2"), property_columns=None) - return pG + return (pG, dataset1) @pytest.fixture(scope="module") @@ -275,7 +275,24 @@ def dataset1_MGPropertyGraph(dask_client): vertex_col_names=("user_id_1", "user_id_2"), property_columns=None) - return mpG + return (mpG, dataset1) + + +@pytest.fixture(scope="module") +def dataset2_simple_MGPropertyGraph(dask_client): + from cugraph.experimental import MGPropertyGraph + + dataframe_type = cudf.DataFrame + simple = dataset2["simple"] + mpG = MGPropertyGraph() + + sg_df = dataframe_type(columns=simple[0], data=simple[1]) + mgdf = dask_cudf.from_cudf(sg_df, npartitions=2) + + mpG.add_edge_data(mgdf, + vertex_col_names=("src", "dst")) + + return (mpG, simple) @pytest.fixture(scope="module") @@ -350,8 +367,8 @@ def test_extract_subgraph_no_query(net_MGPropertyGraph, net_PropertyGraph): @pytest.mark.skip(reason="Skipping tests because it is a work in progress") def test_adding_fixture(dataset1_PropertyGraph, dataset1_MGPropertyGraph): - sgpG = dataset1_PropertyGraph - mgPG = dataset1_MGPropertyGraph + (sgpG, _) = dataset1_PropertyGraph + (mgPG, _) = dataset1_MGPropertyGraph subgraph = sgpG.extract_subgraph(allow_multi_edges=True) dask_subgraph = mgPG.extract_subgraph(allow_multi_edges=True) sg_subgraph_df = \ @@ -367,8 +384,8 @@ def test_adding_fixture(dataset1_PropertyGraph, dataset1_MGPropertyGraph): @pytest.mark.skip(reason="Skipping tests because it is a work in progress") def test_frame_data(dataset1_PropertyGraph, dataset1_MGPropertyGraph): - sgpG = dataset1_PropertyGraph - mgpG = dataset1_MGPropertyGraph + (sgpG, _) = dataset1_PropertyGraph + (mgpG, _) = dataset1_MGPropertyGraph edge_sort_col = ['_SRC_', '_DST_', '_TYPE_'] vert_sort_col = ['_VERTEX_', '_TYPE_'] @@ -392,7 +409,7 @@ def test_property_names_attrs(dataset1_MGPropertyGraph): Ensure the correct number of user-visible properties for vertices and edges are returned. This should exclude the internal bookkeeping properties. """ - pG = dataset1_MGPropertyGraph + (pG, data) = dataset1_MGPropertyGraph expected_vert_prop_names = ["merchant_id", "merchant_location", "merchant_size", "merchant_sales", @@ -414,39 +431,48 @@ def test_property_names_attrs(dataset1_MGPropertyGraph): assert sorted(actual_edge_prop_names) == sorted(expected_edge_prop_names) -def test_extract_subgraph_nonrenumbered_noedgedata(dataset2_MGPropertyGraph): +def test_extract_subgraph_nonrenumbered_noedgedata( + dataset2_simple_MGPropertyGraph): """ - Ensure a subgraph can be extracted that is not renumbered and contains no - edge_data. + Ensure a subgraph can be extracted that contains no edge_data. Also ensure + renumber cannot be False since that is currently not allowed for MG. """ from cugraph import Graph - (pG, data) = dataset2_MGPropertyGraph + (pG, data) = dataset2_simple_MGPropertyGraph + + # renumber=False is currently not allowed for MG. + with pytest.raises(ValueError): + G = pG.extract_subgraph(create_using=Graph(directed=True), + renumber_graph=False, + add_edge_data=False) + G = pG.extract_subgraph(create_using=Graph(directed=True), - renumber_graph=False, add_edge_data=False) actual_edgelist = G.edgelist.edgelist_df.compute() + src_col_name = pG.src_col_name + dst_col_name = pG.dst_col_name + # create a DF without the properties (ie. the last column) - expected_edgelist = cudf.DataFrame(columns=[pG.src_col_name, - pG.dst_col_name], + expected_edgelist = cudf.DataFrame(columns=[src_col_name, dst_col_name], data=[(i, j) for (i, j, k) in data[1]]) - assert_frame_equal(expected_edgelist.sort_values(by=pG.src_col_name, + assert_frame_equal(expected_edgelist.sort_values(by=src_col_name, ignore_index=True), - actual_edgelist.sort_values(by=pG.src_col_name, + actual_edgelist.sort_values(by=src_col_name, ignore_index=True)) assert hasattr(G, "edge_data") is False -def test_num_vertices_with_properties(dataset2_MGPropertyGraph): +def test_num_vertices_with_properties(dataset2_simple_MGPropertyGraph): """ Checks that the num_vertices_with_properties attr is set to the number of vertices that have properties, as opposed to just num_vertices which also includes all verts in the graph edgelist. """ - (pG, data) = dataset2_MGPropertyGraph + (pG, data) = dataset2_simple_MGPropertyGraph # assume no repeated vertices assert pG.get_num_vertices() == len(data[1]) * 2 @@ -463,11 +489,11 @@ def test_num_vertices_with_properties(dataset2_MGPropertyGraph): assert pG.get_num_vertices(include_edge_data=False) == 2 -def test_edges_attr(dataset2_MGPropertyGraph): +def test_edges_attr(dataset2_simple_MGPropertyGraph): """ Ensure the edges attr returns the src, dst, edge_id columns properly. """ - (pG, data) = dataset2_MGPropertyGraph + (pG, data) = dataset2_simple_MGPropertyGraph # create a DF without the properties (ie. the last column) expected_edges = cudf.DataFrame(columns=[pG.src_col_name, pG.dst_col_name], @@ -482,3 +508,118 @@ def test_edges_attr(dataset2_MGPropertyGraph): expected_num_edges = len(data[1]) assert len(edge_ids) == expected_num_edges assert edge_ids.nunique() == expected_num_edges + + +def test_get_vertex_data(dataset1_MGPropertyGraph): + """ + Ensure PG.get_vertex_data() returns the correct data based on vertex IDs + passed in. + """ + (pG, data) = dataset1_MGPropertyGraph + + # Ensure the generated vertex IDs are unique + all_vertex_data = pG.get_vertex_data() + assert all_vertex_data[pG.vertex_col_name].nunique().compute() == \ + len(all_vertex_data) + + # Test with specific columns and types + vert_type = "merchants" + columns = ["merchant_location", "merchant_size"] + + some_vertex_data = pG.get_vertex_data(types=[vert_type], columns=columns) + # Ensure the returned df is the right length and includes only the + # vert/type + specified columns + standard_vert_columns = [pG.vertex_col_name, pG.type_col_name] + assert len(some_vertex_data) == len(data[vert_type][1]) + assert ( + sorted(some_vertex_data.columns) == + sorted(columns + standard_vert_columns) + ) + + # Test with all params specified + vert_ids = [11, 4, 21] + vert_type = "merchants" + columns = ["merchant_location", "merchant_size"] + + some_vertex_data = pG.get_vertex_data(vertex_ids=vert_ids, + types=[vert_type], + columns=columns) + # Ensure the returned df is the right length and includes at least the + # specified columns. + assert len(some_vertex_data) == len(vert_ids) + assert set(columns) - set(some_vertex_data.columns) == set() + + +def test_get_edge_data(dataset1_MGPropertyGraph): + """ + Ensure PG.get_edge_data() returns the correct data based on edge IDs passed + in. + """ + (pG, data) = dataset1_MGPropertyGraph + + # Ensure the generated edge IDs are unique + all_edge_data = pG.get_edge_data() + assert all_edge_data[pG.edge_id_col_name].nunique().compute() == \ + len(all_edge_data) + + # Test with specific edge IDs + edge_ids = [4, 5, 6] + some_edge_data = pG.get_edge_data(edge_ids) + actual_edge_ids = some_edge_data[pG.edge_id_col_name].compute() + if hasattr(actual_edge_ids, "values_host"): + actual_edge_ids = actual_edge_ids.values_host + assert sorted(actual_edge_ids) == sorted(edge_ids) + + # Create a list of expected column names from the three input tables + expected_columns = set([pG.src_col_name, pG.dst_col_name, + pG.edge_id_col_name, pG.type_col_name]) + for d in ["transactions", "relationships", "referrals"]: + for name in data[d][0]: + expected_columns.add(name) + + actual_columns = set(some_edge_data.columns) + + assert actual_columns == expected_columns + + # Test with specific columns and types + edge_type = "transactions" + columns = ["card_num", "card_type"] + + some_edge_data = pG.get_edge_data(types=[edge_type], columns=columns) + # Ensure the returned df is the right length and includes only the + # src/dst/id/type + specified columns + standard_edge_columns = [pG.src_col_name, pG.dst_col_name, + pG.edge_id_col_name, pG.type_col_name] + assert len(some_edge_data) == len(data[edge_type][1]) + assert ( + sorted(some_edge_data.columns) == + sorted(columns + standard_edge_columns) + ) + + # Test with all params specified + # FIXME: since edge IDs are generated, assume that these are correct based + # on the intended edges being the first three added. + edge_ids = [0, 1, 2] + edge_type = "transactions" + columns = ["card_num", "card_type"] + some_edge_data = pG.get_edge_data(edge_ids=edge_ids, + types=[edge_type], + columns=columns) + # Ensure the returned df is the right length and includes at least the + # specified columns. + assert len(some_edge_data) == len(edge_ids) + assert set(columns) - set(some_edge_data.columns) == set() + + +def test_get_data_empty_graphs(dask_client): + """ + Ensures that calls to pG.get_*_data() on an empty pG are handled correctly. + """ + from cugraph.experimental import MGPropertyGraph + + pG = MGPropertyGraph() + + assert pG.get_vertex_data() is None + assert pG.get_vertex_data([0, 1, 2]) is None + assert pG.get_edge_data() is None + assert pG.get_edge_data([0, 1, 2]) is None diff --git a/python/cugraph/cugraph/tests/test_property_graph.py b/python/cugraph/cugraph/tests/test_property_graph.py index c0fb2299224..586f0a80a56 100644 --- a/python/cugraph/cugraph/tests/test_property_graph.py +++ b/python/cugraph/cugraph/tests/test_property_graph.py @@ -97,6 +97,18 @@ ], } + +dataset2 = { + "simple": [ + ["src", "dst", "some_property"], + [(99, 22, "a"), + (98, 34, "b"), + (97, 56, "c"), + (96, 88, "d"), + ] + ], +} + # Placeholder for a directed Graph instance. This is not constructed here in # order to prevent cuGraph code from running on import, which would prevent # proper pytest collection if an exception is raised. See setup_function(). @@ -214,7 +226,27 @@ def dataset1_PropertyGraph(request): vertex_col_names=("user_id_1", "user_id_2"), property_columns=None) - return pG + + return (pG, dataset1) + + +@pytest.fixture(scope="module", params=df_types_fixture_params) +def dataset2_simple_PropertyGraph(request): + """ + Fixture which returns an instance of a PropertyGraph with only edge + data added from dataset2, parameterized for different DataFrame types. + """ + dataframe_type = request.param[0] + from cugraph.experimental import PropertyGraph + + dataframe_type = cudf.DataFrame + simple = dataset2["simple"] + pG = PropertyGraph() + df = dataframe_type(columns=simple[0], data=simple[1]) + + pG.add_edge_data(df, vertex_col_names=("src", "dst")) + + return (pG, simple) @pytest.fixture(scope="module", params=df_types_fixture_params) @@ -479,6 +511,150 @@ def test_num_vertices_with_properties(df_type): assert pG.get_num_vertices(include_edge_data=False) == 2 +def test_edges_attr(dataset2_simple_PropertyGraph): + """ + Ensure the edges attr returns the src, dst, edge_id columns properly. + """ + (pG, data) = dataset2_simple_PropertyGraph + + # create a DF without the properties (ie. the last column) + expected_edges = cudf.DataFrame(columns=[pG.src_col_name, pG.dst_col_name], + data=[(i, j) for (i, j, k) in data[1]]) + actual_edges = pG.edges[[pG.src_col_name, pG.dst_col_name]] + + assert_frame_equal(expected_edges.sort_values(by=pG.src_col_name, + ignore_index=True), + actual_edges.sort_values(by=pG.src_col_name, + ignore_index=True)) + edge_ids = pG.edges[pG.edge_id_col_name] + expected_num_edges = len(data[1]) + assert len(edge_ids) == expected_num_edges + assert edge_ids.nunique() == expected_num_edges + + +def test_get_vertex_data(dataset1_PropertyGraph): + """ + Ensure PG.get_vertex_data() returns the correct data based on vertex IDs + passed in. + """ + (pG, data) = dataset1_PropertyGraph + + # Ensure the generated vertex IDs are unique + all_vertex_data = pG.get_vertex_data() + assert all_vertex_data[pG.vertex_col_name].nunique() == \ + len(all_vertex_data) + + # Test getting a subset of data + # Use the appropriate series type based on input + # FIXME: do not use the debug _vertex_prop_dataframe to determine type + if isinstance(pG._vertex_prop_dataframe, cudf.DataFrame): + vert_ids = cudf.Series([11, 4, 21]) + else: + vert_ids = pd.Series([11, 4, 21]) + + some_vertex_data = pG.get_vertex_data(vert_ids) + actual_vertex_ids = some_vertex_data[pG.vertex_col_name] + if hasattr(actual_vertex_ids, "values_host"): + actual_vertex_ids = actual_vertex_ids.values_host + if hasattr(vert_ids, "values_host"): + vert_ids = vert_ids.values_host + assert sorted(actual_vertex_ids) == sorted(vert_ids) + + expected_columns = set([pG.vertex_col_name, pG.type_col_name]) + for d in ["merchants", "users"]: + for name in data[d][0]: + expected_columns.add(name) + actual_columns = set(some_vertex_data.columns) + assert actual_columns == expected_columns + + # Test with specific columns and types + vert_type = "merchants" + columns = ["merchant_location", "merchant_size"] + + some_vertex_data = pG.get_vertex_data(types=[vert_type], columns=columns) + # Ensure the returned df is the right length and includes only the + # vert/type + specified columns + standard_vert_columns = [pG.vertex_col_name, pG.type_col_name] + assert len(some_vertex_data) == len(data[vert_type][1]) + assert ( + sorted(some_vertex_data.columns) == + sorted(columns + standard_vert_columns) + ) + + # Test with all params specified + vert_ids = [11, 4, 21] + vert_type = "merchants" + columns = ["merchant_location", "merchant_size"] + + some_vertex_data = pG.get_vertex_data(vertex_ids=vert_ids, + types=[vert_type], + columns=columns) + # Ensure the returned df is the right length and includes at least the + # specified columns. + assert len(some_vertex_data) == len(vert_ids) + assert set(columns) - set(some_vertex_data.columns) == set() + + +def test_get_edge_data(dataset1_PropertyGraph): + """ + Ensure PG.get_edge_data() returns the correct data based on edge IDs passed + in. + """ + (pG, data) = dataset1_PropertyGraph + + # Ensure the generated edge IDs are unique + all_edge_data = pG.get_edge_data() + assert all_edge_data[pG.edge_id_col_name].nunique() == len(all_edge_data) + + # Test with specific edge IDs + edge_ids = [4, 5, 6] + some_edge_data = pG.get_edge_data(edge_ids) + actual_edge_ids = some_edge_data[pG.edge_id_col_name] + if hasattr(actual_edge_ids, "values_host"): + actual_edge_ids = actual_edge_ids.values_host + assert sorted(actual_edge_ids) == sorted(edge_ids) + + # Create a list of expected column names from the three input tables + expected_columns = set([pG.src_col_name, pG.dst_col_name, + pG.edge_id_col_name, pG.type_col_name]) + for d in ["transactions", "relationships", "referrals"]: + for name in data[d][0]: + expected_columns.add(name) + + actual_columns = set(some_edge_data.columns) + + assert actual_columns == expected_columns + + # Test with specific columns and types + edge_type = "transactions" + columns = ["card_num", "card_type"] + + some_edge_data = pG.get_edge_data(types=[edge_type], columns=columns) + # Ensure the returned df is the right length and includes only the + # src/dst/id/type + specified columns + standard_edge_columns = [pG.src_col_name, pG.dst_col_name, + pG.edge_id_col_name, pG.type_col_name] + assert len(some_edge_data) == len(data[edge_type][1]) + assert ( + sorted(some_edge_data.columns) == + sorted(columns + standard_edge_columns) + ) + + # Test with all params specified + # FIXME: since edge IDs are generated, assume that these are correct based + # on the intended edges being the first three added. + edge_ids = [0, 1, 2] + edge_type = "transactions" + columns = ["card_num", "card_type"] + some_edge_data = pG.get_edge_data(edge_ids=edge_ids, + types=[edge_type], + columns=columns) + # Ensure the returned df is the right length and includes at least the + # specified columns. + assert len(some_edge_data) == len(edge_ids) + assert set(columns) - set(some_edge_data.columns) == set() + + @pytest.mark.parametrize("df_type", df_types, ids=df_type_id) def test_null_data(df_type): """ @@ -651,7 +827,7 @@ def test_add_edge_data_bad_args(): def test_extract_subgraph_vertex_prop_condition_only(dataset1_PropertyGraph): - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph # This should result in two users: 78634 and 89216 selection = pG.select_vertices( @@ -678,7 +854,7 @@ def test_extract_subgraph_vertex_prop_condition_only(dataset1_PropertyGraph): def test_extract_subgraph_vertex_edge_prop_condition(dataset1_PropertyGraph): from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name selection = pG.select_vertices("(user_location==47906) | " @@ -702,7 +878,7 @@ def test_extract_subgraph_vertex_edge_prop_condition(dataset1_PropertyGraph): def test_extract_subgraph_edge_prop_condition_only(dataset1_PropertyGraph): from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name selection = pG.select_edges(f"{tcn} =='transactions'") @@ -732,7 +908,7 @@ def test_extract_subgraph_unweighted(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name selection = pG.select_edges(f"{tcn} == 'transactions'") @@ -749,7 +925,7 @@ def test_extract_subgraph_specific_query(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name selection = pG.select_edges(f"({tcn}=='transactions') & " @@ -777,7 +953,7 @@ def test_select_vertices_from_previous_selection(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name # Select referrals from only users 89216 and 78634 using an intentionally @@ -845,7 +1021,7 @@ def test_extract_subgraph_no_edges(dataset1_PropertyGraph): """ Valid query that only matches a single vertex. """ - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph selection = pG.select_vertices("(_TYPE_=='merchants') & (merchant_id==86)") G = pG.extract_subgraph(selection=selection) @@ -858,7 +1034,7 @@ def test_extract_subgraph_no_query(dataset1_PropertyGraph): """ Call extract with no args, should result in the entire property graph. """ - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph G = pG.extract_subgraph(create_using=DiGraph_inst, allow_multi_edges=True) @@ -881,7 +1057,7 @@ def test_extract_subgraph_multi_edges(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name # referrals has multiple edges @@ -896,7 +1072,7 @@ def test_extract_subgraph_multi_edges(dataset1_PropertyGraph): def test_extract_subgraph_bad_args(dataset1_PropertyGraph): from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name # non-PropertySelection selection @@ -932,7 +1108,7 @@ def test_extract_subgraph_default_edge_weight(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name selection = pG.select_edges(f"{tcn}=='transactions'") @@ -971,7 +1147,7 @@ def test_extract_subgraph_default_edge_weight_no_property( Ensure default_edge_weight can be used to provide an edge value when a property for the edge weight is not specified. """ - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph edge_weight = 99.2 G = pG.extract_subgraph(allow_multi_edges=True, default_edge_weight=edge_weight) @@ -1014,7 +1190,7 @@ def test_graph_edge_data_added(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph eicn = PropertyGraph.edge_id_col_name expected_num_edges = \ @@ -1052,7 +1228,7 @@ def test_annotate_dataframe(dataset1_PropertyGraph): copy=False invalid args raise correct exceptions """ - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph selection = pG.select_edges("(_TYPE_ == 'referrals') & (stars > 3)") G = pG.extract_subgraph(selection=selection, @@ -1139,7 +1315,7 @@ def test_get_vertices(dataset1_PropertyGraph): Test that get_vertices() returns the correct set of vertices without duplicates. """ - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph (merchants, users, taxpayers, transactions, relationships, referrals) = dataset1.values() @@ -1158,7 +1334,7 @@ def test_get_edges(dataset1_PropertyGraph): """ from cugraph.experimental import PropertyGraph - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph (merchants, users, taxpayers, transactions, relationships, referrals) = dataset1.values() @@ -1182,7 +1358,7 @@ def test_property_names_attrs(dataset1_PropertyGraph): Ensure the correct number of user-visible properties for vertices and edges are returned. This should exclude the internal bookkeeping properties. """ - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph expected_vert_prop_names = ["merchant_id", "merchant_location", "merchant_size", "merchant_sales", @@ -1215,17 +1391,31 @@ def test_extract_subgraph_with_vertex_ids(): raise NotImplementedError +def test_get_data_empty_graphs(): + """ + Ensures that calls to pG.get_*_data() on an empty pG are handled correctly. + """ + from cugraph.experimental import PropertyGraph + + pG = PropertyGraph() + + assert pG.get_vertex_data() is None + assert pG.get_vertex_data([0, 1, 2]) is None + assert pG.get_edge_data() is None + assert pG.get_edge_data([0, 1, 2]) is None + + # ============================================================================= # Benchmarks # ============================================================================= def bench_num_vertices(gpubenchmark, dataset1_PropertyGraph): - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph assert gpubenchmark(pG.get_num_vertices) == 9 def bench_get_vertices(gpubenchmark, dataset1_PropertyGraph): - pG = dataset1_PropertyGraph + (pG, data) = dataset1_PropertyGraph gpubenchmark(pG.get_vertices) From 1a2943419edb632158b9d64472694b88eed3238e Mon Sep 17 00:00:00 2001 From: GALI PREM SAGAR Date: Fri, 29 Jul 2022 12:19:23 -0500 Subject: [PATCH 04/19] Fix issues with day & night modes in python docs (#2471) Fixes similar issue found in: https://github.com/rapidsai/cudf/pull/11400/ Authors: - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2471 --- docs/cugraph/source/_static/custom.js | 22 +++++ docs/cugraph/source/_static/custom_styles.css | 89 +++++++++++++++++++ docs/cugraph/source/_static/params.css | 30 ------- docs/cugraph/source/conf.py | 3 +- 4 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 docs/cugraph/source/_static/custom.js create mode 100644 docs/cugraph/source/_static/custom_styles.css delete mode 100644 docs/cugraph/source/_static/params.css diff --git a/docs/cugraph/source/_static/custom.js b/docs/cugraph/source/_static/custom.js new file mode 100644 index 00000000000..567a07a7cd6 --- /dev/null +++ b/docs/cugraph/source/_static/custom.js @@ -0,0 +1,22 @@ +// Copyright (c) 2022, NVIDIA CORPORATION. + +function update_switch_theme_button() { + current_theme = document.documentElement.dataset.mode; + if (current_theme == "light") { + document.getElementById("theme-switch").title = "Switch to auto theme"; + } else if (current_theme == "auto") { + document.getElementById("theme-switch").title = "Switch to dark theme"; + } else { + document.getElementById("theme-switch").title = "Switch to light theme"; + } +} + +$(document).ready(function() { + var observer = new MutationObserver(function(mutations) { + update_switch_theme_button(); + }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); +}); diff --git a/docs/cugraph/source/_static/custom_styles.css b/docs/cugraph/source/_static/custom_styles.css new file mode 100644 index 00000000000..31db0633f7f --- /dev/null +++ b/docs/cugraph/source/_static/custom_styles.css @@ -0,0 +1,89 @@ +/* Mirrors the change in: + * https://github.com/sphinx-doc/sphinx/pull/5976 + * which is not showing up in our theme. + */ + .classifier:before { + font-style: normal; + margin: 0.5em; + content: ":"; +} + +/* Fix for text wrap in sphinx tables: +* https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html +*/ +@media screen and (min-width: 767px) { + + .wy-table-responsive table td { + /* !important prevents the common CSS stylesheets from overriding + this as on RTD they are loaded after this stylesheet */ + white-space: normal !important; + } + + .wy-table-responsive { + overflow: visible !important; + } +} + + +table.io-supported-types-table { + text-align: center +} + +table.io-supported-types-table thead{ + text-align: center !important; +} + +:root { + +--pst-color-active-navigation: 114, 83, 237; +--pst-color-navbar-link: 77, 77, 77; +--pst-color-navbar-link-hover: var(--pst-color-active-navigation); +--pst-color-navbar-link-active: var(--pst-color-active-navigation); +--pst-color-sidebar-link: 77, 77, 77; +--pst-color-sidebar-link-hover: var(--pst-color-active-navigation); +--pst-color-sidebar-link-active: var(--pst-color-active-navigation); +--pst-color-sidebar-expander-background-hover: 244, 244, 244; +--pst-color-sidebar-caption: 77, 77, 77; +--pst-color-toc-link: 119, 117, 122; +--pst-color-toc-link-hover: var(--pst-color-active-navigation); +--pst-color-toc-link-active: var(--pst-color-active-navigation); + +} + +/* Used to make special-table scrollable when it overflows */ +.special-table-wrapper { + width: 100%; + overflow: auto !important; +} + +.special-table td, .special-table th { + border: 1px solid #dee2e6; +} + +/* Needed to resolve https://github.com/executablebooks/jupyter-book/issues/1611 */ +.output.text_html { + overflow: auto; +} + +html[data-theme="light"] { + --pst-color-primary: rgb(19, 6, 84); + --pst-color-text-base: rgb(51, 51, 51); + + --pst-color-primary: rgb(19, 6, 84); + + --pst-color-link: rgb(0, 91, 129); + --pst-color-secondary: rgb(227, 46, 0); + --pst-table-background-color: transparent; +} + + +html[data-theme="dark"] { + --pst-color-primary: rgb(221, 221, 221); + --pst-color-inline-code: rgb(248, 6, 204); + --pst-table-background-color: var(--pst-color-text-muted); + +} + +div.cell_output table{ + background: var(--pst-table-background-color); +} diff --git a/docs/cugraph/source/_static/params.css b/docs/cugraph/source/_static/params.css deleted file mode 100644 index b57bcdb7c7c..00000000000 --- a/docs/cugraph/source/_static/params.css +++ /dev/null @@ -1,30 +0,0 @@ -/* Mirrors the change in: - * https://github.com/sphinx-doc/sphinx/pull/5976 - * which is not showing up in our theme. - */ -.classifier:before { - font-style: normal; - margin: 0.5em; - content: ":"; -} - -:root { - - --pst-color-active-navigation: 114, 83, 237; - --pst-color-navbar-link: 77, 77, 77; - --pst-color-navbar-link-hover: var(--pst-color-active-navigation); - --pst-color-navbar-link-active: var(--pst-color-active-navigation); - --pst-color-sidebar-link: 77, 77, 77; - --pst-color-sidebar-link-hover: var(--pst-color-active-navigation); - --pst-color-sidebar-link-active: var(--pst-color-active-navigation); - --pst-color-sidebar-expander-background-hover: 244, 244, 244; - --pst-color-sidebar-caption: 77, 77, 77; - --pst-color-toc-link: 119, 117, 122; - --pst-color-toc-link-hover: var(--pst-color-active-navigation); - --pst-color-toc-link-active: var(--pst-color-active-navigation); - -} - -.special-table td, .special-table th { - border: 1px solid #dee2e6; -} \ No newline at end of file diff --git a/docs/cugraph/source/conf.py b/docs/cugraph/source/conf.py index 9833c1bd8b4..1f6dc19a35b 100644 --- a/docs/cugraph/source/conf.py +++ b/docs/cugraph/source/conf.py @@ -212,7 +212,8 @@ def setup(app): - app.add_css_file('params.css') + app.add_css_file('custom_styles.css') + app.add_js_file('custom.js') app.add_css_file('references.css') From 93e15c0f88d7f0e2bffaf83a82d696b6aabea8cf Mon Sep 17 00:00:00 2001 From: Rick Ratzel <3039903+rlratzel@users.noreply.github.com> Date: Mon, 1 Aug 2022 08:54:25 -0400 Subject: [PATCH 05/19] Updated imports to be compatible with latest version of cupy (#2473) Updated imports to be compatible with latest version of cupy and also changed corresponding scipy imports for consistency. Authors: - Rick Ratzel (https://github.com/rlratzel) Approvers: - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2473 --- python/cugraph/cugraph/tests/test_bfs.py | 12 ++++++------ python/cugraph/cugraph/tests/test_connectivity.py | 12 ++++++------ python/cugraph/cugraph/tests/test_sssp.py | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/python/cugraph/cugraph/tests/test_bfs.py b/python/cugraph/cugraph/tests/test_bfs.py index fb2dd5de122..0009d4b5250 100644 --- a/python/cugraph/cugraph/tests/test_bfs.py +++ b/python/cugraph/cugraph/tests/test_bfs.py @@ -23,12 +23,12 @@ import pandas as pd import cupy as cp -from cupyx.scipy.sparse.coo import coo_matrix as cp_coo_matrix -from cupyx.scipy.sparse.csr import csr_matrix as cp_csr_matrix -from cupyx.scipy.sparse.csc import csc_matrix as cp_csc_matrix -from scipy.sparse.coo import coo_matrix as sp_coo_matrix -from scipy.sparse.csr import csr_matrix as sp_csr_matrix -from scipy.sparse.csc import csc_matrix as sp_csc_matrix +from cupyx.scipy.sparse import coo_matrix as cp_coo_matrix +from cupyx.scipy.sparse import csr_matrix as cp_csr_matrix +from cupyx.scipy.sparse import csc_matrix as cp_csc_matrix +from scipy.sparse import coo_matrix as sp_coo_matrix +from scipy.sparse import csr_matrix as sp_csr_matrix +from scipy.sparse import csc_matrix as sp_csc_matrix # Temporarily suppress warnings till networkX fixes deprecation warnings # (Using or importing the ABCs from 'collections' instead of from diff --git a/python/cugraph/cugraph/tests/test_connectivity.py b/python/cugraph/cugraph/tests/test_connectivity.py index 0de9084ffcc..6d31c39c447 100644 --- a/python/cugraph/cugraph/tests/test_connectivity.py +++ b/python/cugraph/cugraph/tests/test_connectivity.py @@ -18,12 +18,12 @@ import pytest import cupy as cp import numpy as np -from cupyx.scipy.sparse.coo import coo_matrix as cp_coo_matrix -from cupyx.scipy.sparse.csr import csr_matrix as cp_csr_matrix -from cupyx.scipy.sparse.csc import csc_matrix as cp_csc_matrix -from scipy.sparse.coo import coo_matrix as sp_coo_matrix -from scipy.sparse.csr import csr_matrix as sp_csr_matrix -from scipy.sparse.csc import csc_matrix as sp_csc_matrix +from cupyx.scipy.sparse import coo_matrix as cp_coo_matrix +from cupyx.scipy.sparse import csr_matrix as cp_csr_matrix +from cupyx.scipy.sparse import csc_matrix as cp_csc_matrix +from scipy.sparse import coo_matrix as sp_coo_matrix +from scipy.sparse import csr_matrix as sp_csr_matrix +from scipy.sparse import csc_matrix as sp_csc_matrix import cudf import cugraph diff --git a/python/cugraph/cugraph/tests/test_sssp.py b/python/cugraph/cugraph/tests/test_sssp.py index aad06a51e69..ac6c7662855 100644 --- a/python/cugraph/cugraph/tests/test_sssp.py +++ b/python/cugraph/cugraph/tests/test_sssp.py @@ -18,12 +18,12 @@ import pytest import pandas as pd import cupy as cp -from cupyx.scipy.sparse.coo import coo_matrix as cp_coo_matrix -from cupyx.scipy.sparse.csr import csr_matrix as cp_csr_matrix -from cupyx.scipy.sparse.csc import csc_matrix as cp_csc_matrix -from scipy.sparse.coo import coo_matrix as sp_coo_matrix -from scipy.sparse.csr import csr_matrix as sp_csr_matrix -from scipy.sparse.csc import csc_matrix as sp_csc_matrix +from cupyx.scipy.sparse import coo_matrix as cp_coo_matrix +from cupyx.scipy.sparse import csr_matrix as cp_csr_matrix +from cupyx.scipy.sparse import csc_matrix as cp_csc_matrix +from scipy.sparse import coo_matrix as sp_coo_matrix +from scipy.sparse import csr_matrix as sp_csr_matrix +from scipy.sparse import csc_matrix as sp_csc_matrix import cudf import cugraph From 2a57740c8eac484ddd1a200f0e93f0dbb4062679 Mon Sep 17 00:00:00 2001 From: Ralph Liu <106174412+oorliu@users.noreply.github.com> Date: Mon, 1 Aug 2022 19:17:01 -0400 Subject: [PATCH 06/19] Datasets API Update: Add Extra Params and Improve Testing (#2453) Adding two new parameters to the `get_graph()` method within the datasets API. - `default_direction` allows users to get only undirected graph objects. - `weights` will specify whether or not the cugraph.Graph object has an `edge_attr` field. `test_dataset.py` has been updated to: 1. Add coverage for the new parameters 2. Removing fetching datasets from the web when it's unnecessary. Docstrings have also been updated for clarity. Authors: - Ralph Liu (https://github.com/oorliu) Approvers: - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2453 --- .../cugraph/experimental/datasets/__init__.py | 24 +++++++---- .../cugraph/experimental/datasets/dataset.py | 41 +++++++++++++++---- .../datasets/metadata/karate.yaml | 8 ++-- .../datasets/metadata/karate_data.yaml | 21 ++++++++++ python/cugraph/cugraph/tests/test_dataset.py | 41 +++++++++++++++---- 5 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 python/cugraph/cugraph/experimental/datasets/metadata/karate_data.yaml diff --git a/python/cugraph/cugraph/experimental/datasets/__init__.py b/python/cugraph/cugraph/experimental/datasets/__init__.py index fedf9d0dbda..5f355eb8cbc 100644 --- a/python/cugraph/cugraph/experimental/datasets/__init__.py +++ b/python/cugraph/cugraph/experimental/datasets/__init__.py @@ -27,6 +27,7 @@ meta_path = Path(__file__).parent / "metadata" karate = Dataset(meta_path / "karate.yaml") +karate_data = Dataset(meta_path / "karate_data.yaml") karate_undirected = Dataset(meta_path / "karate_undirected.yaml") karate_asymmetric = Dataset(meta_path / "karate_asymmetric.yaml") dolphins = Dataset(meta_path / "dolphins.yaml") @@ -37,15 +38,20 @@ small_tree = Dataset(meta_path / "small_tree.yaml") -# LARGE DATASETS -LARGE_DATASETS = [cyber] +MEDIUM_DATASETS = [polbooks] -# <10,000 lines -MEDIUM_DATASETS = [netscience, polbooks] +SMALL_DATASETS = [karate, dolphins, netscience] -# <500 lines -SMALL_DATASETS = [karate, small_line, small_tree, dolphins] +RLY_SMALL_DATASETS = [small_line, small_tree] -# ALL -ALL_DATASETS = [karate, dolphins, netscience, polbooks, cyber, - small_line, small_tree] \ No newline at end of file +ALL_DATASETS = [karate, dolphins, netscience, polbooks, + small_line, small_tree] + +ALL_DATASETS_WGT = [karate, dolphins, netscience, polbooks, + small_line, small_tree] + +TEST_GROUP = [dolphins, netscience] + +DATASETS_KTRUSS = [polbooks] + +DATASETS_UNDIRECTED = [karate_undirected, small_line, karate_asymmetric] \ No newline at end of file diff --git a/python/cugraph/cugraph/experimental/datasets/dataset.py b/python/cugraph/cugraph/experimental/datasets/dataset.py index 3ae904904f6..f5595e1f354 100644 --- a/python/cugraph/cugraph/experimental/datasets/dataset.py +++ b/python/cugraph/cugraph/experimental/datasets/dataset.py @@ -11,11 +11,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import cugraph import cudf import yaml import os from pathlib import Path +from cugraph.structure.graph_classes import Graph class DefaultDownloadDir: @@ -64,7 +64,6 @@ class Dataset: The metadata file for the specific graph dataset, which includes information on the name, type, url link, data loading format, graph properties - """ def __init__(self, meta_data_file_name): with open(meta_data_file_name, 'r') as file: @@ -118,22 +117,48 @@ def get_edgelist(self, fetch=False): return self._edgelist - def get_graph(self, fetch=False): + def get_graph(self, fetch=False, create_using=Graph, ignore_weights=False): """ Return a Graph object. Parameters ---------- fetch : Boolean (default=False) - Automatically fetch for the dataset from the 'url' location within - the YAML file. + Downloads the dataset from the web. + + create_using: cugraph.Graph (instance or class), optional + (default=Graph) + Specify the type of Graph to create. Can pass in an instance to + create a Graph instance with specified 'directed' attribute. + + ignore_weights : Boolean (default=False) + Ignores weights in the dataset if True, resulting in an + unweighted Graph. If False (the default), weights from the + dataset -if present- will be applied to the Graph. If the + dataset does not contain weights, the Graph returned will + be unweighted regardless of ignore_weights. """ if self._edgelist is None: self.get_edgelist(fetch) - self._graph = cugraph.Graph(directed=self.metadata['is_directed']) - self._graph.from_cudf_edgelist(self._edgelist, source='src', - destination='dst') + if create_using is None: + self._graph = Graph() + elif isinstance(create_using, Graph): + attrs = {"directed": create_using.is_directed()} + self._graph = type(create_using)(**attrs) + elif type(create_using) is type: + self._graph = create_using() + else: + raise TypeError("create_using must be a cugraph.Graph " + "(or subclass) type or instance, got: " + f"{type(create_using)}") + + if (len(self.metadata['col_names']) > 2 and not(ignore_weights)): + self._graph.from_cudf_edgelist(self._edgelist, source='src', + destination='dst', edge_attr='wgt') + else: + self._graph.from_cudf_edgelist(self._edgelist, source='src', + destination='dst') return self._graph diff --git a/python/cugraph/cugraph/experimental/datasets/metadata/karate.yaml b/python/cugraph/cugraph/experimental/datasets/metadata/karate.yaml index d86c7b1a241..9b7ac679e96 100644 --- a/python/cugraph/cugraph/experimental/datasets/metadata/karate.yaml +++ b/python/cugraph/cugraph/experimental/datasets/metadata/karate.yaml @@ -1,17 +1,19 @@ -name: karate-data +name: karate file_type: .csv author: Zachary W. -url: https://raw.githubusercontent.com/rapidsai/cugraph/branch-22.08/datasets/karate-data.csv +url: https://raw.githubusercontent.com/rapidsai/cugraph/branch-22.08/datasets/karate.csv refs: W. W. Zachary, An information flow model for conflict and fission in small groups, Journal of Anthropological Research 33, 452-473 (1977). -delim: "\t" +delim: " " col_names: - src - dst + - wgt col_types: - int32 - int32 + - float32 has_loop: true is_directed: true is_multigraph: false diff --git a/python/cugraph/cugraph/experimental/datasets/metadata/karate_data.yaml b/python/cugraph/cugraph/experimental/datasets/metadata/karate_data.yaml new file mode 100644 index 00000000000..d86c7b1a241 --- /dev/null +++ b/python/cugraph/cugraph/experimental/datasets/metadata/karate_data.yaml @@ -0,0 +1,21 @@ +name: karate-data +file_type: .csv +author: Zachary W. +url: https://raw.githubusercontent.com/rapidsai/cugraph/branch-22.08/datasets/karate-data.csv +refs: + W. W. Zachary, An information flow model for conflict and fission in small groups, + Journal of Anthropological Research 33, 452-473 (1977). +delim: "\t" +col_names: + - src + - dst +col_types: + - int32 + - int32 +has_loop: true +is_directed: true +is_multigraph: false +is_symmetric: true +number_of_edges: 156 +number_of_nodes: 34 +number_of_lines: 156 diff --git a/python/cugraph/cugraph/tests/test_dataset.py b/python/cugraph/cugraph/tests/test_dataset.py index 093f1382bcb..9d9078af9d1 100644 --- a/python/cugraph/cugraph/tests/test_dataset.py +++ b/python/cugraph/cugraph/tests/test_dataset.py @@ -17,13 +17,18 @@ import os from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory -from cugraph.experimental.datasets import (ALL_DATASETS) +from cugraph.experimental.datasets import (ALL_DATASETS, ALL_DATASETS_WGT, + SMALL_DATASETS) +from cugraph.structure import Graph # ============================================================================= # Pytest Setup / Teardown - called for each test function # ============================================================================= +dataset_path = Path(__file__).parents[4] / "datasets" + + # Use this to simulate a fresh API import @pytest.fixture def datasets(): @@ -125,25 +130,19 @@ def test_fetch(dataset, datasets): @pytest.mark.parametrize("dataset", ALL_DATASETS) def test_get_edgelist(dataset, datasets): - tmpd = TemporaryDirectory() - datasets.set_download_dir(tmpd.name) + datasets.set_download_dir(dataset_path) E = dataset.get_edgelist(fetch=True) assert E is not None - tmpd.cleanup() - @pytest.mark.parametrize("dataset", ALL_DATASETS) def test_get_graph(dataset, datasets): - tmpd = TemporaryDirectory() - datasets.set_download_dir(tmpd.name) + datasets.set_download_dir(dataset_path) G = dataset.get_graph(fetch=True) assert G is not None - tmpd.cleanup() - @pytest.mark.parametrize("dataset", ALL_DATASETS) def test_metadata(dataset): @@ -167,3 +166,27 @@ def test_get_path(dataset, datasets): def test_get_path_raises(dataset): with pytest.raises(RuntimeError): dataset.get_path() + + +@pytest.mark.parametrize("dataset", ALL_DATASETS_WGT) +def test_weights(dataset, datasets): + datasets.set_download_dir(dataset_path) + + G_w = dataset.get_graph(fetch=True) + G = dataset.get_graph(fetch=True, ignore_weights=True) + + assert G_w.is_weighted() + assert not G.is_weighted() + + +@pytest.mark.parametrize("dataset", SMALL_DATASETS) +def test_create_using(dataset, datasets): + datasets.set_download_dir(dataset_path) + + G_d = dataset.get_graph() + G_t = dataset.get_graph(create_using=Graph) + G = dataset.get_graph(create_using=Graph(directed=True)) + + assert not G_d.is_directed() + assert not G_t.is_directed() + assert G.is_directed() From 2e319e30f2738004307bddece06508c608fb7718 Mon Sep 17 00:00:00 2001 From: GALI PREM SAGAR Date: Tue, 2 Aug 2022 14:10:00 -0500 Subject: [PATCH 07/19] Centralize common `css` & `js` code in docs (#2472) This PR will utilize the common `css` & `js` code being merged here: https://github.com/rapidsai/docs/pull/286 Authors: - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - Rick Ratzel (https://github.com/rlratzel) - AJ Schmidt (https://github.com/ajschmidt8) - Chuck Hastings (https://github.com/ChuckHastings) URL: https://github.com/rapidsai/cugraph/pull/2472 --- cpp/doxygen/Doxyfile | 328 ++++++++++++------ cpp/doxygen/header.html | 62 ++++ docs/cugraph/source/_static/custom.js | 22 -- docs/cugraph/source/_static/custom_styles.css | 89 ----- docs/cugraph/source/conf.py | 6 +- 5 files changed, 280 insertions(+), 227 deletions(-) create mode 100644 cpp/doxygen/header.html delete mode 100644 docs/cugraph/source/_static/custom.js delete mode 100644 docs/cugraph/source/_static/custom_styles.css diff --git a/cpp/doxygen/Doxyfile b/cpp/doxygen/Doxyfile index f25ec774a21..55f45798b6b 100644 --- a/cpp/doxygen/Doxyfile +++ b/cpp/doxygen/Doxyfile @@ -1,4 +1,4 @@ -# Doxyfile 1.8.11 +# Doxyfile 1.8.20 # This file describes the settings to be used by the documentation system # doxygen (www.doxygen.org) for a project. @@ -17,11 +17,11 @@ # Project related configuration options #--------------------------------------------------------------------------- -# This tag specifies the encoding used for all characters in the config file -# that follow. The default is UTF-8 which is also the encoding used for all text -# before the first occurrence of this tag. Doxygen uses libiconv (or the iconv -# built into libc) for the transcoding. See http://www.gnu.org/software/libiconv -# for the list of possible encodings. +# This tag specifies the encoding used for all characters in the configuration +# file that follow. The default is UTF-8 which is also the encoding used for all +# text before the first occurrence of this tag. Doxygen uses libiconv (or the +# iconv built into libc) for the transcoding. See +# https://www.gnu.org/software/libiconv/ for the list of possible encodings. # The default value is: UTF-8. DOXYFILE_ENCODING = UTF-8 @@ -93,6 +93,14 @@ ALLOW_UNICODE_NAMES = NO OUTPUT_LANGUAGE = English +# The OUTPUT_TEXT_DIRECTION tag is used to specify the direction in which all +# documentation generated by doxygen is written. Doxygen will use this +# information to generate all generated output in the proper direction. +# Possible values are: None, LTR, RTL and Context. +# The default value is: None. + +OUTPUT_TEXT_DIRECTION = None + # If the BRIEF_MEMBER_DESC tag is set to YES, doxygen will include brief member # descriptions after the members that are listed in the file and class # documentation (similar to Javadoc). Set to NO to disable this. @@ -179,6 +187,16 @@ SHORT_NAMES = NO JAVADOC_AUTOBRIEF = NO +# If the JAVADOC_BANNER tag is set to YES then doxygen will interpret a line +# such as +# /*************** +# as being the beginning of a Javadoc-style comment "banner". If set to NO, the +# Javadoc-style will behave just like regular comments and it will not be +# interpreted by doxygen. +# The default value is: NO. + +JAVADOC_BANNER = NO + # If the QT_AUTOBRIEF tag is set to YES then doxygen will interpret the first # line (until the first dot) of a Qt-style comment as the brief description. If # set to NO, the Qt-style will behave just like regular Qt-style comments (thus @@ -199,6 +217,14 @@ QT_AUTOBRIEF = NO MULTILINE_CPP_IS_BRIEF = NO +# By default Python docstrings are displayed as preformatted text and doxygen's +# special commands cannot be used. By setting PYTHON_DOCSTRING to NO the +# doxygen's special commands can be used and the contents of the docstring +# documentation blocks is shown as doxygen documentation. +# The default value is: YES. + +PYTHON_DOCSTRING = YES + # If the INHERIT_DOCS tag is set to YES then an undocumented member inherits the # documentation from any documented member that it re-implements. # The default value is: YES. @@ -226,16 +252,15 @@ TAB_SIZE = 4 # will allow you to put the command \sideeffect (or @sideeffect) in the # documentation, which will result in a user-defined paragraph with heading # "Side Effects:". You can put \n's in the value part of an alias to insert -# newlines. +# newlines (in the resulting output). You can put ^^ in the value part of an +# alias to insert a newline as if a physical newline was in the original file. +# When you need a literal { or } or , in the value part of an alias you have to +# escape them by means of a backslash (\), this can lead to conflicts with the +# commands \{ and \} for these it is advised to use the version @{ and @} or use +# a double escape (\\{ and \\}) ALIASES = -# This tag can be used to specify a number of word-keyword mappings (TCL only). -# A mapping has the form "name=value". For example adding "class=itcl::class" -# will allow you to use the command class in the itcl::class meaning. - -TCL_SUBST = - # Set the OPTIMIZE_OUTPUT_FOR_C tag to YES if your project consists of C sources # only. Doxygen will then generate output that is more tailored for C. For # instance, some of the names that are used will be different. The list of all @@ -264,28 +289,38 @@ OPTIMIZE_FOR_FORTRAN = NO OPTIMIZE_OUTPUT_VHDL = NO +# Set the OPTIMIZE_OUTPUT_SLICE tag to YES if your project consists of Slice +# sources only. Doxygen will then generate output that is more tailored for that +# language. For instance, namespaces will be presented as modules, types will be +# separated into more groups, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_SLICE = NO + # Doxygen selects the parser to use depending on the extension of the files it # parses. With this tag you can assign which parser to use for a given # extension. Doxygen has a built-in mapping, but you can override or extend it # using this tag. The format is ext=language, where ext is a file extension, and -# language is one of the parsers supported by doxygen: IDL, Java, Javascript, -# C#, C, C++, D, PHP, Objective-C, Python, Fortran (fixed format Fortran: -# FortranFixed, free formatted Fortran: FortranFree, unknown formatted Fortran: -# Fortran. In the later case the parser tries to guess whether the code is fixed -# or free formatted code, this is the default for Fortran type files), VHDL. For -# instance to make doxygen treat .inc files as Fortran files (default is PHP), -# and .f files as C (default is Fortran), use: inc=Fortran f=C. +# language is one of the parsers supported by doxygen: IDL, Java, JavaScript, +# Csharp (C#), C, C++, D, PHP, md (Markdown), Objective-C, Python, Slice, VHDL, +# Fortran (fixed format Fortran: FortranFixed, free formatted Fortran: +# FortranFree, unknown formatted Fortran: Fortran. In the later case the parser +# tries to guess whether the code is fixed or free formatted code, this is the +# default for Fortran type files). For instance to make doxygen treat .inc files +# as Fortran files (default is PHP), and .f files as C (default is Fortran), +# use: inc=Fortran f=C. # # Note: For files without extension you can use no_extension as a placeholder. # # Note that for custom extensions you also need to set FILE_PATTERNS otherwise # the files are not read by doxygen. -EXTENSION_MAPPING = cu=C++ cuh=C++ +EXTENSION_MAPPING = cu=C++ \ + cuh=C++ # If the MARKDOWN_SUPPORT tag is enabled then doxygen pre-processes all comments # according to the Markdown format, which allows for more readable -# documentation. See http://daringfireball.net/projects/markdown/ for details. +# documentation. See https://daringfireball.net/projects/markdown/ for details. # The output of markdown processing is further processed by doxygen, so you can # mix doxygen, HTML, and XML commands with Markdown formatting. Disable only in # case of backward compatibilities issues. @@ -293,6 +328,15 @@ EXTENSION_MAPPING = cu=C++ cuh=C++ MARKDOWN_SUPPORT = YES +# When the TOC_INCLUDE_HEADINGS tag is set to a non-zero value, all headings up +# to that level are automatically included in the table of contents, even if +# they do not have an id attribute. +# Note: This feature currently applies only to Markdown headings. +# Minimum value: 0, maximum value: 99, default value: 5. +# This tag requires that the tag MARKDOWN_SUPPORT is set to YES. + +TOC_INCLUDE_HEADINGS = 5 + # When enabled doxygen tries to link words that correspond to documented # classes, or namespaces to their corresponding documentation. Such a link can # be prevented in individual cases by putting a % sign in front of the word or @@ -318,7 +362,7 @@ BUILTIN_STL_SUPPORT = NO CPP_CLI_SUPPORT = NO # Set the SIP_SUPPORT tag to YES if your project consists of sip (see: -# http://www.riverbankcomputing.co.uk/software/sip/intro) sources only. Doxygen +# https://www.riverbankcomputing.com/software/sip/intro) sources only. Doxygen # will parse them like normal C++ but will assume all classes use public instead # of private inheritance when no explicit protection keyword is present. # The default value is: NO. @@ -404,6 +448,19 @@ TYPEDEF_HIDES_STRUCT = NO LOOKUP_CACHE_SIZE = 0 +# The NUM_PROC_THREADS specifies the number threads doxygen is allowed to use +# during processing. When set to 0 doxygen will based this on the number of +# cores available in the system. You can set it explicitly to a value larger +# than 0 to get more control over the balance between CPU load and processing +# speed. At this moment only the input processing can be done using multiple +# threads. Since this is still an experimental feature the default is set to 1, +# which efficively disables parallel processing. Please report any issues you +# encounter. Generating dot graphs in parallel is controlled by the +# DOT_NUM_THREADS setting. +# Minimum value: 0, maximum value: 32, default value: 1. + +NUM_PROC_THREADS = 1 + #--------------------------------------------------------------------------- # Build related configuration options #--------------------------------------------------------------------------- @@ -424,6 +481,12 @@ EXTRACT_ALL = NO EXTRACT_PRIVATE = NO +# If the EXTRACT_PRIV_VIRTUAL tag is set to YES, documented private virtual +# methods of a class will be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIV_VIRTUAL = NO + # If the EXTRACT_PACKAGE tag is set to YES, all members with package or internal # scope will be included in the documentation. # The default value is: NO. @@ -478,8 +541,8 @@ HIDE_UNDOC_MEMBERS = NO HIDE_UNDOC_CLASSES = NO # If the HIDE_FRIEND_COMPOUNDS tag is set to YES, doxygen will hide all friend -# (class|struct|union) declarations. If set to NO, these declarations will be -# included in the documentation. +# declarations. If set to NO, these declarations will be included in the +# documentation. # The default value is: NO. HIDE_FRIEND_COMPOUNDS = NO @@ -502,7 +565,7 @@ INTERNAL_DOCS = NO # names in lower-case letters. If set to YES, upper-case letters are also # allowed. This is useful if you have classes or files whose names only differ # in case and if your file system supports case sensitive file names. Windows -# and Mac users are advised to set this option to NO. +# (including Cygwin) and Mac users are advised to set this option to NO. # The default value is: system dependent. CASE_SENSE_NAMES = YES @@ -689,7 +752,7 @@ LAYOUT_FILE = # The CITE_BIB_FILES tag can be used to specify one or more bib files containing # the reference definitions. This must be a list of .bib files. The .bib # extension is automatically appended if omitted. This requires the bibtex tool -# to be installed. See also http://en.wikipedia.org/wiki/BibTeX for more info. +# to be installed. See also https://en.wikipedia.org/wiki/BibTeX for more info. # For LaTeX the style of the bibliography can be controlled using # LATEX_BIB_STYLE. To use this feature you need bibtex and perl available in the # search path. See also \cite for info how to create references. @@ -734,7 +797,8 @@ WARN_IF_DOC_ERROR = YES # This WARN_NO_PARAMDOC option can be enabled to get warnings for functions that # are documented, but have no documentation for their parameters or return # value. If set to NO, doxygen will only warn about wrong or incomplete -# parameter documentation, but not about the absence of documentation. +# parameter documentation, but not about the absence of documentation. If +# EXTRACT_ALL is set to YES then this flag will automatically be disabled. # The default value is: NO. WARN_NO_PARAMDOC = YES @@ -771,12 +835,14 @@ WARN_LOGFILE = # spaces. See also FILE_PATTERNS and EXTENSION_MAPPING # Note: If this tag is empty the current directory is searched. -INPUT = main_page.md ../src ../include +INPUT = main_page.md \ + ../src \ + ../include # This tag can be used to specify the character encoding of the source files # that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses # libiconv (or the iconv built into libc) for the transcoding. See the libiconv -# documentation (see: http://www.gnu.org/software/libiconv) for the list of +# documentation (see: https://www.gnu.org/software/libiconv/) for the list of # possible encodings. # The default value is: UTF-8. @@ -793,10 +859,17 @@ INPUT_ENCODING = UTF-8 # If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cpp, # *.c++, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, *.ddl, *.odl, *.h, # *.hh, *.hxx, *.hpp, *.h++, *.cs, *.d, *.php, *.php4, *.php5, *.phtml, *.inc, -# *.m, *.markdown, *.md, *.mm, *.dox, *.py, *.pyw, *.f90, *.f, *.for, *.tcl, -# *.vhd, *.vhdl, *.ucf, *.qsf, *.as and *.js. - -FILE_PATTERNS = *.cpp *.hpp *.h *.c *.cu *.cuh +# *.m, *.markdown, *.md, *.mm, *.dox (to be provided as doxygen C comment), +# *.doc (to be provided as doxygen C comment), *.txt (to be provided as doxygen +# C comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, *.f18, *.f, *.for, *.vhd, +# *.vhdl, *.ucf, *.qsf and *.ice. + +FILE_PATTERNS = *.cpp \ + *.hpp \ + *.h \ + *.c \ + *.cu \ + *.cuh # The RECURSIVE tag can be used to specify whether or not subdirectories should # be searched for input files as well. @@ -827,7 +900,8 @@ EXCLUDE_SYMLINKS = NO # Note that the wildcards are matched against the file with absolute path, so to # exclude all test directories for example use the pattern */test/* -EXCLUDE_PATTERNS = */nvtx/* */nvstrings/* +EXCLUDE_PATTERNS = */nvtx/* \ + */nvstrings/* # The EXCLUDE_SYMBOLS tag can be used to specify one or more symbol names # (namespaces, classes, functions, etc.) that should be excluded from the @@ -898,7 +972,7 @@ INPUT_FILTER = # need to set EXTENSION_MAPPING for the extension otherwise the files are not # properly processed by doxygen. -FILTER_PATTERNS = +FILTER_PATTERNS = # If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using # INPUT_FILTER) will also be used to filter the input files that are used for @@ -949,7 +1023,7 @@ INLINE_SOURCES = NO STRIP_CODE_COMMENTS = YES # If the REFERENCED_BY_RELATION tag is set to YES then for each documented -# function all documented functions referencing it will be listed. +# entity all documented functions referencing it will be listed. # The default value is: NO. REFERENCED_BY_RELATION = NO @@ -981,12 +1055,12 @@ SOURCE_TOOLTIPS = YES # If the USE_HTAGS tag is set to YES then the references to source code will # point to the HTML generated by the htags(1) tool instead of doxygen built-in # source browser. The htags tool is part of GNU's global source tagging system -# (see http://www.gnu.org/software/global/global.html). You will need version +# (see https://www.gnu.org/software/global/global.html). You will need version # 4.8.6 or higher. # # To use it do the following: # - Install the latest version of global -# - Enable SOURCE_BROWSER and USE_HTAGS in the config file +# - Enable SOURCE_BROWSER and USE_HTAGS in the configuration file # - Make sure the INPUT points to the root of the source tree # - Run doxygen as normal # @@ -1008,25 +1082,6 @@ USE_HTAGS = NO VERBATIM_HEADERS = YES -# If the CLANG_ASSISTED_PARSING tag is set to YES then doxygen will use the -# clang parser (see: http://clang.llvm.org/) for more accurate parsing at the -# cost of reduced performance. This can be particularly helpful with template -# rich C++ code for which doxygen's built-in parser lacks the necessary type -# information. -# Note: The availability of this option depends on whether or not doxygen was -# generated with the -Duse-libclang=ON option for CMake. -# The default value is: NO. - -CLANG_ASSISTED_PARSING = NO - -# If clang assisted parsing is enabled you can provide the compiler with command -# line options that you would normally use when invoking the compiler. Note that -# the include paths will already be set by doxygen for the files and directories -# specified with INPUT and INCLUDE_PATH. -# This tag requires that the tag CLANG_ASSISTED_PARSING is set to YES. - -CLANG_OPTIONS = - #--------------------------------------------------------------------------- # Configuration options related to the alphabetical class index #--------------------------------------------------------------------------- @@ -1095,7 +1150,7 @@ HTML_FILE_EXTENSION = .html # of the possible markers and block names see the documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_HEADER = +HTML_HEADER = header.html # The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each # generated HTML page. If the tag is left blank doxygen will generate a standard @@ -1145,7 +1200,7 @@ HTML_EXTRA_FILES = # The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen # will adjust the colors in the style sheet and background images according to # this color. Hue is specified as an angle on a colorwheel, see -# http://en.wikipedia.org/wiki/Hue for more information. For instance the value +# https://en.wikipedia.org/wiki/Hue for more information. For instance the value # 0 represents red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300 # purple, and 360 is red again. # Minimum value: 0, maximum value: 359, default value: 220. @@ -1181,6 +1236,17 @@ HTML_COLORSTYLE_GAMMA = 80 HTML_TIMESTAMP = NO +# If the HTML_DYNAMIC_MENUS tag is set to YES then the generated HTML +# documentation will contain a main index with vertical navigation menus that +# are dynamically created via JavaScript. If disabled, the navigation index will +# consists of multiple levels of tabs that are statically embedded in every HTML +# page. Disable this option to support browsers that do not have JavaScript, +# like the Qt help browser. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_MENUS = YES + # If the HTML_DYNAMIC_SECTIONS tag is set to YES then the generated HTML # documentation will contain sections that can be hidden and shown after the # page has loaded. @@ -1204,13 +1270,13 @@ HTML_INDEX_NUM_ENTRIES = 100 # If the GENERATE_DOCSET tag is set to YES, additional index files will be # generated that can be used as input for Apple's Xcode 3 integrated development -# environment (see: http://developer.apple.com/tools/xcode/), introduced with -# OSX 10.5 (Leopard). To create a documentation set, doxygen will generate a +# environment (see: https://developer.apple.com/xcode/), introduced with OSX +# 10.5 (Leopard). To create a documentation set, doxygen will generate a # Makefile in the HTML output directory. Running make will produce the docset in # that directory and running make install will install the docset in # ~/Library/Developer/Shared/Documentation/DocSets so that Xcode will find it at -# startup. See http://developer.apple.com/tools/creatingdocsetswithdoxygen.html -# for more information. +# startup. See https://developer.apple.com/library/archive/featuredarticles/Doxy +# genXcode/_index.html for more information. # The default value is: NO. # This tag requires that the tag GENERATE_HTML is set to YES. @@ -1249,7 +1315,7 @@ DOCSET_PUBLISHER_NAME = Publisher # If the GENERATE_HTMLHELP tag is set to YES then doxygen generates three # additional HTML index files: index.hhp, index.hhc, and index.hhk. The # index.hhp is a project file that can be read by Microsoft's HTML Help Workshop -# (see: http://www.microsoft.com/en-us/download/details.aspx?id=21138) on +# (see: https://www.microsoft.com/en-us/download/details.aspx?id=21138) on # Windows. # # The HTML Help Workshop contains a compiler that can convert all HTML output @@ -1280,7 +1346,7 @@ CHM_FILE = HHC_LOCATION = # The GENERATE_CHI flag controls if a separate .chi index file is generated -# (YES) or that it should be included in the master .chm file (NO). +# (YES) or that it should be included in the main .chm file (NO). # The default value is: NO. # This tag requires that the tag GENERATE_HTMLHELP is set to YES. @@ -1325,7 +1391,7 @@ QCH_FILE = # The QHP_NAMESPACE tag specifies the namespace to use when generating Qt Help # Project output. For more information please see Qt Help Project / Namespace -# (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#namespace). +# (see: https://doc.qt.io/archives/qt-4.8/qthelpproject.html#namespace). # The default value is: org.doxygen.Project. # This tag requires that the tag GENERATE_QHP is set to YES. @@ -1333,7 +1399,7 @@ QHP_NAMESPACE = org.doxygen.Project # The QHP_VIRTUAL_FOLDER tag specifies the namespace to use when generating Qt # Help Project output. For more information please see Qt Help Project / Virtual -# Folders (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#virtual- +# Folders (see: https://doc.qt.io/archives/qt-4.8/qthelpproject.html#virtual- # folders). # The default value is: doc. # This tag requires that the tag GENERATE_QHP is set to YES. @@ -1342,7 +1408,7 @@ QHP_VIRTUAL_FOLDER = doc # If the QHP_CUST_FILTER_NAME tag is set, it specifies the name of a custom # filter to add. For more information please see Qt Help Project / Custom -# Filters (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#custom- +# Filters (see: https://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom- # filters). # This tag requires that the tag GENERATE_QHP is set to YES. @@ -1350,7 +1416,7 @@ QHP_CUST_FILTER_NAME = # The QHP_CUST_FILTER_ATTRS tag specifies the list of the attributes of the # custom filter to add. For more information please see Qt Help Project / Custom -# Filters (see: http://qt-project.org/doc/qt-4.8/qthelpproject.html#custom- +# Filters (see: https://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom- # filters). # This tag requires that the tag GENERATE_QHP is set to YES. @@ -1358,7 +1424,7 @@ QHP_CUST_FILTER_ATTRS = # The QHP_SECT_FILTER_ATTRS tag specifies the list of the attributes this # project's filter section matches. Qt Help Project / Filter Attributes (see: -# http://qt-project.org/doc/qt-4.8/qthelpproject.html#filter-attributes). +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#filter-attributes). # This tag requires that the tag GENERATE_QHP is set to YES. QHP_SECT_FILTER_ATTRS = @@ -1442,6 +1508,17 @@ TREEVIEW_WIDTH = 250 EXT_LINKS_IN_WINDOW = NO +# If the HTML_FORMULA_FORMAT option is set to svg, doxygen will use the pdf2svg +# tool (see https://github.com/dawbarton/pdf2svg) or inkscape (see +# https://inkscape.org) to generate formulas as SVG images instead of PNGs for +# the HTML output. These images will generally look nicer at scaled resolutions. +# Possible values are: png (the default) and svg (looks nicer but requires the +# pdf2svg or inkscape tool). +# The default value is: png. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FORMULA_FORMAT = png + # Use this tag to change the font size of LaTeX formulas included as images in # the HTML documentation. When you change the font size after a successful # doxygen run you need to manually remove any form_*.png images from the HTML @@ -1451,7 +1528,7 @@ EXT_LINKS_IN_WINDOW = NO FORMULA_FONTSIZE = 10 -# Use the FORMULA_TRANPARENT tag to determine whether or not the images +# Use the FORMULA_TRANSPARENT tag to determine whether or not the images # generated for formulas are transparent PNGs. Transparent PNGs are not # supported properly for IE 6.0, but are supported on all modern browsers. # @@ -1462,8 +1539,14 @@ FORMULA_FONTSIZE = 10 FORMULA_TRANSPARENT = YES +# The FORMULA_MACROFILE can contain LaTeX \newcommand and \renewcommand commands +# to create new LaTeX commands to be used in formulas as building blocks. See +# the section "Including formulas" for details. + +FORMULA_MACROFILE = + # Enable the USE_MATHJAX option to render LaTeX formulas using MathJax (see -# http://www.mathjax.org) which uses client side Javascript for the rendering +# https://www.mathjax.org) which uses client side JavaScript for the rendering # instead of using pre-rendered bitmaps. Use this if you do not have LaTeX # installed or if you want to formulas look prettier in the HTML output. When # enabled you may also need to install MathJax separately and configure the path @@ -1490,8 +1573,8 @@ MATHJAX_FORMAT = HTML-CSS # MATHJAX_RELPATH should be ../mathjax. The default value points to the MathJax # Content Delivery Network so you can quickly see the result without installing # MathJax. However, it is strongly recommended to install a local copy of -# MathJax from http://www.mathjax.org before deployment. -# The default value is: http://cdn.mathjax.org/mathjax/latest. +# MathJax from https://www.mathjax.org before deployment. +# The default value is: https://cdn.jsdelivr.net/npm/mathjax@2. # This tag requires that the tag USE_MATHJAX is set to YES. MATHJAX_RELPATH = http://cdn.mathjax.org/mathjax/latest @@ -1533,7 +1616,7 @@ MATHJAX_CODEFILE = SEARCHENGINE = YES # When the SERVER_BASED_SEARCH tag is enabled the search engine will be -# implemented using a web server instead of a web client using Javascript. There +# implemented using a web server instead of a web client using JavaScript. There # are two flavors of web server based searching depending on the EXTERNAL_SEARCH # setting. When disabled, doxygen will generate a PHP script for searching and # an index file used by the script. When EXTERNAL_SEARCH is enabled the indexing @@ -1552,7 +1635,7 @@ SERVER_BASED_SEARCH = NO # # Doxygen ships with an example indexer (doxyindexer) and search engine # (doxysearch.cgi) which are based on the open source search engine library -# Xapian (see: http://xapian.org/). +# Xapian (see: https://xapian.org/). # # See the section "External Indexing and Searching" for details. # The default value is: NO. @@ -1565,7 +1648,7 @@ EXTERNAL_SEARCH = NO # # Doxygen ships with an example indexer (doxyindexer) and search engine # (doxysearch.cgi) which are based on the open source search engine library -# Xapian (see: http://xapian.org/). See the section "External Indexing and +# Xapian (see: https://xapian.org/). See the section "External Indexing and # Searching" for details. # This tag requires that the tag SEARCHENGINE is set to YES. @@ -1617,21 +1700,35 @@ LATEX_OUTPUT = latex # The LATEX_CMD_NAME tag can be used to specify the LaTeX command name to be # invoked. # -# Note that when enabling USE_PDFLATEX this option is only used for generating -# bitmaps for formulas in the HTML output, but not in the Makefile that is -# written to the output directory. -# The default file is: latex. +# Note that when not enabling USE_PDFLATEX the default is latex when enabling +# USE_PDFLATEX the default is pdflatex and when in the later case latex is +# chosen this is overwritten by pdflatex. For specific output languages the +# default can have been set differently, this depends on the implementation of +# the output language. # This tag requires that the tag GENERATE_LATEX is set to YES. LATEX_CMD_NAME = latex # The MAKEINDEX_CMD_NAME tag can be used to specify the command name to generate # index for LaTeX. +# Note: This tag is used in the Makefile / make.bat. +# See also: LATEX_MAKEINDEX_CMD for the part in the generated output file +# (.tex). # The default file is: makeindex. # This tag requires that the tag GENERATE_LATEX is set to YES. MAKEINDEX_CMD_NAME = makeindex +# The LATEX_MAKEINDEX_CMD tag can be used to specify the command name to +# generate index for LaTeX. In case there is no backslash (\) as first character +# it will be automatically added in the LaTeX code. +# Note: This tag is used in the generated output file (.tex). +# See also: MAKEINDEX_CMD_NAME for the part in the Makefile / make.bat. +# The default value is: makeindex. +# This tag requires that the tag GENERATE_LATEX is set to YES. + +LATEX_MAKEINDEX_CMD = makeindex + # If the COMPACT_LATEX tag is set to YES, doxygen generates more compact LaTeX # documents. This may be useful for small projects and may help to save some # trees in general. @@ -1716,9 +1813,11 @@ LATEX_EXTRA_FILES = PDF_HYPERLINKS = YES -# If the USE_PDFLATEX tag is set to YES, doxygen will use pdflatex to generate -# the PDF file directly from the LaTeX files. Set this option to YES, to get a -# higher quality PDF documentation. +# If the USE_PDFLATEX tag is set to YES, doxygen will use the engine as +# specified with LATEX_CMD_NAME to generate the PDF file directly from the LaTeX +# files. Set this option to YES, to get a higher quality PDF documentation. +# +# See also section LATEX_CMD_NAME for selecting the engine. # The default value is: YES. # This tag requires that the tag GENERATE_LATEX is set to YES. @@ -1752,7 +1851,7 @@ LATEX_SOURCE_CODE = NO # The LATEX_BIB_STYLE tag can be used to specify the style to use for the # bibliography, e.g. plainnat, or ieeetr. See -# http://en.wikipedia.org/wiki/BibTeX and \cite for more info. +# https://en.wikipedia.org/wiki/BibTeX and \cite for more info. # The default value is: plain. # This tag requires that the tag GENERATE_LATEX is set to YES. @@ -1766,6 +1865,14 @@ LATEX_BIB_STYLE = plain LATEX_TIMESTAMP = NO +# The LATEX_EMOJI_DIRECTORY tag is used to specify the (relative or absolute) +# path from which the emoji images will be read. If a relative path is entered, +# it will be relative to the LATEX_OUTPUT directory. If left blank the +# LATEX_OUTPUT directory will be used. +# This tag requires that the tag GENERATE_LATEX is set to YES. + +LATEX_EMOJI_DIRECTORY = + #--------------------------------------------------------------------------- # Configuration options related to the RTF output #--------------------------------------------------------------------------- @@ -1805,9 +1912,9 @@ COMPACT_RTF = NO RTF_HYPERLINKS = NO -# Load stylesheet definitions from file. Syntax is similar to doxygen's config -# file, i.e. a series of assignments. You only have to provide replacements, -# missing definitions are set to their default value. +# Load stylesheet definitions from file. Syntax is similar to doxygen's +# configuration file, i.e. a series of assignments. You only have to provide +# replacements, missing definitions are set to their default value. # # See also section "Doxygen usage" for information on how to generate the # default style sheet that doxygen normally uses. @@ -1816,8 +1923,8 @@ RTF_HYPERLINKS = NO RTF_STYLESHEET_FILE = # Set optional variables used in the generation of an RTF document. Syntax is -# similar to doxygen's config file. A template extensions file can be generated -# using doxygen -e rtf extensionFile. +# similar to doxygen's configuration file. A template extensions file can be +# generated using doxygen -e rtf extensionFile. # This tag requires that the tag GENERATE_RTF is set to YES. RTF_EXTENSIONS_FILE = @@ -1903,6 +2010,13 @@ XML_OUTPUT = xml XML_PROGRAMLISTING = YES +# If the XML_NS_MEMB_FILE_SCOPE tag is set to YES, doxygen will include +# namespace members in file scope as well, matching the HTML output. +# The default value is: NO. +# This tag requires that the tag GENERATE_XML is set to YES. + +XML_NS_MEMB_FILE_SCOPE = NO + #--------------------------------------------------------------------------- # Configuration options related to the DOCBOOK output #--------------------------------------------------------------------------- @@ -1935,9 +2049,9 @@ DOCBOOK_PROGRAMLISTING = NO #--------------------------------------------------------------------------- # If the GENERATE_AUTOGEN_DEF tag is set to YES, doxygen will generate an -# AutoGen Definitions (see http://autogen.sf.net) file that captures the -# structure of the code including all documentation. Note that this feature is -# still experimental and incomplete at the moment. +# AutoGen Definitions (see http://autogen.sourceforge.net/) file that captures +# the structure of the code including all documentation. Note that this feature +# is still experimental and incomplete at the moment. # The default value is: NO. GENERATE_AUTOGEN_DEF = NO @@ -2104,12 +2218,6 @@ EXTERNAL_GROUPS = YES EXTERNAL_PAGES = YES -# The PERL_PATH should be the absolute path and name of the perl script -# interpreter (i.e. the result of 'which perl'). -# The default file (with absolute path) is: /usr/bin/perl. - -PERL_PATH = /usr/bin/perl - #--------------------------------------------------------------------------- # Configuration options related to the dot tool #--------------------------------------------------------------------------- @@ -2123,15 +2231,6 @@ PERL_PATH = /usr/bin/perl CLASS_DIAGRAMS = YES -# You can define message sequence charts within doxygen comments using the \msc -# command. Doxygen will then run the mscgen tool (see: -# http://www.mcternan.me.uk/mscgen/)) to produce the chart and insert it in the -# documentation. The MSCGEN_PATH tag allows you to specify the directory where -# the mscgen tool resides. If left empty the tool is assumed to be found in the -# default search path. - -MSCGEN_PATH = - # You can include diagrams made with dia in doxygen documentation. Doxygen will # then run dia to produce the diagram and insert it in the documentation. The # DIA_PATH tag allows you to specify the directory where the dia binary resides. @@ -2150,7 +2249,7 @@ HIDE_UNDOC_RELATIONS = YES # http://www.graphviz.org/), a graph visualization toolkit from AT&T and Lucent # Bell Labs. The other options in this section have no effect if this option is # set to NO -# The default value is: YES. +# The default value is: NO. HAVE_DOT = YES @@ -2306,9 +2405,7 @@ DIRECTORY_GRAPH = YES # Note: If you choose svg you need to set HTML_FILE_EXTENSION to xhtml in order # to make the SVG files visible in IE 9+ (other browsers do not have this # requirement). -# Possible values are: png, png:cairo, png:cairo:cairo, png:cairo:gd, png:gd, -# png:gd:gd, jpg, jpg:cairo, jpg:cairo:gd, jpg:gd, jpg:gd:gd, gif, gif:cairo, -# gif:cairo:gd, gif:gd, gif:gd:gd, svg, png:gd, png:gd:gd, png:cairo, +# Possible values are: png, jpg, gif, svg, png:gd, png:gd:gd, png:cairo, # png:cairo:gd, png:cairo:cairo, png:cairo:gdiplus, png:gdiplus and # png:gdiplus:gdiplus. # The default value is: png. @@ -2361,6 +2458,11 @@ DIAFILE_DIRS = PLANTUML_JAR_PATH = +# When using plantuml, the PLANTUML_CFG_FILE tag can be used to specify a +# configuration file for plantuml. + +PLANTUML_CFG_FILE = + # When using plantuml, the specified paths are searched for files specified by # the !include statement in a plantuml block. diff --git a/cpp/doxygen/header.html b/cpp/doxygen/header.html new file mode 100644 index 00000000000..579d7829ed9 --- /dev/null +++ b/cpp/doxygen/header.html @@ -0,0 +1,62 @@ + + + + + + + + +$projectname: $title +$title + + + +$treeview +$search +$mathjax + +$extrastylesheet + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+
$projectname +  $projectnumber +
+
$projectbrief
+
+
$projectbrief
+
$searchbox
+
+ + diff --git a/docs/cugraph/source/_static/custom.js b/docs/cugraph/source/_static/custom.js deleted file mode 100644 index 567a07a7cd6..00000000000 --- a/docs/cugraph/source/_static/custom.js +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2022, NVIDIA CORPORATION. - -function update_switch_theme_button() { - current_theme = document.documentElement.dataset.mode; - if (current_theme == "light") { - document.getElementById("theme-switch").title = "Switch to auto theme"; - } else if (current_theme == "auto") { - document.getElementById("theme-switch").title = "Switch to dark theme"; - } else { - document.getElementById("theme-switch").title = "Switch to light theme"; - } -} - -$(document).ready(function() { - var observer = new MutationObserver(function(mutations) { - update_switch_theme_button(); - }) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme'] - }); -}); diff --git a/docs/cugraph/source/_static/custom_styles.css b/docs/cugraph/source/_static/custom_styles.css deleted file mode 100644 index 31db0633f7f..00000000000 --- a/docs/cugraph/source/_static/custom_styles.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Mirrors the change in: - * https://github.com/sphinx-doc/sphinx/pull/5976 - * which is not showing up in our theme. - */ - .classifier:before { - font-style: normal; - margin: 0.5em; - content: ":"; -} - -/* Fix for text wrap in sphinx tables: -* https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html -*/ -@media screen and (min-width: 767px) { - - .wy-table-responsive table td { - /* !important prevents the common CSS stylesheets from overriding - this as on RTD they are loaded after this stylesheet */ - white-space: normal !important; - } - - .wy-table-responsive { - overflow: visible !important; - } -} - - -table.io-supported-types-table { - text-align: center -} - -table.io-supported-types-table thead{ - text-align: center !important; -} - -:root { - ---pst-color-active-navigation: 114, 83, 237; ---pst-color-navbar-link: 77, 77, 77; ---pst-color-navbar-link-hover: var(--pst-color-active-navigation); ---pst-color-navbar-link-active: var(--pst-color-active-navigation); ---pst-color-sidebar-link: 77, 77, 77; ---pst-color-sidebar-link-hover: var(--pst-color-active-navigation); ---pst-color-sidebar-link-active: var(--pst-color-active-navigation); ---pst-color-sidebar-expander-background-hover: 244, 244, 244; ---pst-color-sidebar-caption: 77, 77, 77; ---pst-color-toc-link: 119, 117, 122; ---pst-color-toc-link-hover: var(--pst-color-active-navigation); ---pst-color-toc-link-active: var(--pst-color-active-navigation); - -} - -/* Used to make special-table scrollable when it overflows */ -.special-table-wrapper { - width: 100%; - overflow: auto !important; -} - -.special-table td, .special-table th { - border: 1px solid #dee2e6; -} - -/* Needed to resolve https://github.com/executablebooks/jupyter-book/issues/1611 */ -.output.text_html { - overflow: auto; -} - -html[data-theme="light"] { - --pst-color-primary: rgb(19, 6, 84); - --pst-color-text-base: rgb(51, 51, 51); - - --pst-color-primary: rgb(19, 6, 84); - - --pst-color-link: rgb(0, 91, 129); - --pst-color-secondary: rgb(227, 46, 0); - --pst-table-background-color: transparent; -} - - -html[data-theme="dark"] { - --pst-color-primary: rgb(221, 221, 221); - --pst-color-inline-code: rgb(248, 6, 204); - --pst-table-background-color: var(--pst-color-text-muted); - -} - -div.cell_output table{ - background: var(--pst-table-background-color); -} diff --git a/docs/cugraph/source/conf.py b/docs/cugraph/source/conf.py index 1f6dc19a35b..99f789c948f 100644 --- a/docs/cugraph/source/conf.py +++ b/docs/cugraph/source/conf.py @@ -212,9 +212,9 @@ def setup(app): - app.add_css_file('custom_styles.css') - app.add_js_file('custom.js') - app.add_css_file('references.css') + app.add_css_file("https://docs.rapids.ai/assets/css/custom.css") + app.add_js_file("https://docs.rapids.ai/assets/js/custom.js") + app.add_css_file("references.css") source_suffix = ['.rst', '.md'] From 0f2befbe9138807896133eb9348c8ab1d6c128d5 Mon Sep 17 00:00:00 2001 From: Chuck Hastings <45364586+ChuckHastings@users.noreply.github.com> Date: Tue, 2 Aug 2022 15:54:58 -0400 Subject: [PATCH 08/19] fix non-deterministic bug in uniform neighborhood sampling (#2477) Found a bug in uniform neighborhood sampling. Called a function that returns an optional. In SG mode the optional is always null. It was being dereferenced and passed to a function where random memory contents were being used. Fixed the SG code to not use those values. Closes #2446 Authors: - Chuck Hastings (https://github.com/ChuckHastings) Approvers: - Alex Barghi (https://github.com/alexbarghi-nv) - Seunghwa Kang (https://github.com/seunghwak) URL: https://github.com/rapidsai/cugraph/pull/2477 --- .../cugraph/detail/decompress_edge_partition.cuh | 10 ++++------ cpp/src/c_api/uniform_neighbor_sampling.cpp | 15 +++++++++++++++ cpp/src/sampling/detail/sampling_utils_impl.cuh | 2 ++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cpp/include/cugraph/detail/decompress_edge_partition.cuh b/cpp/include/cugraph/detail/decompress_edge_partition.cuh index cc4445af392..aa682b01dfd 100644 --- a/cpp/include/cugraph/detail/decompress_edge_partition.cuh +++ b/cpp/include/cugraph/detail/decompress_edge_partition.cuh @@ -285,6 +285,7 @@ void partially_decompress_edge_partition_to_fill_edgelist( edge_partition_device_view_t edge_partition, vertex_t const* input_majors, edge_t const* input_major_start_offsets, + vertex_t input_majors_size, std::vector const& segment_offsets, vertex_t* majors, vertex_t* minors, @@ -458,7 +459,7 @@ void partially_decompress_edge_partition_to_fill_edgelist( thrust::for_each( handle.get_thrust_policy(), thrust::make_counting_iterator(vertex_t{0}), - thrust::make_counting_iterator(edge_partition.major_range_size()), + thrust::make_counting_iterator(input_majors_size), [edge_partition, input_majors, input_major_start_offsets, @@ -471,13 +472,10 @@ void partially_decompress_edge_partition_to_fill_edgelist( global_edge_index] __device__(auto idx) { auto major = input_majors[idx]; auto major_offset = input_major_start_offsets[idx]; - auto major_partition_offset = - static_cast(major - edge_partition.major_range_first()); vertex_t const* indices{nullptr}; thrust::optional weights{thrust::nullopt}; edge_t local_degree{}; - thrust::tie(indices, weights, local_degree) = - edge_partition.local_edges(major_partition_offset); + thrust::tie(indices, weights, local_degree) = edge_partition.local_edges(major); // FIXME: This can lead to thread divergence if local_degree varies significantly // within threads in this warp @@ -497,7 +495,7 @@ void partially_decompress_edge_partition_to_fill_edgelist( major_input_property); } if (global_edge_index) { - auto adjacency_list_offset = thrust::get<0>(*global_edge_index)[major_partition_offset]; + auto adjacency_list_offset = thrust::get<0>(*global_edge_index)[major]; auto minor_map = thrust::get<1>(*global_edge_index); thrust::sequence(thrust::seq, minor_map + major_offset, diff --git a/cpp/src/c_api/uniform_neighbor_sampling.cpp b/cpp/src/c_api/uniform_neighbor_sampling.cpp index ed458eaf1cd..6d35dfe2dbc 100644 --- a/cpp/src/c_api/uniform_neighbor_sampling.cpp +++ b/cpp/src/c_api/uniform_neighbor_sampling.cpp @@ -165,6 +165,21 @@ extern "C" cugraph_error_code_t cugraph_uniform_neighbor_sample( cugraph_sample_result_t** result, cugraph_error_t** error) { + CAPI_EXPECTS( + reinterpret_cast(graph)->vertex_type_ == + reinterpret_cast(start) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and start must match", + *error); + + CAPI_EXPECTS( + reinterpret_cast(fan_out) + ->type_ == INT32, + CUGRAPH_INVALID_INPUT, + "fan_out should be of type int", + *error); + uniform_neighbor_sampling_functor functor{ handle, graph, start, fan_out, with_replacement, do_expensive_check}; return cugraph::c_api::run_algorithm(graph, functor, result, error); diff --git a/cpp/src/sampling/detail/sampling_utils_impl.cuh b/cpp/src/sampling/detail/sampling_utils_impl.cuh index 8a88b274c94..47c02054466 100644 --- a/cpp/src/sampling/detail/sampling_utils_impl.cuh +++ b/cpp/src/sampling/detail/sampling_utils_impl.cuh @@ -775,6 +775,7 @@ gather_one_hop_edgelist( partition, active_majors.cbegin(), active_majors_out_offsets.cbegin(), + static_cast(active_majors.size()), majors_segments, output_offset + majors.data(), output_offset + minors.data(), @@ -834,6 +835,7 @@ gather_one_hop_edgelist( partition, active_majors.cbegin(), active_majors_out_offsets.cbegin(), + static_cast(active_majors.size()), *majors_segments, majors.data(), minors.data(), From 4372f3165100106a6bfa7464b3d593409dea7df7 Mon Sep 17 00:00:00 2001 From: Vyas Ramasubramani Date: Tue, 2 Aug 2022 13:03:58 -0700 Subject: [PATCH 09/19] Fix typos in Python CMakeLists CUDA arch file (#2475) These typos would prevent properly handling required CMake features as determined by `rapids_cuda_init_architectures`. This only affects compilations where `FIND_CUGRAPH_CPP=OFF`. Authors: - Vyas Ramasubramani (https://github.com/vyasr) Approvers: - Rick Ratzel (https://github.com/rlratzel) - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2475 --- python/cugraph/CMakeLists.txt | 2 +- python/pylibcugraph/CMakeLists.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/cugraph/CMakeLists.txt b/python/cugraph/CMakeLists.txt index f90035f9460..ee929ca6e27 100644 --- a/python/cugraph/CMakeLists.txt +++ b/python/cugraph/CMakeLists.txt @@ -45,7 +45,7 @@ if(NOT cugraph_FOUND) # TODO: This will not be necessary once we upgrade to CMake 3.22, which will pull in the required # languages for the C++ project even if this project does not require those languges. include(rapids-cuda) - rapids_cuda_init_architectures(CUGRAPH) + rapids_cuda_init_architectures(cugraph-python) enable_language(CUDA) # Since cugraph only enables CUDA optionally, we need to manually include the file that diff --git a/python/pylibcugraph/CMakeLists.txt b/python/pylibcugraph/CMakeLists.txt index 030da9c3e38..9126536e472 100644 --- a/python/pylibcugraph/CMakeLists.txt +++ b/python/pylibcugraph/CMakeLists.txt @@ -49,13 +49,13 @@ if(NOT cugraph_FOUND) # TODO: This will not be necessary once we upgrade to CMake 3.22, which will pull in the required # languages for the C++ project even if this project does not require those languges. include(rapids-cuda) - rapids_cuda_init_architectures(CUGRAPH) + rapids_cuda_init_architectures(pylibcugraph-python) enable_language(CUDA) # Since cugraph only enables CUDA optionally, we need to manually include the file that # rapids_cuda_init_architectures relies on `project` including. - include("${CMAKE_PROJECT_cugraph-python_INCLUDE}") + include("${CMAKE_PROJECT_pylibcugraph-python_INCLUDE}") add_subdirectory(../../cpp cugraph-cpp) From 5c7303c2e4bc6d008918320cb66c949c2ae2698b Mon Sep 17 00:00:00 2001 From: GALI PREM SAGAR Date: Tue, 2 Aug 2022 15:23:26 -0500 Subject: [PATCH 10/19] Pin `dask` & `distributed` for release (#2478) This PR pins `dask` & `distributed` to `2022.7.1` for `22.08` release. xref: https://github.com/rapidsai/cudf/pull/11433 Authors: - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - AJ Schmidt (https://github.com/ajschmidt8) URL: https://github.com/rapidsai/cugraph/pull/2478 --- conda/environments/cugraph_dev_cuda11.2.yml | 4 ++-- conda/environments/cugraph_dev_cuda11.4.yml | 4 ++-- conda/environments/cugraph_dev_cuda11.5.yml | 4 ++-- conda/recipes/cugraph/meta.yaml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/conda/environments/cugraph_dev_cuda11.2.yml b/conda/environments/cugraph_dev_cuda11.2.yml index f1f4c0e4570..96dc4f15c74 100644 --- a/conda/environments/cugraph_dev_cuda11.2.yml +++ b/conda/environments/cugraph_dev_cuda11.2.yml @@ -16,8 +16,8 @@ dependencies: - libraft-headers=22.08.* - pyraft=22.08.* - cuda-python>=11.5,<11.7.1 -- dask>=2022.05.2 -- distributed>=2022.05.2 +- dask==2022.7.1 +- distributed==2022.7.1 - dask-cuda=22.08.* - dask-cudf=22.08.* - nccl>=2.9.9 diff --git a/conda/environments/cugraph_dev_cuda11.4.yml b/conda/environments/cugraph_dev_cuda11.4.yml index fc1ca620e6d..9c8e4429048 100644 --- a/conda/environments/cugraph_dev_cuda11.4.yml +++ b/conda/environments/cugraph_dev_cuda11.4.yml @@ -16,8 +16,8 @@ dependencies: - libraft-headers=22.08.* - pyraft=22.08.* - cuda-python>=11.5,<11.7.1 -- dask>=2022.05.2 -- distributed>=2022.05.2 +- dask==2022.7.1 +- distributed==2022.7.1 - dask-cuda=22.08.* - dask-cudf=22.08.* - nccl>=2.9.9 diff --git a/conda/environments/cugraph_dev_cuda11.5.yml b/conda/environments/cugraph_dev_cuda11.5.yml index 1c901e712cd..6b7303a76ff 100644 --- a/conda/environments/cugraph_dev_cuda11.5.yml +++ b/conda/environments/cugraph_dev_cuda11.5.yml @@ -16,8 +16,8 @@ dependencies: - libraft-headers=22.08.* - pyraft=22.08.* - cuda-python>=11.5,<11.7.1 -- dask>=2022.05.2 -- distributed>=2022.05.2 +- dask==2022.7.1 +- distributed==2022.7.1 - dask-cuda=22.08.* - dask-cudf=22.08.* - nccl>=2.9.9 diff --git a/conda/recipes/cugraph/meta.yaml b/conda/recipes/cugraph/meta.yaml index 022618fa22a..f5a9b1598ac 100644 --- a/conda/recipes/cugraph/meta.yaml +++ b/conda/recipes/cugraph/meta.yaml @@ -51,8 +51,8 @@ requirements: - cudf={{ minor_version }} - dask-cudf {{ minor_version }} - dask-cuda {{ minor_version }} - - dask>=2022.05.2 - - distributed>=2022.05.2 + - dask==2022.7.1 + - distributed==2022.7.1 - ucx-py {{ ucx_py_version }} - ucx-proc=*=gpu - {{ pin_compatible('cudatoolkit', max_pin='x', min_pin='x') }} From b74e22ab9f17c0f99de8ae8600f17e66cd26a6ea Mon Sep 17 00:00:00 2001 From: Ralph Liu <106174412+oorliu@users.noreply.github.com> Date: Tue, 2 Aug 2022 16:33:26 -0400 Subject: [PATCH 11/19] Use Datasets API to Update Notebook Examples (#2440) Addresses issue #2364 All of the SG notebook examples have been updated to use the newly added Datasets API. Previously, Graph objects were created by specifying a path to the `.csv` file, calling `cuDF` to read in the file, and then converting the edge list to a graph. Now, a dataset object is imported and can create graphs by calling the `get_graph()` method. Comments and headings have also been updated for continuity. Authors: - Ralph Liu (https://github.com/oorliu) Approvers: - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2440 --- .../algorithms/centrality/Betweenness.ipynb | 56 +- notebooks/algorithms/centrality/Katz.ipynb | 53 +- notebooks/algorithms/community/ECG.ipynb | 37 +- notebooks/algorithms/community/Louvain.ipynb | 37 +- .../community/Spectral-Clustering.ipynb | 41 +- .../community/Subgraph-Extraction.ipynb | 53 +- .../community/Triangle-Counting.ipynb | 65 +- notebooks/algorithms/community/ktruss.ipynb | 40 +- .../components/ConnectedComponents.ipynb | 36 +- notebooks/algorithms/cores/core-number.ipynb | 39 +- notebooks/algorithms/cores/kcore.ipynb | 75 +- .../algorithms/layout/Force-Atlas2.ipynb | 31 +- notebooks/applications/CostMatrix.ipynb | 641 ------------------ notebooks/centrality/Centrality.ipynb | 386 +++++++++++ notebooks/img/Full-four_node_replication.png | Bin 17291 -> 0 bytes notebooks/img/graph_after_ghost.png | Bin 37330 -> 0 bytes notebooks/img/graph_after_replication.png | Bin 33718 -> 0 bytes notebooks/link_analysis/HITS.ipynb | 44 +- notebooks/link_analysis/Pagerank.ipynb | 43 +- .../link_prediction/Jaccard-Similarity.ipynb | 37 +- .../link_prediction/Overlap-Similarity.ipynb | 27 +- notebooks/sampling/RandomWalk.ipynb | 31 +- notebooks/structure/Renumber-2.ipynb | 36 +- notebooks/structure/Renumber.ipynb | 19 +- notebooks/structure/Symmetrize.ipynb | 27 +- notebooks/traversal/BFS.ipynb | 27 +- notebooks/traversal/SSSP.ipynb | 34 +- 27 files changed, 727 insertions(+), 1188 deletions(-) delete mode 100644 notebooks/applications/CostMatrix.ipynb create mode 100644 notebooks/centrality/Centrality.ipynb delete mode 100644 notebooks/img/Full-four_node_replication.png delete mode 100644 notebooks/img/graph_after_ghost.png delete mode 100644 notebooks/img/graph_after_replication.png diff --git a/notebooks/algorithms/centrality/Betweenness.ipynb b/notebooks/algorithms/centrality/Betweenness.ipynb index 8860819b3ad..82b7b4bc29e 100644 --- a/notebooks/algorithms/centrality/Betweenness.ipynb +++ b/notebooks/algorithms/centrality/Betweenness.ipynb @@ -12,7 +12,8 @@ "| --------------|------------|------------------|-----------------|----------------|\n", "| Brad Rees | 04/24/2019 | created | 0.15 | GV100, CUDA 11.0\n", "| Brad Rees | 08/16/2020 | tested / updated | 21.10 nightly | RTX 3090 CUDA 11.4\n", - "| Don Acosta | 07/05/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5" + "| Don Acosta | 07/05/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5" ] }, { @@ -111,7 +112,10 @@ "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -124,42 +128,6 @@ "import networkx as nx" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Some Prep" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Define the path to the test data \n", - "datafile='../../data/karate-data.csv'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read in the data - GPU\n", - "cuGraph depends on cuDF for data loading and the initial Dataframe creation\n", - "\n", - "The data file contains an edge list, which represents the connection of a vertex to another. The `source` to `destination` pairs is in what is known as Coordinate Format (COO). In this test case, the data is just two columns. However a third, `weight`, column is also possible" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -173,9 +141,8 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph using the source (src) and destination (dst) vertex pairs from the Dataframe \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "# Create a graph using the imported Dataset object\n", + "G = karate.get_graph(fetch=True)" ] }, { @@ -256,6 +223,7 @@ "outputs": [], "source": [ "# Read the data, this also created a NetworkX Graph \n", + "datafile=\"../../data/karate-data.csv\"\n", "file = open(datafile, 'rb')\n", "Gnx = nx.read_edgelist(file)" ] @@ -321,7 +289,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -335,11 +303,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/centrality/Katz.ipynb b/notebooks/algorithms/centrality/Katz.ipynb index f3537fe75e7..b62cea2df82 100755 --- a/notebooks/algorithms/centrality/Katz.ipynb +++ b/notebooks/algorithms/centrality/Katz.ipynb @@ -12,7 +12,8 @@ "| --------------|------------|------------------|-----------------|----------------|\n", "| Brad Rees | 10/15/2019 | created | 0.14 | GV100, CUDA 10.2\n", "| Brad Rees | 08/16/2020 | tested / updated | 0.15.1 nightly | RTX 3090 CUDA 11.4\n", - "| Don Acosta | 07/05/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5" + "| Don Acosta | 07/05/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5" ] }, { @@ -40,9 +41,9 @@ " this value is 0.0f, cuGraph will use the default value which is 0.00001. \n", " Setting too small a tolerance can lead to non-convergence due to numerical \n", " roundoff. Usually values between 0.01 and 0.00001 are acceptable.\n", - " nstart:cuDataFrame, GPU Dataframe containing the initial guess for katz centrality. \n", + " nstart: cuDataFrame, GPU Dataframe containing the initial guess for katz centrality. \n", " Default is None\n", - " normalized:bool, If True normalize the resulting katz centrality values. \n", + " normalized: bool, If True normalize the resulting katz centrality values. \n", " Default is True\n", "\n", "Returns:\n", @@ -106,7 +107,10 @@ "source": [ "# Import rapids libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -140,35 +144,6 @@ "tol = 0.00001 # tolerance" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Define the path to the test data \n", - "datafile='../../data/karate-data.csv'" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read in the data - GPU\n", - "cuGraph depends on cuDF for data loading and the initial Dataframe creation\n", - "\n", - "The data file contains an edge list, which represents the connection of a vertex to another. The `source` to `destination` pairs is in what is known as Coordinate Format (COO). In this test case, the data is just two columns. However a third, `weight`, column is also possible" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -182,9 +157,8 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph using the source (src) and destination (dst) vertex pairs from the Dataframe \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "# Create a graph using the imported Dataset object\n", + "G = karate.get_graph(fetch=True)" ] }, { @@ -275,6 +249,7 @@ "outputs": [], "source": [ "# Read the data, this also created a NetworkX Graph \n", + "datafile = \"../../data/karate-data.csv\"\n", "file = open(datafile, 'rb')\n", "Gnx = nx.read_edgelist(file)" ] @@ -348,7 +323,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -362,11 +337,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/community/ECG.ipynb b/notebooks/algorithms/community/ECG.ipynb index 28d44f5e3b2..829be21035c 100644 --- a/notebooks/algorithms/community/ECG.ipynb +++ b/notebooks/algorithms/community/ECG.ipynb @@ -13,6 +13,7 @@ "| | 08/16/2020 | updated | 0.15 | GV100, CUDA 10.2 |\n", "| | 08/05/2021 | tested/updated | 21.10 nightly | RTX 3090 CUDA 11.4 |\n", "| Don Acosta | 07/20/2022 | tested/updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", "\n", "## Introduction\n", "\n", @@ -101,34 +102,17 @@ "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Read data using cuDF" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "datafile='../../data/karate-data.csv'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# read the data using cuDF\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "## Create an Edgelist" ] }, { @@ -137,6 +121,9 @@ "metadata": {}, "outputs": [], "source": [ + "# You can also just get the edgelist\n", + "gdf = karate.get_edgelist(fetch=True)\n", + "\n", "# The algorithm also requires that there are vertex weights. Just use 1.0 \n", "gdf[\"data\"] = 1.0" ] @@ -232,7 +219,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -246,11 +233,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/community/Louvain.ipynb b/notebooks/algorithms/community/Louvain.ipynb index 4786fb1e9dc..a8529483534 100755 --- a/notebooks/algorithms/community/Louvain.ipynb +++ b/notebooks/algorithms/community/Louvain.ipynb @@ -15,6 +15,7 @@ "| | 08/16/2020 | updated | 0.14 | GV100, CUDA 10.2 |\n", "| | 08/05/2021 | tested / updated | 21.10 nightly | RTX 3090 CUDA 11.4 |\n", "| Don Acosta | 07/11/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", "\n", "\n", "\n", @@ -140,34 +141,17 @@ "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Read data using cuDF" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "datafile='../../data//karate-data.csv'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# read the data using cuDF\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "## Create an Edgelist" ] }, { @@ -176,6 +160,9 @@ "metadata": {}, "outputs": [], "source": [ + "# You can also just get the edgelist\n", + "gdf = karate.get_edgelist(fetch=True)\n", + "\n", "# The algorithm also requires that there are vertex weights. Just use 1.0 \n", "gdf[\"data\"] = 1.0" ] @@ -323,7 +310,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -337,11 +324,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/community/Spectral-Clustering.ipynb b/notebooks/algorithms/community/Spectral-Clustering.ipynb index a314861090c..2ac1b9e8c16 100755 --- a/notebooks/algorithms/community/Spectral-Clustering.ipynb +++ b/notebooks/algorithms/community/Spectral-Clustering.ipynb @@ -13,7 +13,8 @@ "| ---------------------------|------------|------------------|-----------------|-----------------------------|\n", "| Brad Rees and James Wyles | 08/01/2019 | created | 0.14 | GV100 32G, CUDA 10.2 |\n", "| | 08/16/2020 | updated | 0.15 | GV100 32G, CUDA 10.2 |\n", - "| Don Acosta | 07/11/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |" + "| Don Acosta | 07/11/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |" ] }, { @@ -140,48 +141,34 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# Import needed libraries\n", "import cugraph\n", "import cudf\n", - "import numpy as np" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read the CSV datafile using cuDF" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "datafile='../../data/karate-data.csv'\n", + "import numpy as np\n", "\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Add Edge Weights" + "### Create Edgelist and Add Edge Weights" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ + "gdf = karate.get_edgelist(fetch=True)\n", + "\n", "# The algorithm requires that there are edge weights. In this case all the weights are being set to 1\n", "gdf[\"data\"] = cudf.Series(np.ones(len(gdf), dtype=np.float32))" ] @@ -219,7 +206,7 @@ "metadata": {}, "outputs": [], "source": [ - "# create a CuGraph \n", + "# create a Graph \n", "G = cugraph.Graph()\n", "G.from_cudf_edgelist(gdf, source='src', destination='dst', edge_attr='data')" ] @@ -390,7 +377,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.6.9 64-bit", "language": "python", "name": "python3" }, @@ -404,11 +391,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.6.9" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6" } } }, diff --git a/notebooks/algorithms/community/Subgraph-Extraction.ipynb b/notebooks/algorithms/community/Subgraph-Extraction.ipynb index 88577d756ba..22c226fbb7a 100755 --- a/notebooks/algorithms/community/Subgraph-Extraction.ipynb +++ b/notebooks/algorithms/community/Subgraph-Extraction.ipynb @@ -13,7 +13,8 @@ "| --------------|------------|------------------|-----------------|-----------------------------|\n", "| Brad Rees | 10/16/2019 | created | 0.13 | GV100 32G, CUDA 10.2 |\n", "| | 08/16/2020 | updated | 0.15 | GV100 32G, CUDA 10.2 |\n", - "| Don Acosta | 07/11/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |" + "| Don Acosta | 07/11/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |" ] }, { @@ -79,7 +80,10 @@ "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -89,26 +93,6 @@ "## Read data using cuDF" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "datafile='../../data//karate-data.csv'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# read the data using cuDF\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -123,19 +107,14 @@ "metadata": {}, "outputs": [], "source": [ + "# You can also just get the edgelist\n", + "gdf = karate.get_edgelist(fetch=True)\n", + "\n", "# The louvain algorithm requires that there are vertex weights. Just use 1.0 \n", - "gdf[\"data\"] = 1.0" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# create a Graph \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst', edge_attr='data')" + "gdf[\"data\"] = 1.0\n", + "\n", + "# Create a graph\n", + "G = cugraph.from_cudf_edgelist(gdf, source='src', destination='dst')" ] }, { @@ -275,7 +254,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -289,11 +268,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/community/Triangle-Counting.ipynb b/notebooks/algorithms/community/Triangle-Counting.ipynb index 74006ae9cda..0554fac1362 100755 --- a/notebooks/algorithms/community/Triangle-Counting.ipynb +++ b/notebooks/algorithms/community/Triangle-Counting.ipynb @@ -14,6 +14,7 @@ "| Brad Rees | 08/01/2019 | created | 0.13 | GV100 32G, CUDA 10.2 |\n", "| | 08/16/2020 | updated | 0.15 | GV100 32G, CUDA 10.2 |\n", "| Don Acosta | 07/11/2022 | tested / updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", + "| Ralph Liu | 07/27/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", "\n", "## Introduction\n", "Triangle Counting, as the name implies, finds the number of triangles in a graph. Triangles are important in computing the clustering Coefficient and can be used for clustering. \n", @@ -90,8 +91,11 @@ "# Import needed libraries\n", "import cugraph\n", "import cudf\n", + "from collections import OrderedDict\n", "from cugraph.experimental import triangle_count as experimental_triangles\n", - "from collections import OrderedDict" + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -106,23 +110,6 @@ "from scipy.io import mmread" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Some Prep" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Define the path to the test data \n", - "datafile='../../data/karate.csv'" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -137,6 +124,7 @@ "metadata": {}, "outputs": [], "source": [ + "datafile= '../../data/karate.csv'\n", "# Read the data, this also created a NetworkX Graph \n", "file = open(datafile, 'rb')\n", "df = pd.read_csv(\n", @@ -211,36 +199,6 @@ "# cuGraph" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read in the data - GPU\n", - "cuGraph depends on cuDF for data loading and the initial Dataframe creation\n", - "\n", - "The data file contains an edge list, which represents the connection of a vertex to another. The `source` to `destination` pairs is in what is known as Coordinate Format (COO). A third, `weight`, column is also used in this example." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "gdf = cudf.read_csv(\n", - " datafile,\n", - " delimiter=\" \",\n", - " header=None,\n", - " names=[\"0\", \"1\", \"weight\"],\n", - " dtype={\"0\": \"int32\", \"1\": \"int32\", \"weight\": \"float32\"})" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [] - }, { "cell_type": "markdown", "metadata": {}, @@ -254,9 +212,8 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph using the source (src) and destination (dst) vertex pairs from the Dataframe \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source=\"0\", destination=\"1\",edge_attr=\"weight\")" + "G = karate.get_graph()\n", + "G = G.to_undirected()" ] }, { @@ -330,7 +287,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.6.9 64-bit", "language": "python", "name": "python3" }, @@ -344,11 +301,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.6.9" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6" } } }, diff --git a/notebooks/algorithms/community/ktruss.ipynb b/notebooks/algorithms/community/ktruss.ipynb index 20c14d76986..3c96f7ff5a7 100644 --- a/notebooks/algorithms/community/ktruss.ipynb +++ b/notebooks/algorithms/community/ktruss.ipynb @@ -14,6 +14,7 @@ "| | 08/16/2020 | updated | 0.15 | GV100, CUDA 10.2 |\n", "| | 08/05/2021 | tested/updated | 21.10 nightly | RTX 3090 CUDA 11.4 |\n", "| Don Acosta | 07/08/2022 | tested/updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated | 22.08 nightly | DGX Tesla V100 CUDA 11.5 |\n", "## Introduction\n", "\n", "Compute the k-truss of the graph G. A K-Truss is a relaxed cliques where every vertex is supported by at least k-2 triangle.\n", @@ -96,34 +97,17 @@ "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Read data using cuDF" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "datafile='../../data//karate-data.csv'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# read the data using cuDF\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "### Create a Graph" ] }, { @@ -132,9 +116,9 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "# Create a graph using the imported Dataset object\n", + "G = karate.get_graph(fetch=True)\n", + "G = G.to_undirected()" ] }, { @@ -260,7 +244,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -274,11 +258,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/components/ConnectedComponents.ipynb b/notebooks/algorithms/components/ConnectedComponents.ipynb index 0259c314ccf..5f18352647f 100755 --- a/notebooks/algorithms/components/ConnectedComponents.ipynb +++ b/notebooks/algorithms/components/ConnectedComponents.ipynb @@ -16,10 +16,11 @@ "\n", "_Notebook Credits_\n", "\n", - "| Author Credit | Date | Update | cuGraph Version | Test Hardware |\n", - "| --------------|------------|------------------|-----------------|-----------------------------|\n", - "| Kumar Aatish | 08/13/2019 | created | 0.15 | GV100, CUDA 10.2 |\n", - "| Brad Rees | 10/18/2021 | updated | 21.12 nightly | GV100, CUDA 11.4 |\n", + "| Author Credit | Date | Update | cuGraph Version | Test Hardware |\n", + "| --------------|------------|------------------|-----------------|--------------------|\n", + "| Kumar Aatish | 08/13/2019 | created | 0.15 | GV100, CUDA 10.2 |\n", + "| Brad Rees | 10/18/2021 | updated | 21.12 nightly | GV100, CUDA 11.4 |\n", + "| Ralph Liu | 06/22/2022 | updated/tested | 22.08 | TV100, CUDA 11.5 |\n", "| Don Acosta | 07/22/2021 | updated | 22.08 nightly | DGX Tesla V100, CUDA 11.5 |\n", "\n", "\n", @@ -131,13 +132,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 1. Read graph data from file\n", - "\n", - "cuGraph depends on cuDF for data loading and the initial Dataframe creation on the GPU.\n", - "\n", - "The data file contains an edge list, which represents the connection of a vertex to another. The source to destination pairs is in what is known as Coordinate Format (COO).\n", - "\n", - "In this test case the data in the test file is expressed in three columns, source, destination and the edge weight. While edge weight is relevant in other algorithms, cuGraph connected component calls do not make use of it and hence that column can be discarded from the dataframe." + "### 1. Import a Built-In Dataset" ] }, { @@ -146,14 +141,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Test file\n", - "datafile='../../data/netscience.csv'\n", - "\n", - "# the datafile contains three columns, but we only want to use the first two. \n", - "# We will use the \"usecols' feature of read_csv to ignore that column\n", - "\n", - "gdf = cudf.read_csv(datafile, delimiter=' ', names=['src', 'dst', 'wgt'], dtype=['int32', 'int32', 'float32'], usecols=['src', 'dst'])\n", - "gdf.head(5)" + "from cugraph.experimental.datasets import netscience" ] }, { @@ -169,9 +157,7 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph using the source (src) and destination (dst) vertex pairs from the Dataframe\n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "G = netscience.get_graph(fetch=True)" ] }, { @@ -362,7 +348,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -376,11 +362,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/cores/core-number.ipynb b/notebooks/algorithms/cores/core-number.ipynb index 64b8eada7ef..06fe570d390 100755 --- a/notebooks/algorithms/cores/core-number.ipynb +++ b/notebooks/algorithms/cores/core-number.ipynb @@ -16,6 +16,7 @@ "| --------------|------------|------------------|-----------------|--------------------|\n", "| Brad Rees | 10/28/2019 | created | 0.13 | GV100, CUDA 10.2 |\n", "| Don Acosta | 07/21/2022 | updated/tested | 22.08 nightly | DGX Tesla V100, CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated/tested | 22.08 nightly | DGX Tesla V100, CUDA 11.5 |\n", "\n", "## Introduction\n", "\n", @@ -77,34 +78,17 @@ "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Read data using cuDF" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Test file \n", - "datafile='../../data/karate-data.csv'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# read the data using cuDF\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "### Create a Graph" ] }, { @@ -113,9 +97,8 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "G = karate.get_graph(fetch=True)\n", + "G = G.to_undirected()" ] }, { @@ -160,7 +143,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -174,11 +157,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/algorithms/cores/kcore.ipynb b/notebooks/algorithms/cores/kcore.ipynb index 432a46834a1..065f02ffd98 100755 --- a/notebooks/algorithms/cores/kcore.ipynb +++ b/notebooks/algorithms/cores/kcore.ipynb @@ -17,6 +17,7 @@ "| Brad Rees | 10/28/2019 | created | 0.13 | GV100, CUDA 10.2 |\n", "| Brad Rees | 08/16/2020 | created | 0.15 | GV100, CUDA 10.2 |\n", "| Don Acosta | 07/21/2022 | updated/tested | 22.08 nightly | DGX Tesla V100, CUDA 11.5 |\n", + "| Ralph Liu | 07/26/2022 | updated/tested | 22.08 nightly | DGX Tesla V100, CUDA 11.5 |\n", "\n", "## Introduction\n", "\n", @@ -71,58 +72,50 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "# Import needed libraries\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Read data using cuDF" + "### Create a Graph" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ - "# Test file \n", - "datafile='../../data/karate-data.csv'" + "G = karate.get_graph(fetch=True)\n", + "G = G.to_undirected()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": {}, - "outputs": [], - "source": [ - "# read the data using cuDF\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# create a Graph \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Main Graph\n", + "\tNumber of Vertices: 34\n", + "\tNumber of Edges: 156\n" + ] + } + ], "source": [ "print(\"Main Graph\")\n", "print(\"\\tNumber of Vertices: \" + str(G.number_of_vertices()))\n", @@ -138,9 +131,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "RuntimeError", + "evalue": "non-success value returned from cugraph_core_number: CUGRAPH_UNKNOWN_ERROR", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mRuntimeError\u001b[0m Traceback (most recent call last)", + "\u001b[1;32m/home/nfs/ralphl/datasets-api/notebooks/algorithms/cores/kcore.ipynb Cell 10\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[39m# Call k-cores on the graph\u001b[39;00m\n\u001b[0;32m----> 2\u001b[0m kcg \u001b[39m=\u001b[39m cugraph\u001b[39m.\u001b[39;49mk_core(G)\n", + "File \u001b[0;32m~/miniconda3/envs/cugraph_dev/lib/python3.9/site-packages/cugraph-22.2.0a0+366.gabd2f0ef-py3.9-linux-x86_64.egg/cugraph/cores/k_core.py:103\u001b[0m, in \u001b[0;36mk_core\u001b[0;34m(G, k, core_number)\u001b[0m\n\u001b[1;32m 99\u001b[0m core_number \u001b[39m=\u001b[39m G\u001b[39m.\u001b[39madd_internal_vertex_id(core_number, \u001b[39m'\u001b[39m\u001b[39mvertex\u001b[39m\u001b[39m'\u001b[39m,\n\u001b[1;32m 100\u001b[0m cols)\n\u001b[1;32m 102\u001b[0m \u001b[39melse\u001b[39;00m:\n\u001b[0;32m--> 103\u001b[0m core_number \u001b[39m=\u001b[39m _call_plc_core_number(G)\n\u001b[1;32m 104\u001b[0m core_number \u001b[39m=\u001b[39m core_number\u001b[39m.\u001b[39mrename(\n\u001b[1;32m 105\u001b[0m columns\u001b[39m=\u001b[39m{\u001b[39m\"\u001b[39m\u001b[39mcore_number\u001b[39m\u001b[39m\"\u001b[39m: \u001b[39m\"\u001b[39m\u001b[39mvalues\u001b[39m\u001b[39m\"\u001b[39m}, copy\u001b[39m=\u001b[39m\u001b[39mFalse\u001b[39;00m\n\u001b[1;32m 106\u001b[0m )\n\u001b[1;32m 108\u001b[0m \u001b[39mif\u001b[39;00m k \u001b[39mis\u001b[39;00m \u001b[39mNone\u001b[39;00m:\n", + "File \u001b[0;32m~/miniconda3/envs/cugraph_dev/lib/python3.9/site-packages/cugraph-22.2.0a0+366.gabd2f0ef-py3.9-linux-x86_64.egg/cugraph/cores/k_core.py:27\u001b[0m, in \u001b[0;36m_call_plc_core_number\u001b[0;34m(G)\u001b[0m\n\u001b[1;32m 25\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m_call_plc_core_number\u001b[39m(G):\n\u001b[1;32m 26\u001b[0m vertex, core_number \u001b[39m=\u001b[39m \\\n\u001b[0;32m---> 27\u001b[0m pylibcugraph_core_number(\n\u001b[1;32m 28\u001b[0m resource_handle\u001b[39m=\u001b[39;49mResourceHandle(),\n\u001b[1;32m 29\u001b[0m graph\u001b[39m=\u001b[39;49mG\u001b[39m.\u001b[39;49m_plc_graph,\n\u001b[1;32m 30\u001b[0m degree_type\u001b[39m=\u001b[39;49m\u001b[39mNone\u001b[39;49;00m,\n\u001b[1;32m 31\u001b[0m do_expensive_check\u001b[39m=\u001b[39;49m\u001b[39mFalse\u001b[39;49;00m\n\u001b[1;32m 32\u001b[0m )\n\u001b[1;32m 34\u001b[0m df \u001b[39m=\u001b[39m cudf\u001b[39m.\u001b[39mDataFrame()\n\u001b[1;32m 35\u001b[0m df[\u001b[39m\"\u001b[39m\u001b[39mvertex\u001b[39m\u001b[39m\"\u001b[39m] \u001b[39m=\u001b[39m vertex\n", + "File \u001b[0;32mcore_number.pyx:124\u001b[0m, in \u001b[0;36mpylibcugraph.core_number.core_number\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mutils.pyx:51\u001b[0m, in \u001b[0;36mpylibcugraph.utils.assert_success\u001b[0;34m()\u001b[0m\n", + "\u001b[0;31mRuntimeError\u001b[0m: non-success value returned from cugraph_core_number: CUGRAPH_UNKNOWN_ERROR" + ] + } + ], "source": [ "# Call k-cores on the graph\n", "kcg = cugraph.k_core(G) " @@ -267,7 +276,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.13 ('cugraph_dev')", "language": "python", "name": "python3" }, @@ -285,7 +294,7 @@ }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "8a663d26f441a9657bbd22051a1abab57e1b3709a9f7822414e6eae68c6232e8" } } }, diff --git a/notebooks/algorithms/layout/Force-Atlas2.ipynb b/notebooks/algorithms/layout/Force-Atlas2.ipynb index 90c39294cca..00fb9318790 100644 --- a/notebooks/algorithms/layout/Force-Atlas2.ipynb +++ b/notebooks/algorithms/layout/Force-Atlas2.ipynb @@ -20,6 +20,7 @@ "| -----------------|------------|------------------|-----------------|----------------|\n", "| Hugo Linsenmaier | 11/16/2020 | created | 0.17 | GV100, CUDA 11.0\n", "| Brad Rees | 01/11/2022 | tested / updated | 22.02 nightly | RTX A6000 CUDA 11.5\n", + "| Ralph Liu | 06/22/2022 | updated/tested | 22.08 | TV100, CUDA 11.5\n", " " ] }, @@ -49,7 +50,6 @@ "outputs": [], "source": [ "# Import RAPIDS libraries\n", - "\n", "import cudf\n", "import cugraph\n", "import time" @@ -94,8 +94,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Define the path to the test data \n", - "datafile = '../../data/netscience.csv'" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import netscience" ] }, { @@ -127,18 +127,7 @@ "metadata": {}, "outputs": [], "source": [ - "edges_gdf = cudf.read_csv(datafile, names=[\"source\", \"destination\", \"weights\"],\n", - " delimiter=' ', dtype=[\"int32\", \"int32\", \"float32\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(edges_gdf, renumber=False)\n", + "G = netscience.get_graph()\n", "G.number_of_nodes(), G.number_of_edges()" ] }, @@ -187,6 +176,8 @@ "metadata": {}, "outputs": [], "source": [ + "edges_gdf = netscience.get_edgelist()\n", + "\n", "connected = calc_connected_edges(pos_gdf,\n", " edges_gdf,\n", " node_x=\"x\",\n", @@ -194,8 +185,8 @@ " node_x_dtype=\"float32\",\n", " node_y_dtype=\"float32\",\n", " node_id=\"vertex\",\n", - " edge_source=\"source\",\n", - " edge_target=\"destination\",\n", + " edge_source=\"src\",\n", + " edge_target=\"dst\",\n", " edge_aggregate_col=None,\n", " edge_render_type=\"direct\",\n", " )" @@ -234,7 +225,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.8.13 ('cugraph_dev')", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -248,11 +239,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.13" + "version": "3.9.7" }, "vscode": { "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" } } }, diff --git a/notebooks/applications/CostMatrix.ipynb b/notebooks/applications/CostMatrix.ipynb deleted file mode 100644 index 687b1526069..00000000000 --- a/notebooks/applications/CostMatrix.ipynb +++ /dev/null @@ -1,641 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# How to compute a _Cost Matrix_ by replicating data\n", - "# Skip notebook test\n", - "\n", - "### Approach\n", - "A simple approach to creating a cost matrix is to run All-Source Shortest Path (ASSP), however cuGraph currently does not have an All-Source Shortest Path (ASSP) algorithm. One is on the roadmap, based on Floyd-Warshall, but that doesn't help us today. Luckily there is a work around if the graph to be processed is small. The hack is to run ASSP by creating a lot of copies of the graph and running the Single Source Shortest Path (SSSP) on one seed per graph copy. Since each SSSP run within its own disjoint component, there is no issue with path collisions between seeds. \n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Notebook Organization\n", - "The first portion of the notebook discusses each step independently. It gives insight into what is going on and how fast each step takes.\n", - "\n", - "The second section puts it all the steps together in a single function and times how long with would take to compute the matrix\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Data\n", - "\n", - "In this notebook we will use the email-Eu-core\n", - "\n", - "* Number of Vertices: 1,005\n", - "* Number of Edges: 25,571\n", - "\n", - "We are using this dataset since it is small with a few communities, meaning that there are paths to be found." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Notebook Revisions\n", - "\n", - "| Author Credit | Date | Update | cuGraph Version | Test Hardware |\n", - "| --------------|------------|------------------|-----------------|----------------|\n", - "| Brad Rees | 06/21/2022 | created | 22.08 | V100 w 32 GB, CUDA 11.5\n", - "| Don Acosta | 06/28/2022 | modified | 22.08 | V100 w 32 GB, CUDA 11.5" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### References\n", - "\n", - "* https://www.sciencedirect.com/topics/mathematics/cost-matrix\n", - "* https://en.wikipedia.org/wiki/Shortest_path_problem\n", - "\n", - "Dataset\n", - "* Hao Yin, Austin R. Benson, Jure Leskovec, and David F. Gleich. Local Higher-order Graph Clustering. In Proceedings of the 23rd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining. 2017.\n", - "\n", - "* J. Leskovec, J. Kleinberg and C. Faloutsos. Graph Evolution: Densification and Shrinking Diameters. ACM Transactions on Knowledge Discovery from Data (ACM TKDD), 1(1), 2007. http://www.cs.cmu.edu/~jure/pubs/powergrowth-tkdd.pdf\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# system and other\n", - "import time\n", - "from time import perf_counter\n", - "import math\n", - "\n", - "# rapids\n", - "import cugraph\n", - "import cudf" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "-----\n", - "# Reading the data\n", - "\n", - "Let's start with data read" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# simple function to read in the CSV data file\n", - "def read_data_cudf(datafile):\n", - " gdf = cudf.read_csv(datafile,\n", - " delimiter=\" \",\n", - " header=None,\n", - " names=['src','dst', 'wt'])\n", - " return gdf" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# function to determine the number of nodes in the dataset\n", - "def find_number_of_nodes(df):\n", - " node = cudf.concat([df['src'], df['dst']])\n", - " node = node.unique()\n", - " return len(node)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read the data and verify that it is zero based (e.g. first vertex is 0)\n", - "**IMPORTANT:** The node numbering must be zero based. We use the starting index on the replicated graph to be one larger than the number of vertices. If the starting index is not zero, then the graph copies will overlap in index space and not be independent (disjoint). " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t1 = perf_counter()\n", - "gdf = read_data_cudf('../data/email-Eu-core.csv')\n", - "read_t = perf_counter() - t1" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(f\" read {len(gdf)} edges in {read_t} seconds\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# verify that the starting ID is zero\n", - "min([gdf['src'].min(), gdf['dst'].min()])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# check the max ID\n", - "max([gdf['src'].max(), gdf['dst'].max()])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# the number of nodes should be one greater than the max ID\n", - "# that is the ID that we start the next instance of the data at\n", - "offset = find_number_of_nodes(gdf)\n", - "print(offset)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Now let's dive into how to replicate the data\n", - "We will use a model that doubles the data at each pass. That is a lot faster \n", - "than adding one copy at a time. \n", - "The number of disjoint versions of the data will be a power of 2.\n", - "Although the power of 2 replication results in faster data set growth and Graph building, the simple order one replication is shown here for illustration purposes.\n", - "\n", - "\n", - "![Data Duplicated](../../notebooks/img/graph_after_replication.png)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# This function creates additional version of the data \n", - "\n", - "def make_data(base_df, N):\n", - " id = find_number_of_nodes(base_df)\n", - " _d = base_df\n", - "\n", - " for x in range(N):\n", - " tmp = _d.copy()\n", - " tmp['src'] += id\n", - " tmp['dst'] += id\n", - " _d = cudf.concat([_d,tmp])\n", - " id = id * 2\n", - " return _d" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%timeit\n", - "_ = make_data(gdf, 3)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "gdf2 = make_data(gdf, 3)\n", - "print()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# simple print to show tha there is not a lot more data\n", - "# print # of Edges and # of Nodes\n", - "print(f\"Old {len(gdf)} {find_number_of_nodes(gdf)}\")\n", - "print(f\"New {len(gdf2)} {find_number_of_nodes(gdf2)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Build the ghost node connection set\n", - "A ghost node is an artificially added node to parallelize/simulate the all-points shortest path algorithm which is not yet supported.\n", - "After the ghost node is added, the 2nd hop is actually the all points shortest path.\n", - "The Ghost node is later removed after the Shortest path algorithms are run.\n", - "\n", - "![Ghost Node](../../notebooks/img/graph_after_ghost.png)\n", - "\n", - "The Ghost Node is connected to a different corresponding node in each replication so all sources are covered.\n", - "\n", - "In this simple example of a four-node 'square' graph after complete replication and adding the ghost node, the graph looks like this:\n", - "\n", - "![Ghost Node](../../notebooks/img/Full-four_node_replication.png)\n", - "\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def add_ghost_node(_df, N):\n", - " # get the size of the graph. That number will be the ghost node ID\n", - " ghost_node_id = find_number_of_nodes(_df)\n", - " \n", - " num_copies = math.floor(math.pow(2, N))\n", - "\n", - " seeds = cudf.DataFrame()\n", - " seeds['dst'] = [((offset * x) + x) for x in range(num_copies)]\n", - " seeds['src'] = ghost_node_id\n", - " \n", - " _d = cudf.concat([_df, seeds])\n", - " \n", - " return _d, ghost_node_id" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%timeit\n", - "_, _ = add_ghost_node(gdf2, 10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "gdf_with_ghost, ghost_id = add_ghost_node(gdf2, 10)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Create an Empty directed Graph" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "G = cugraph.Graph(directed=True)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Populate the new graph with an edgelist containing\n", - "* The original Data\n", - "* The replicated data copies\n", - "* Each replication connected to the Ghost Node by a single edge from a different node\n", - "in each copy of the graph." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%time\n", - "G.from_cudf_edgelist(gdf_with_ghost, source='src', destination='dst', renumber=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%time\n", - "G.number_of_edges()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Run Single Source Shortest Path (SSSP) from the ghost node\n", - "The single Ghost node source becomes a all-source shortest path after one hop since all the\n", - "replicated data is connected through that node. This will include extraneous ghost node related data which will be removed in later steps." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%timeit\n", - "X = cugraph.sssp(G, ghost_id)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "X = cugraph.sssp(G, ghost_id)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This result will contain a ghost node like the simple example." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "X.head(5)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Now reset vertex IDs and convert to a cost matrix\n", - "All edges with the ghost node as a source are removed here." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# drop the ghost node which doesnt exist so remove from matrix.\n", - "X = X[X['predecessor'] != ghost_id]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Apply the CuGraph filter which removes all nodes not encountered during the graph traversal. In this case the SSSP." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# drop unreachable\n", - "X = cugraph.filter_unreachable(X)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Remove the path cost that was incurred by going to the single seed in each copy from the ghost node." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# adjust distances so that they don't go to the ghost node\n", - "X['distance'] -= 1" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Now the Ghost node and tangential edges are removed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "X.head(5)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Calculate the seed for each copy. This is where it is critical that the original graph node numbering is zero based." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# add a new column for the seed\n", - "# since each seed was a different component with a different offset amount, exploit that to determine the seed number\n", - "X['seed'] = (X['vertex'] / offset).astype(int)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "X.head(5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Now adjust all vertices to be in the correct range\n", - "# resets the seed number to the\n", - "X['v2'] = X['vertex'] - (X['seed'] * offset)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Finally just pull out the cost matrix\n", - "cost = X.drop(columns=['vertex', 'predecessor'])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cost.head(10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# cleanup \n", - "del G\n", - "del X\n", - "del gdf_with_ghost\n", - "del gdf2" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "----\n", - "# Section 2: Do it all in a single function" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Set the number of replications - 10 will produce 1,024 graphs\n", - "N = 10" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def build_cost_matrix(_gdf):\n", - " data = make_data(_gdf, N)\n", - " gdf_with_ghost, ghost_id = add_ghost_node(data, N)\n", - " \n", - " G = cugraph.Graph(directed=True)\n", - " G.from_cudf_edgelist(gdf_with_ghost, source='src', destination='dst', renumber=False)\n", - " \n", - " X = cugraph.sssp(G, ghost_id)\n", - " \n", - " X = X[X['predecessor'] != ghost_id]\n", - " X = cugraph.filter_unreachable(X)\n", - " X['distance'] -= 1\n", - " X['seed'] = (X['vertex'] / offset).astype(int)\n", - " X['v2'] = X['vertex'] - (X['seed'] * offset)\n", - " cost = X.drop(columns=['vertex', 'predecessor'])\n", - " \n", - " return cost" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%timeit\n", - "CM = build_cost_matrix(gdf)\n", - "CM" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "CM = build_cost_matrix(gdf)\n", - "CM.head(5)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "___\n", - "Copyright (c) 2022, NVIDIA CORPORATION.\n", - "\n", - "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", - "\n", - "Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.\n", - "___" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "cugraph_dev", - "language": "python", - "name": "cugraph_dev" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.13" - }, - "vscode": { - "interpreter": { - "hash": "cee8a395f2f0c5a5bcf513ae8b620111f4346eff6dc64e1ea99c951b2ec68604" - } - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/notebooks/centrality/Centrality.ipynb b/notebooks/centrality/Centrality.ipynb new file mode 100644 index 00000000000..dbc2a0e18a8 --- /dev/null +++ b/notebooks/centrality/Centrality.ipynb @@ -0,0 +1,386 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Centrality" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this notebook, we will compute vertex centrality scores using the various cuGraph algorithms. We will then compare the similarities and differences.\n", + "\n", + "| Author Credit | Date | Update | cuGraph Version | Test Hardware |\n", + "| --------------|------------|------------------|-----------------|----------------|\n", + "| Brad Rees | 04/16/2021 | created | 0.19 | GV100, CUDA 11.0\n", + "| | 08/05/2021 | tested / updated | 21.10 nightly | RTX 3090 CUDA 11.4\n", + "| Ralph Liu | 06/22/2022 | test/update | 22.08 | T100, Cuda 11.5\n", + "\n", + " " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Centrality is measure of how important, or central, a node or edge is within a graph. It is useful for identifying influencer in social networks, key routing nodes in communication/computer network infrastructures, \n", + "\n", + "The seminal paper on centrality is: Freeman, L. C. (1978). Centrality in social networks conceptual clarification. Social networks, 1(3), 215-239.\n", + "\n", + "\n", + "__Degree centrality__ – _done but needs an API_
\n", + "Degree centrality is based on the notion that whoever has the most connections must be important. \n", + "\n", + "
\n", + " Cd(v) = degree(v)\n", + "
\n", + "\n", + "cuGraph currently does not have a Degree Centrality function call. However, since Degree Centrality is just the degree of a node, we can use _G.degree()_ function.\n", + "Degree Centrality for a Directed graph can be further divided in _indegree centrality_ and _outdegree centrality_ and can be obtained using _G.degrees()_\n", + "\n", + "\n", + "___Closeness centrality – coming soon___
\n", + "Closeness is a measure of the shortest path to every other node in the graph. A node that is close to every other node, can reach over other node in the fewest number of hops, means that it has greater influence on the network versus a node that is not close.\n", + "\n", + "__Betweenness Centrality__
\n", + "Betweenness is a measure of the number of shortest paths that cross through a node, or over an edge. A node with high betweenness means that it had a greater influence on the flow of information. \n", + "\n", + "Betweenness centrality of a node 𝑣 is the sum of the fraction of all-pairs shortest paths that pass through 𝑣\n", + "\n", + "
\n", + " \n", + "
\n", + "\n", + "To speedup runtime of betweenness centrailty, the metric can be computed on a limited number of nodes (randomly selected) and then used to estimate the other scores. For this example, the graphs are relatively small (under 5,000 nodes) so betweenness on every node will be computed.\n", + "\n", + "___Eigenvector Centrality - coming soon___
\n", + "Eigenvectors can be thought of as the balancing points of a graph, or center of gravity of a 3D object. High centrality means that more of the graph is balanced around that node.\n", + "\n", + "__Katz Centrality__
\n", + "Katz is a variant of degree centrality and of eigenvector centrality. \n", + "Katz centrality is a measure of the relative importance of a node within the graph based on measuring the influence across the total number of walks between vertex pairs. \n", + "\n", + "
\n", + " \n", + "
\n", + "\n", + "See:\n", + "* [Katz on Wikipedia](https://en.wikipedia.org/wiki/Katz_centrality) for more details on the algorithm.\n", + "* https://www.sci.unich.it/~francesc/teaching/network/katz.html\n", + "\n", + "__PageRank__
\n", + "PageRank is classified as both a Link Analysis tool and a centrality measure. PageRank is based on the assumption that important nodes point (directed edge) to other important nodes. From a social network perspective, the question is who do you seek for an answer and then who does that person seek. PageRank is good when there is implied importance in the data, for example a citation network, web page linkages, or trust networks. \n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Test Data\n", + "We will be using the Zachary Karate club dataset \n", + "*W. W. Zachary, An information flow model for conflict and fission in small groups, Journal of\n", + "Anthropological Research 33, 452-473 (1977).*\n", + "\n", + "\n", + "![Karate Club](../img/zachary_black_lines.png)\n", + "\n", + "\n", + "Because the test data has vertex IDs starting at 1, the auto-renumber feature of cuGraph (mentioned above) will be used so the starting vertex ID is zero for maximum efficiency. The resulting data will then be auto-unrenumbered, making the entire renumbering process transparent to users." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import the modules\n", + "import cugraph\n", + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd \n", + "from IPython.display import display_html " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Functions\n", + "using underscore variable names to avoid collisions. \n", + "non-underscore names are expected to be global names" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Compute Centrality\n", + "# the centrality calls are very straightforward with the graph being the primary argument\n", + "# we are using the default argument values for all centrality functions\n", + "\n", + "def compute_centrality(_graph) :\n", + " # Compute Degree Centrality\n", + " _d = _graph.degree()\n", + " \n", + " # Compute the Betweenness Centrality\n", + " _b = cugraph.betweenness_centrality(_graph)\n", + "\n", + " # Compute Katz Centrality\n", + " _k = cugraph.katz_centrality(_graph)\n", + " \n", + " # Compute PageRank Centrality\n", + " _p = cugraph.pagerank(_graph)\n", + " \n", + " return _d, _b, _k, _p" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Print function\n", + "# being lazy and requiring that the dataframe names are not changed versus passing them in\n", + "def print_centrality(_n):\n", + " dc_top = dc.sort_values(by='degree', ascending=False).head(_n).to_pandas()\n", + " bc_top = bc.sort_values(by='betweenness_centrality', ascending=False).head(_n).to_pandas()\n", + " katz_top = katz.sort_values(by='katz_centrality', ascending=False).head(_n).to_pandas()\n", + " pr_top = pr.sort_values(by='pagerank', ascending=False).head(_n).to_pandas()\n", + " \n", + " df1_styler = dc_top.style.set_table_attributes(\"style='display:inline'\").set_caption('Degree').hide_index()\n", + " df2_styler = bc_top.style.set_table_attributes(\"style='display:inline'\").set_caption('Betweenness').hide_index()\n", + " df3_styler = katz_top.style.set_table_attributes(\"style='display:inline'\").set_caption('Katz').hide_index()\n", + " df4_styler = pr_top.style.set_table_attributes(\"style='display:inline'\").set_caption('PageRank').hide_index()\n", + "\n", + " display_html(df1_styler._repr_html_()+df2_styler._repr_html_()+df3_styler._repr_html_()+df4_styler._repr_html_(), raw=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create a Graph" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a graph using the imported Dataset object\n", + "G = karate.get_graph(fetch=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compute Centrality" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dc, bc, katz, pr = compute_centrality(G)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Results\n", + "Typically, analysts just look at the top 10% of results. Basically just those vertices that are the most central or important. \n", + "The karate data has 32 vertices, so let's round a little and look at the top 5 vertices" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print_centrality(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### A Different Dataset\n", + "The Karate dataset is not that large or complex, which makes it a perfect test dataset since it is easy to visually verify results. Let's look at a larger dataset with a lot more edges" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import a different dataset object\n", + "from cugraph.experimental.datasets import netscience" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "G = netscience.get_graph(fetch=True)\n", + "(G.number_of_nodes(), G.number_of_edges())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dc, bc, katz, pr = compute_centrality(G)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print_centrality(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now see a larger discrepancy between the centrality scores and which nodes rank highest.\n", + "Which centrality measure to use is left to the analyst to decide and does require insight into the difference algorithms and graph structure." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### And One More Dataset\n", + "Let's look at a Cyber dataset. The vertex ID are IP addresses" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import a different dataset object\n", + "from cugraph.experimental.datasets import cyber" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get the edgelist\n", + "gdf = cyber.get_edgelist(fetch=True)\n", + "\n", + "# Create a Graph\n", + "G = cugraph.Graph()\n", + "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "(G.number_of_nodes(), G.number_of_edges())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dc, bc, katz, pr = compute_centrality(G)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print_centrality(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are differences in how each centrality measure ranks the nodes. In some cases, every algorithm returns similar results, and in others, the results are different. Understanding how the centrality measure is computed and what edge represent is key to selecting the right centrality metric." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "----\n", + "Copyright (c) 2019-2021, NVIDIA CORPORATION.\n", + "\n", + "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", + "\n", + "Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.6.9 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.9" + }, + "vscode": { + "interpreter": { + "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/img/Full-four_node_replication.png b/notebooks/img/Full-four_node_replication.png deleted file mode 100644 index 8cbc3cd1dcaeb7ed4cb08613e353ef3baab61d13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17291 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 z zjo_Cn0`?shwdw6?+UtPciFnPe%oHi;MKMy4rXxw-zA+|`)qRDzG!}L;7}2oh`pm2_ zWL`DCC?Uv0hye7%fXoCpzI#z`0GF6v)k@ipKWzwR;wozLeH*v#g^|mNUJC@z1zwhB zZ^YW_#Jz`kGuc00hVXiL=7lC3Vd&5x!=Y$WJ2087Hdgm=EZja@o$ zq{oKe0_oR4jJ5>^^stD+pa2sgaP%Ec{0wbPoG3-gg#Gyc^!FkD&wo^cnE2YS0sfL- VeO+4y_yG-!*Yc`zr81`Q{|oXd=}rIu diff --git a/notebooks/img/graph_after_ghost.png b/notebooks/img/graph_after_ghost.png deleted file mode 100644 index 256ac9b5425ccaba8b3ef42ff40b93fa639e8879..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37330 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 diff --git a/notebooks/link_analysis/HITS.ipynb b/notebooks/link_analysis/HITS.ipynb index b564acb565e..91a78473dae 100755 --- a/notebooks/link_analysis/HITS.ipynb +++ b/notebooks/link_analysis/HITS.ipynb @@ -12,13 +12,13 @@ "Notebook Credits\n", "* Original Authors: Bradley Rees and James Wyles\n", "* Created: 06/09/2020\n", - "* Updated: 08/16/2020\n", + "* Updated: 06/22/2022\n", "\n", "RAPIDS Versions: 0.15 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 10.0\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "\n", "## Introduction\n", @@ -139,8 +139,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Define the path to the test data \n", - "datafile='../data/karate-data.csv'" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -158,6 +158,7 @@ "outputs": [], "source": [ "# Read the data, this also created a NetworkX Graph \n", + "datafile = \"../data/karate-data.csv\"\n", "file = open(datafile, 'rb')\n", "Gnx = nx.read_edgelist(file)" ] @@ -197,26 +198,6 @@ "# cuGraph" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read in the data - GPU\n", - "cuGraph depends on cuDF for data loading and the initial Dataframe creation\n", - "\n", - "The data file contains an edge list, which represents the connection of a vertex to another. The `source` to `destination` pairs is in what is known as Coordinate Format (COO). In this test case, the data is just two columns. However a third, `weight`, column is also possible" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Read the data \n", - "gdf = cudf.read_csv(datafile, names=[\"src\", \"dst\"], delimiter='\\t', dtype=[\"int32\", \"int32\"] )" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -230,9 +211,7 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph using the source (src) and destination (dst) vertex pairs from the Dataframe \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "G = karate.get_graph(fetch=True)" ] }, { @@ -379,9 +358,9 @@ ], "metadata": { "kernelspec": { - "display_name": "cugraph_dev", + "display_name": "Python 3.9.7 ('base')", "language": "python", - "name": "cugraph_dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -393,7 +372,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/link_analysis/Pagerank.ipynb b/notebooks/link_analysis/Pagerank.ipynb index 2ee8ca045c3..ebd8d32905b 100755 --- a/notebooks/link_analysis/Pagerank.ipynb +++ b/notebooks/link_analysis/Pagerank.ipynb @@ -13,13 +13,13 @@ "Notebook Credits\n", "* Original Authors: Bradley Rees and James Wyles\n", "* Created: 08/13/2019\n", - "* Updated: 04/06/2022\n", + "* Updated: 06/22/2022\n", "\n", - "RAPIDS Versions: 22.04 \n", + "RAPIDS Versions: 22.08 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 11.5\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "\n", "## Introduction\n", @@ -129,8 +129,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Define the path to the test data \n", - "datafile='../data/karate-data.csv'" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -147,7 +147,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Read the data, this also creates a NetworkX Graph \n", + "# Read the data, this also creates a NetworkX Graph\n", + "datafile = \"../data/karate-data.csv\"\n", "file = open(datafile, 'rb')\n", "Gnx = nx.read_edgelist(file)" ] @@ -187,26 +188,6 @@ "# cuGraph" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Read in the data - GPU\n", - "cuGraph graphs can be created from cuDF, dask_cuDF and Pandas dataframes\n", - "\n", - "The data file contains an edge list, which represents the connection of a vertex to another. The `source` to `destination` pairs is in what is known as Coordinate Format (COO). In this test case, the data is just two columns. However a third, `weight`, column is also possible" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Read the data \n", - "gdf = cudf.read_csv(datafile, names=[\"src\", \"dst\"], delimiter='\\t', dtype=[\"int32\", \"int32\"] )" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -220,8 +201,7 @@ "metadata": {}, "outputs": [], "source": [ - "# create a Graph using the source (src) and destination (dst) vertex pairs from the Dataframe \n", - "G = cugraph.from_edgelist(gdf, source='src', destination='dst')" + "G = karate.get_graph(fetch=True)" ] }, { @@ -446,7 +426,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -461,6 +441,11 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/link_prediction/Jaccard-Similarity.ipynb b/notebooks/link_prediction/Jaccard-Similarity.ipynb index e3c6e7fa4cc..1c94ec2a023 100755 --- a/notebooks/link_prediction/Jaccard-Similarity.ipynb +++ b/notebooks/link_prediction/Jaccard-Similarity.ipynb @@ -17,12 +17,12 @@ "\n", " Original Authors: Brad Rees\n", " Created: 10/14/2019\n", - " Last Edit: 08/16/2020\n", + " Last Edit: 06/22/2022\n", "\n", - "RAPIDS Versions: 0.14\n", + "RAPIDS Versions: 22.08\n", "\n", "Test Hardware\n", - "* GV100 32G, CUDA 10.2\n" + "* Tesla V100 32G, CUDA 11.5\n" ] }, { @@ -221,8 +221,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Read the CSV datafile using cuDF\n", - "data file is actually _tab_ separated, so we need to set the delimiter" + "### Create an Edgelist" ] }, { @@ -231,10 +230,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Test file \n", - "datafile='../data/karate-data.csv'\n", - "\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "from cugraph.experimental.datasets import karate\n", + "gdf = karate.get_edgelist()" ] }, { @@ -271,8 +268,8 @@ "outputs": [], "source": [ "# create a Graph \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "G = karate.get_graph()\n", + "G = G.to_undirected()" ] }, { @@ -472,20 +469,25 @@ "---\n", "### It's that easy with cuGraph\n", "\n", - "Copyright (c) 2019-2020, NVIDIA CORPORATION.\n", + "Copyright (c) 2019-2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", "Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.\n", "___" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "cugraph_dev", + "display_name": "Python 3.9.7 ('base')", "language": "python", - "name": "cugraph_dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -497,7 +499,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/link_prediction/Overlap-Similarity.ipynb b/notebooks/link_prediction/Overlap-Similarity.ipynb index d71078ed061..5b3633f4014 100755 --- a/notebooks/link_prediction/Overlap-Similarity.ipynb +++ b/notebooks/link_prediction/Overlap-Similarity.ipynb @@ -16,7 +16,8 @@ "| --------------|------------|------------------|-----------------|--------------------|\n", "| Brad Rees | 10/14/2019 | created | 0.08 | GV100, CUDA 10.0 |\n", "| | 08/16/2020 | upadted | 0.12 | GV100, CUDA 10.0 |\n", - "| | 08/05/2021 | tested / updated | 21.10 nightly | RTX 3090 CUDA 11.4 |\n" + "| | 08/05/2021 | tested / updated | 21.10 nightly | RTX 3090 CUDA 11.4 |\n", + "| Ralph Liu | 06/22/2022 | updated/tested | 22.08 | TV100, CUDA 11.5 |\n" ] }, { @@ -239,8 +240,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Read the CSV datafile using cuDF\n", - "data file is actually _tab_ separated, so we need to set the delimiter" + "### Import a Dataset Object" ] }, { @@ -249,10 +249,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Test file \n", - "datafile='../data/karate-data.csv'\n", - "\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "from cugraph.experimental.datasets import karate\n", + "gdf = karate.get_edgelist(fetch=True)" ] }, { @@ -289,8 +287,8 @@ "outputs": [], "source": [ "# create a Graph \n", - "G = cugraph.Graph()\n", - "G.from_cudf_edgelist(gdf, source='src', destination='dst')" + "G = karate.get_graph()\n", + "G = G.to_undirected()" ] }, { @@ -582,9 +580,9 @@ ], "metadata": { "kernelspec": { - "display_name": "cugraph_dev", + "display_name": "Python 3.9.7 ('base')", "language": "python", - "name": "cugraph_dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -596,7 +594,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.13" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/sampling/RandomWalk.ipynb b/notebooks/sampling/RandomWalk.ipynb index afceff5378d..caacf909259 100644 --- a/notebooks/sampling/RandomWalk.ipynb +++ b/notebooks/sampling/RandomWalk.ipynb @@ -9,9 +9,10 @@ "In this notebook, we will compute the Random Walk from a set of seeds using cuGraph. \n", "\n", "\n", - "| Author Credit | Date | Update | cuGraph Version | Test Hardware |\n", - "| --------------|------------|--------------|-----------------|----------------|\n", - "| Brad Rees | 04/20/2021 | created | 0.19 | GV100, CUDA 11.0\n", + "| Author Credit | Date | Update | cuGraph Version | Test Hardware |\n", + "| --------------|------------|----------------|-----------------|----------------|\n", + "| Brad Rees | 04/20/2021 | created | 0.19 | GV100, CUDA 11.0\n", + "| Ralph Liu | 06/22/2022 | updated/tested | 22.08 | TV100, CUDA 11.5\n", "\n", "Currently NetworkX does not have a random walk function. There is code on StackOverflow that generats a random walk by getting a vertice and then randomly selection a neighbor and then repeating the process. " ] @@ -40,7 +41,10 @@ "source": [ "# Import the modules\n", "import cugraph\n", - "import cudf" + "import cudf\n", + "\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate" ] }, { @@ -49,11 +53,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Read The Data\n", - "# Define the path to the test data \n", - "datafile='../data/karate-data.csv'\n", - "\n", - "gdf = cudf.read_csv(datafile, delimiter='\\t', names=['src', 'dst'], dtype=['int32', 'int32'] )" + "gdf = karate.get_edgelist(fetch=True)" ] }, { @@ -156,7 +156,7 @@ "metadata": {}, "source": [ "-----\n", - "Copyright (c) 2021, NVIDIA CORPORATION.\n", + "Copyright (c) 2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", @@ -166,9 +166,9 @@ ], "metadata": { "kernelspec": { - "display_name": "cugraph_dev", + "display_name": "Python 3.9.7 ('base')", "language": "python", - "name": "cugraph_dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -180,7 +180,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.10" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/structure/Renumber-2.ipynb b/notebooks/structure/Renumber-2.ipynb index a26becbb99f..03858b3e52a 100755 --- a/notebooks/structure/Renumber-2.ipynb +++ b/notebooks/structure/Renumber-2.ipynb @@ -20,13 +20,13 @@ "| Brad Rees | 08/13/2019 | created |\n", "| Brad Rees | 07/08/2020 | updated |\n", "| Ralph Liu | 06/01/2022 | docs & code change |\n", + "| | 06/22/2022 | update |\n", "\n", - "RAPIDS Versions: 0.13 \n", - "cuGraph Version: 22.06 \n", + "RAPIDS Versions: 22.08 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 11.5\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "\n", "## Introduction\n", @@ -83,12 +83,9 @@ "metadata": {}, "outputs": [], "source": [ - "# Read the data\n", - "# the file contains an index column that will be ignored\n", - "\n", - "datafile='../data/cyber.csv'\n", - "\n", - "gdf = cudf.read_csv(datafile, delimiter=',', names=['idx','srcip','dstip'], dtype=['int32','str', 'str'], skiprows=1, usecols=['srcip', 'dstip'] )" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import cyber\n", + "gdf = cyber.get_edgelist(fetch=True)" ] }, { @@ -104,6 +101,9 @@ "metadata": {}, "outputs": [], "source": [ + "# trim\n", + "gdf = gdf[1:]\n", + "\n", "# take a peek at the data\n", "gdf.head()" ] @@ -115,8 +115,8 @@ "outputs": [], "source": [ "# Since IP columns are strings, we first need to convert them to integers\n", - "gdf['src_ip'] = gdf['srcip'].str.ip2int()\n", - "gdf['dst_ip'] = gdf['dstip'].str.ip2int()" + "gdf['src_ip'] = gdf['src'].str.ip2int()\n", + "gdf['dst_ip'] = gdf['dst'].str.ip2int()" ] }, { @@ -225,7 +225,7 @@ "metadata": {}, "source": [ "___\n", - "Copyright (c) 2019-2020, NVIDIA CORPORATION.\n", + "Copyright (c) 2019-2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", @@ -235,11 +235,8 @@ } ], "metadata": { - "interpreter": { - "hash": "31f2aee4e71d21fbe5cf8b01ff0e069b9275f58929596ceb00d14d90e3e16cd6" - }, "kernelspec": { - "display_name": "Python 3.6.9 64-bit", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -253,7 +250,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.9" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/structure/Renumber.ipynb b/notebooks/structure/Renumber.ipynb index 8c7b4e615eb..903ed9df389 100755 --- a/notebooks/structure/Renumber.ipynb +++ b/notebooks/structure/Renumber.ipynb @@ -16,13 +16,13 @@ "Notebook Credits\n", "* Original Authors: Chuck Hastings and Bradley Rees\n", "* Created: 08/13/2019\n", - "* Updated: 07/08/2020\n", + "* Updated: 06/22/2022\n", "\n", - "RAPIDS Versions: 0.15 \n", + "RAPIDS Versions: 22.08 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 10.2\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "## Introduction\n", "\n", @@ -63,7 +63,7 @@ "import pandas as pd\n", "import numpy as np\n", "import networkx as nx\n", - "from cugraph.structure import NumberMap\n" + "from cugraph.structure import NumberMap" ] }, { @@ -331,7 +331,7 @@ "metadata": {}, "source": [ "___\n", - "Copyright (c) 2019-2020, NVIDIA CORPORATION.\n", + "Copyright (c) 2019-2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", @@ -342,7 +342,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -356,7 +356,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/structure/Symmetrize.ipynb b/notebooks/structure/Symmetrize.ipynb index 30b9d5dc618..f0c57baf070 100755 --- a/notebooks/structure/Symmetrize.ipynb +++ b/notebooks/structure/Symmetrize.ipynb @@ -11,13 +11,13 @@ "Notebook Credits\n", "* Original Authors: Bradley Rees and James Wyles\n", "* Created: 08/13/2019\n", - "* Updated: 03/02/2020\n", + "* Updated: 06/22/2022\n", "\n", - "RAPIDS Versions: 0.13 \n", + "RAPIDS Versions: 22.08 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 10.2\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "\n", "## Introduction\n", @@ -80,9 +80,11 @@ "metadata": {}, "outputs": [], "source": [ - "# load the full symmetrized dataset for comparison\n", - "datafile='../data/karate-data.csv'\n", - "test_gdf = cudf.read_csv(datafile, names=[\"src\", \"dst\"], delimiter='\\t', dtype=[\"int32\", \"int32\"] )" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate\n", + "\n", + "# This is the symmetrized dataset\n", + "test_gdf = karate.get_edgelist(fetch=True)" ] }, { @@ -162,7 +164,7 @@ "metadata": {}, "source": [ "---\n", - "Copyright (c) 2019-2020, NVIDIA CORPORATION.\n", + "Copyright (c) 2019-2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", @@ -173,9 +175,9 @@ ], "metadata": { "kernelspec": { - "display_name": "cugraph_dev", + "display_name": "Python 3.9.7 ('base')", "language": "python", - "name": "cugraph_dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -187,7 +189,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/traversal/BFS.ipynb b/notebooks/traversal/BFS.ipynb index ce768967eb9..b9409e2f821 100755 --- a/notebooks/traversal/BFS.ipynb +++ b/notebooks/traversal/BFS.ipynb @@ -10,13 +10,13 @@ "Notebook Credits\n", "* Original Authors: Bradley Rees and James Wyles\n", "* Feature available since 0.6\n", - "* Last Edit: 08/16/2020\n", + "* Last Edit: 06/22/2022\n", "\n", - "RAPIDS Versions: 0.14.0 \n", + "RAPIDS Versions: 22.08 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 10.0\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "\n", "\n", @@ -94,7 +94,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Read the data using cuDF" + "# Create an Edgelist" ] }, { @@ -103,10 +103,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Read the data file\n", - "datafile='../data/karate-data.csv'\n", + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate\n", "\n", - "gdf = cudf.read_csv(datafile, names=[\"src\", \"dst\"], delimiter='\\t', dtype=[\"int32\", \"int32\"] )" + "gdf = karate.get_edgelist(fetch=True)" ] }, { @@ -257,7 +257,7 @@ "metadata": {}, "source": [ "___\n", - "Copyright (c) 2019-2020, NVIDIA CORPORATION.\n", + "Copyright (c) 2019-2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", @@ -268,9 +268,9 @@ ], "metadata": { "kernelspec": { - "display_name": "cugraph_dev", + "display_name": "Python 3.9.7 ('base')", "language": "python", - "name": "cugraph_dev" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -282,7 +282,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, diff --git a/notebooks/traversal/SSSP.ipynb b/notebooks/traversal/SSSP.ipynb index 3d0ce1c4234..a5e981f38c2 100755 --- a/notebooks/traversal/SSSP.ipynb +++ b/notebooks/traversal/SSSP.ipynb @@ -11,14 +11,14 @@ "Notebook Credits\n", "* Original Authors: Bradley Rees and James Wyles\n", "* available since release 0.6\n", - "* Last Edit: 08/16/2020\n", + "* Last Edit: 06/22/2022\n", "\n", "\n", - "RAPIDS Versions: 0.12.0 \n", + "RAPIDS Versions: 22.08 \n", "\n", "Test Hardware\n", "\n", - "* GV100 32G, CUDA 10.0\n", + "* Tesla V100 32G, CUDA 11.5\n", "\n", "\n", "\n", @@ -79,7 +79,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Read the data and adjust the vertex IDs" + "### Create an Edgelist" ] }, { @@ -88,17 +88,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Test file - using the classic Karate club dataset. \n", - "datafile='../data/karate-data.csv'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "gdf = cudf.read_csv(datafile, names=[\"src\", \"dst\"], delimiter='\\t', dtype=[\"int32\", \"int32\"])" + "# Import a built-in dataset\n", + "from cugraph.experimental.datasets import karate\n", + "\n", + "gdf = karate.get_edgelist(fetch=True)" ] }, { @@ -173,7 +166,7 @@ "metadata": {}, "source": [ "___\n", - "Copyright (c) 2019-2020, NVIDIA CORPORATION.\n", + "Copyright (c) 2019-2022, NVIDIA CORPORATION.\n", "\n", "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n", "\n", @@ -184,7 +177,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3.9.7 ('base')", "language": "python", "name": "python3" }, @@ -198,7 +191,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.9.7" + }, + "vscode": { + "interpreter": { + "hash": "f708a36acfaef0acf74ccd43dfb58100269bf08fb79032a1e0a6f35bd9856f51" + } } }, "nbformat": 4, From ac42e0b3fdd16f90e04fbd666006e2024928f14a Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 2 Aug 2022 15:34:12 -0500 Subject: [PATCH 12/19] Don't store redundant columns in PropertyGraph Dataframes (#2449) The main purpose of this is to reduce memory usage. Closes #2400 I still need to update MG tests. I'll also remove the in-code assertions, since they won't always be True, because a column name could have previously been used as a property. Nevertheless, seeing these assertions pass should give us warm-fuzzies :) Authors: - Erik Welch (https://github.com/eriknw) Approvers: - Rick Ratzel (https://github.com/rlratzel) - Alex Barghi (https://github.com/alexbarghi-nv) - Vibhu Jawa (https://github.com/VibhuJawa) - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2449 --- .../dask/structure/mg_property_graph.py | 8 +++-- .../cugraph/structure/property_graph.py | 8 +++-- .../cugraph/tests/test_property_graph.py | 35 ++++++++++++------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/python/cugraph/cugraph/dask/structure/mg_property_graph.py b/python/cugraph/cugraph/dask/structure/mg_property_graph.py index 541360e64ec..42627711220 100644 --- a/python/cugraph/cugraph/dask/structure/mg_property_graph.py +++ b/python/cugraph/cugraph/dask/structure/mg_property_graph.py @@ -412,7 +412,9 @@ def add_vertex_data(self, # remove the ones to keep column_names_to_drop.difference_update(property_columns + default_vertex_columns) - tmp_df = tmp_df.drop(labels=column_names_to_drop, axis=1) + else: + column_names_to_drop = {vertex_col_name} + tmp_df = tmp_df.drop(labels=column_names_to_drop, axis=1) # Save the original dtypes for each new column so they can be restored # prior to constructing subgraphs (since column dtypes may get altered @@ -566,7 +568,9 @@ def add_edge_data(self, # remove the ones to keep column_names_to_drop.difference_update(property_columns + default_edge_columns) - tmp_df = tmp_df.drop(labels=column_names_to_drop, axis=1) + else: + column_names_to_drop = {vertex_col_names[0], vertex_col_names[1]} + tmp_df = tmp_df.drop(labels=column_names_to_drop, axis=1) # Save the original dtypes for each new column so they can be restored # prior to constructing subgraphs (since column dtypes may get altered diff --git a/python/cugraph/cugraph/structure/property_graph.py b/python/cugraph/cugraph/structure/property_graph.py index feeafd32026..09c7f6b0040 100644 --- a/python/cugraph/cugraph/structure/property_graph.py +++ b/python/cugraph/cugraph/structure/property_graph.py @@ -424,7 +424,9 @@ def add_vertex_data(self, # remove the ones to keep column_names_to_drop.difference_update(property_columns + default_vertex_columns) - tmp_df = tmp_df.drop(labels=column_names_to_drop, axis=1) + else: + column_names_to_drop = {vertex_col_name} + tmp_df.drop(labels=column_names_to_drop, axis=1, inplace=True) # Save the original dtypes for each new column so they can be restored # prior to constructing subgraphs (since column dtypes may get altered @@ -591,7 +593,9 @@ def add_edge_data(self, # remove the ones to keep column_names_to_drop.difference_update(property_columns + default_edge_columns) - tmp_df = tmp_df.drop(labels=column_names_to_drop, axis=1) + else: + column_names_to_drop = {vertex_col_names[0], vertex_col_names[1]} + tmp_df.drop(labels=column_names_to_drop, axis=1, inplace=True) # Save the original dtypes for each new column so they can be restored # prior to constructing subgraphs (since column dtypes may get altered diff --git a/python/cugraph/cugraph/tests/test_property_graph.py b/python/cugraph/cugraph/tests/test_property_graph.py index 586f0a80a56..b7cc6920cd1 100644 --- a/python/cugraph/cugraph/tests/test_property_graph.py +++ b/python/cugraph/cugraph/tests/test_property_graph.py @@ -333,11 +333,10 @@ def test_add_vertex_data(df_type): type_name="merchants", vertex_col_name="merchant_id", property_columns=None) - assert pG.get_num_vertices() == 5 assert pG.get_num_vertices('merchants') == 5 assert pG.get_num_edges() == 0 - expected_props = merchants[0].copy() + expected_props = set(merchants[0].copy()) - {'merchant_id'} assert sorted(pG.vertex_property_names) == sorted(expected_props) @@ -564,6 +563,7 @@ def test_get_vertex_data(dataset1_PropertyGraph): for d in ["merchants", "users"]: for name in data[d][0]: expected_columns.add(name) + expected_columns -= {'merchant_id', 'user_id'} actual_columns = set(some_vertex_data.columns) assert actual_columns == expected_columns @@ -620,6 +620,7 @@ def test_get_edge_data(dataset1_PropertyGraph): for d in ["transactions", "relationships", "referrals"]: for name in data[d][0]: expected_columns.add(name) + expected_columns -= {'user_id', 'user_id_1', 'user_id_2'} actual_columns = set(some_edge_data.columns) @@ -755,8 +756,8 @@ def test_add_edge_data(df_type): assert pG.get_num_vertices('transactions') == 0 assert pG.get_num_edges() == 4 assert pG.get_num_edges('transactions') == 4 - expected_props = ["merchant_id", "user_id", - "volume", "time", "card_num", "card_type"] + # Original SRC and DST columns no longer include "merchant_id", "user_id" + expected_props = ["volume", "time", "card_num", "card_type"] assert sorted(pG.edge_property_names) == sorted(expected_props) @@ -928,8 +929,9 @@ def test_extract_subgraph_specific_query(dataset1_PropertyGraph): (pG, data) = dataset1_PropertyGraph tcn = PropertyGraph.type_col_name + # _DST_ below used to be referred to as merchant_id selection = pG.select_edges(f"({tcn}=='transactions') & " - "(merchant_id==4) & " + "(_DST_==4) & " "(time>1639085000)") G = pG.extract_subgraph(selection=selection, create_using=DiGraph_inst, @@ -1023,7 +1025,13 @@ def test_extract_subgraph_no_edges(dataset1_PropertyGraph): """ (pG, data) = dataset1_PropertyGraph - selection = pG.select_vertices("(_TYPE_=='merchants') & (merchant_id==86)") + # "merchant_id" column is no longer saved; use as "_VERTEX_" + with pytest.raises(NameError, match="merchant_id"): + selection = pG.select_vertices( + "(_TYPE_=='merchants') & (merchant_id==86)" + ) + + selection = pG.select_vertices("(_TYPE_=='merchants') & (_VERTEX_==86)") G = pG.extract_subgraph(selection=selection) assert G.is_directed() @@ -1360,13 +1368,14 @@ def test_property_names_attrs(dataset1_PropertyGraph): """ (pG, data) = dataset1_PropertyGraph - expected_vert_prop_names = ["merchant_id", "merchant_location", - "merchant_size", "merchant_sales", - "merchant_num_employees", "merchant_name", - "user_id", "user_location", "vertical"] - expected_edge_prop_names = ["user_id", "merchant_id", "volume", "time", - "card_num", "card_type", "user_id_1", - "user_id_2", "relationship_type", "stars"] + # _VERTEX_ columns: "merchant_id", "user_id" + expected_vert_prop_names = ["merchant_location", "merchant_size", + "merchant_sales", "merchant_num_employees", + "user_location", "merchant_name", "vertical"] + # _SRC_ and _DST_ columns: "user_id", "user_id_1", "user_id_2" + # Note that "merchant_id" is a property in for type "transactions" + expected_edge_prop_names = ["merchant_id", "volume", "time", "card_num", + "card_type", "relationship_type", "stars"] # Extracting a subgraph with weights has/had a side-effect of adding a # weight column, so call extract_subgraph() to ensure the internal weight From d50622f68dbc431ba22cec0c77c34e7d12655806 Mon Sep 17 00:00:00 2001 From: Vibhu Jawa Date: Tue, 2 Aug 2022 14:38:44 -0700 Subject: [PATCH 13/19] Uniform neighbor sample (#2450) This PR switches `cugraphstore` to use uniform neighbor sampling. Opening this in favor of https://github.com/rapidsai/cugraph/pull/2426 Authors: - Vibhu Jawa (https://github.com/VibhuJawa) - Rick Ratzel (https://github.com/rlratzel) Approvers: - Alex Barghi (https://github.com/alexbarghi-nv) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2450 --- python/cugraph/cugraph/gnn/graph_store.py | 146 ++++++++++++------ .../cugraph/cugraph/tests/test_graph_store.py | 100 +++++++++++- 2 files changed, 190 insertions(+), 56 deletions(-) diff --git a/python/cugraph/cugraph/gnn/graph_store.py b/python/cugraph/cugraph/gnn/graph_store.py index ed78e81d204..0b40cc3bf0a 100644 --- a/python/cugraph/cugraph/gnn/graph_store.py +++ b/python/cugraph/cugraph/gnn/graph_store.py @@ -16,8 +16,9 @@ import cugraph from cugraph.experimental import PropertyGraph from cugraph.community.egonet import batched_ego_graphs -from cugraph.utilities.utils import sample_groups +from cugraph.sampling import uniform_neighbor_sample import cupy as cp +from functools import cached_property src_n = PropertyGraph.src_col_name @@ -83,7 +84,8 @@ def get_node_storage(self, key, ntype=None): ) ) ntype = ntypes[0] - + # FIXME: Remove once below lands + # https://github.com/rapidsai/cugraph/pull/2444 df = self.gdata._vertex_prop_dataframe col_names = self.ndata_key_col_d[key] return CuFeatureStorage( @@ -107,6 +109,8 @@ def get_edge_storage(self, key, etype=None): etype = etypes[0] col_names = self.edata_key_col_d[key] + # FIXME: Remove once below lands + # https://github.com/rapidsai/cugraph/pull/2444 df = self.gdata._edge_prop_dataframe return CuFeatureStorage( df=df, @@ -124,12 +128,16 @@ def num_edges(self, etype=None): @property def ntypes(self): + # FIXME: Remove once below is fixed + # https://github.com/rapidsai/cugraph/issues/2423 s = self.gdata._vertex_prop_dataframe[type_n] ntypes = s.drop_duplicates().to_arrow().to_pylist() return ntypes @property def etypes(self): + # FIXME: Remove once below is fixed + # https://github.com/rapidsai/cugraph/issues/2423 s = self.gdata._edge_prop_dataframe[type_n] ntypes = s.drop_duplicates().to_arrow().to_pylist() return ntypes @@ -137,6 +145,8 @@ def etypes(self): @property def ndata(self): return { + # FIXME: Remove once below lands + # https://github.com/rapidsai/cugraph/pull/2444 k: self.gdata._vertex_prop_dataframe[col_names].dropna(how="all") for k, col_names in self.ndata_key_col_d.items() } @@ -144,6 +154,8 @@ def ndata(self): @property def edata(self): return { + # FIXME: Remove once below lands + # https://github.com/rapidsai/cugraph/pull/2444 k: self.gdata._edge_prop_dataframe[col_names].dropna(how="all") for k, col_names in self.edata_key_col_d.items() } @@ -202,67 +214,105 @@ def sample_neighbors( DLPack capsule The corresponding eids for the sampled bipartite graph """ - nodes = cudf.from_dlpack(nodes) - num_nodes = len(nodes) - current_seeds = nodes.reindex(index=cp.arange(0, num_nodes)) - _g = self.__G.extract_subgraph( - create_using=cugraph.Graph, allow_multi_edges=True - ) - ego_edge_list, seeds_offsets = batched_ego_graphs( - _g, current_seeds, radius=1 - ) - del _g - # filter and get a certain size neighborhood + if edge_dir not in ["in", "out"]: + raise ValueError( + f"edge_dir must be either 'in' or 'out' got {edge_dir} instead" + ) - # Step 1 - # Get Filtered List of ego_edge_list corresposing to current_seeds - # We filter by creating a series of destination nodes - # corresponding to the offsets and filtering non matching vallues + if edge_dir == "in": + sg = self.extracted_reverse_subgraph_without_renumbering + else: + sg = self.extracted_subgraph_without_renumbering - seeds_offsets_s = cudf.Series(seeds_offsets).values - offset_lens = seeds_offsets_s[1:] - seeds_offsets_s[0:-1] - dst_seeds = current_seeds.repeat(offset_lens) - dst_seeds.index = ego_edge_list.index - filtered_list = ego_edge_list[ego_edge_list["dst"] == dst_seeds] + if not hasattr(self, '_sg_node_dtype'): + self._sg_node_dtype = sg.edgelist.edgelist_df['src'].dtype - del dst_seeds, offset_lens, seeds_offsets_s - del ego_edge_list, seeds_offsets + # Uniform sampling assumes fails when the dtype + # if the seed dtype is not same as the node dtype + nodes = cudf.from_dlpack(nodes).astype(self._sg_node_dtype) - # Step 2 - # Sample Fan Out - # for each dst take maximum of fanout samples - filtered_list = sample_groups( - filtered_list, by="dst", n_samples=fanout + sampled_df = uniform_neighbor_sample( + sg, start_list=nodes, fanout_vals=[fanout], + with_replacement=replace ) - # TODO: Verify order of execution - sample_df = cudf.DataFrame( - {src_n: filtered_list["src"], dst_n: filtered_list["dst"]} - ) - del filtered_list + sampled_df.drop(columns=["indices"], inplace=True) - # del parents_nodes, children_nodes - edge_df = sample_df.merge( - self.gdata._edge_prop_dataframe[[src_n, dst_n, eid_n]], - on=[src_n, dst_n], - ) + # handle empty graph case + if len(sampled_df) == 0: + return None, None, None + + # we reverse directions when directions=='in' + if edge_dir == "in": + sampled_df.rename( + columns={"destinations": src_n, "sources": dst_n}, inplace=True + ) + else: + sampled_df.rename( + columns={"sources": src_n, "destinations": dst_n}, inplace=True + ) + + # FIXME: Remove once below lands + # https://github.com/rapidsai/cugraph/issues/2444 + edge_df = self.gdata._edge_prop_dataframe[[src_n, dst_n, eid_n]] + sampled_df = edge_df.merge(sampled_df) return ( - edge_df[src_n].to_dlpack(), - edge_df[dst_n].to_dlpack(), - edge_df[eid_n].to_dlpack(), + sampled_df[src_n].to_dlpack(), + sampled_df[dst_n].to_dlpack(), + sampled_df[eid_n].to_dlpack(), ) - def find_edges(self, edge_ids, etype): + @cached_property + def extracted_reverse_subgraph_without_renumbering(self): + # TODO: Switch to extract_subgraph based on response on + # https://github.com/rapidsai/cugraph/issues/2458 + subset_df = self.gdata._edge_prop_dataframe[[src_n, dst_n]] + subset_df.rename(columns={src_n: dst_n, dst_n: src_n}, inplace=True) + subset_df["weight"] = cp.float32(1.0) + subgraph = cugraph.Graph(directed=True) + subgraph.from_cudf_edgelist( + subset_df, + source=src_n, + destination=dst_n, + edge_attr="weight", + legacy_renum_only=True, + ) + return subgraph + + @cached_property + def extracted_subgraph_without_renumbering(self): + gr_template = cugraph.Graph(directed=True) + subgraph = self.gdata.extract_subgraph(create_using=gr_template, + default_edge_weight=1.0, + renumber_graph=True) + return subgraph + + def find_edges(self, edge_ids_cap, etype): """Return the source and destination node IDs given the edge IDs within the given edge type. - Return type is - cudf.Series, cudf.Series + + Parameters + ---------- + edge_ids_cap : Dlpack of Node IDs (single dimension) + The edge ids to find + + Returns + ------- + DLPack capsule + The src nodes for the given ids + + DLPack capsule + The dst nodes for the given ids """ - edge_df = self.gdata._edge_prop_dataframe[ - [src_n, dst_n, eid_n, type_n] - ] + edge_ids = cudf.from_dlpack(edge_ids_cap) + + # FIXME: Remove once below lands + # https://github.com/rapidsai/cugraph/issues/2444 + edge_df = self.gdata._edge_prop_dataframe[[src_n, dst_n, + eid_n, type_n]] + subset_df = get_subset_df( edge_df, PropertyGraph.edge_id_col_name, edge_ids, etype ) diff --git a/python/cugraph/cugraph/tests/test_graph_store.py b/python/cugraph/cugraph/tests/test_graph_store.py index 12c825dbb3a..3c7a7262025 100644 --- a/python/cugraph/cugraph/tests/test_graph_store.py +++ b/python/cugraph/cugraph/tests/test_graph_store.py @@ -160,6 +160,7 @@ def test_sample_neighbors(graph_file): assert len(parents_list) > 0 +@pytest.mark.skip(reason="Neg one fanout fails see cugraph/issues/2446") @pytest.mark.parametrize("graph_file", utils.DATASETS) def test_sample_neighbor_neg_one_fanout(graph_file): cu_M = utils.read_csv_file(graph_file) @@ -418,11 +419,94 @@ def test_get_edge_storage_gs(dataset1_CuGraphStore): def test_sampling_gs(dataset1_CuGraphStore): node_pack = cp.asarray([4]).toDlpack() - ( - parents_cap, - children_cap, - edge_id_cap, - ) = dataset1_CuGraphStore.sample_neighbors(node_pack, fanout=1) - x = cudf.from_dlpack(parents_cap) - - assert x is not None + gs = dataset1_CuGraphStore + src_cap, _, _ = gs.sample_neighbors(node_pack, fanout=1) + src_ser = cudf.from_dlpack(src_cap) + assert len(src_ser) != 0 + + +@pytest.mark.skip(reason="Neg one fanout fails see cugraph/issues/2446") +def test_sampling_dataset_gs_neg_one_fanout(dataset1_CuGraphStore): + node_pack = cp.asarray([4]).toDlpack() + gs = dataset1_CuGraphStore + src_cap, _, _ = gs.sample_neighbors(node_pack, fanout=-1) + src_ser = cudf.from_dlpack(src_cap) + assert len(src_ser) != 0 + + +def test_sampling_gs_out_dir(): + src_ser = cudf.Series([1, 1, 1, 1, 1, 2, 2, 3]) + dst_ser = cudf.Series([2, 3, 4, 5, 6, 3, 4, 7]) + df = cudf.DataFrame( + {"src": src_ser, "dst": dst_ser, "edge_id": np.arange(len(src_ser))} + ) + pg = PropertyGraph() + gs = CuGraphStore(pg) + gs.add_edge_data(df, ["src", "dst"], edge_key="edges") + + # below are obtained from dgl runs on the same graph + expected_out = { + 1: ([1, 1, 1, 1, 1], [2, 3, 4, 5, 6]), + 2: ([2, 2], [3, 4]), + 3: ([3], [7]), + 4: ([], []), + } + + for seed in expected_out.keys(): + seed_cap = cudf.Series([seed]).to_dlpack() + sample_src, sample_dst, sample_eid = gs.sample_neighbors( + nodes=seed_cap, fanout=9, edge_dir="out" + ) + if sample_src is None: + sample_src = cudf.Series([]).astype(np.int64) + sample_dst = cudf.Series([]).astype(np.int64) + else: + sample_src = cudf.from_dlpack(sample_src) + sample_dst = cudf.from_dlpack(sample_dst) + + output_df = cudf.DataFrame({"src": sample_src, "dst": sample_dst}) + output_df = output_df.sort_values(by=["src", "dst"]) + output_df = output_df.reset_index(drop=True) + + expected_df = cudf.DataFrame( + {"src": expected_out[seed][0], "dst": expected_out[seed][1]} + ).astype(np.int64) + cudf.testing.assert_frame_equal(output_df, expected_df) + + +def test_sampling_gs_in_dir(): + src_ser = cudf.Series([1, 1, 1, 1, 1, 2, 2, 3]) + dst_ser = cudf.Series([2, 3, 4, 5, 6, 3, 4, 7]) + df = cudf.DataFrame( + {"src": src_ser, "dst": dst_ser, "edge_id": np.arange(len(src_ser))} + ) + pg = PropertyGraph() + gs = CuGraphStore(pg) + gs.add_edge_data(df, ["src", "dst"], edge_key="edges") + + # below are obtained from dgl runs on the same graph + expected_in = {1: ([], []), + 2: ([1], [2]), + 3: ([1, 2], [3, 3]), + 4: ([1, 2], [4, 4])} + + for seed in expected_in.keys(): + seed_cap = cudf.Series([seed]).to_dlpack() + sample_src, sample_dst, sample_eid = gs.sample_neighbors( + nodes=seed_cap, fanout=9, edge_dir="in" + ) + if sample_src is None: + sample_src = cudf.Series([]).astype(np.int64) + sample_dst = cudf.Series([]).astype(np.int64) + else: + sample_src = cudf.from_dlpack(sample_src) + sample_dst = cudf.from_dlpack(sample_dst) + + output_df = cudf.DataFrame({"src": sample_src, "dst": sample_dst}) + output_df = output_df.sort_values(by=["src", "dst"]) + output_df = output_df.reset_index(drop=True) + + expected_df = cudf.DataFrame( + {"src": expected_in[seed][0], "dst": expected_in[seed][1]} + ).astype(np.int64) + cudf.testing.assert_frame_equal(output_df, expected_df) From 4dc286e8e9572913f2437c7c2dc99eebbb758f86 Mon Sep 17 00:00:00 2001 From: Ralph Liu <106174412+oorliu@users.noreply.github.com> Date: Wed, 3 Aug 2022 13:30:07 -0400 Subject: [PATCH 14/19] Use Datasets API to Update Docstring Examples (#2441) closes #2361 Docstring examples now use the new method of creating graphs by using the `datasets` API. This change cleans up the code by eliminating the usage of `cuDF`. Old docstring example: ``` >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', ... dtype=['int32', 'int32', 'float32'], header=None) >>> G = cugraph.Graph() >>> G.from_cudf_edgelist(M, source='0', destination='1') ``` Updated docstring example: ``` >>> from cugraph.experimental.datasets import karate >>> G = karate.get_graph() ``` Authors: - Ralph Liu (https://github.com/oorliu) - Dylan Chima-Sanchez (https://github.com/betochimas) Approvers: - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2441 --- MANIFEST.in | 2 +- python/cugraph/MANIFEST.in | 2 +- .../centrality/betweenness_centrality.py | 14 +++---- .../cugraph/centrality/degree_centrality.py | 6 +-- .../centrality/eigenvector_centrality.py | 6 +-- .../cugraph/centrality/katz_centrality.py | 6 +-- python/cugraph/cugraph/community/ecg.py | 7 +--- python/cugraph/cugraph/community/egonet.py | 16 ++------ .../cugraph/community/ktruss_subgraph.py | 14 ++----- python/cugraph/cugraph/community/leiden.py | 8 +--- python/cugraph/cugraph/community/louvain.py | 8 +--- .../cugraph/community/spectral_clustering.py | 40 +++++-------------- .../cugraph/community/subgraph_extraction.py | 8 +--- .../cugraph/community/triangle_count.py | 8 +--- .../cugraph/components/connectivity.py | 24 +++-------- python/cugraph/cugraph/cores/core_number.py | 6 +-- python/cugraph/cugraph/cores/k_core.py | 6 +-- .../datasets/metadata/__init__.py | 0 python/cugraph/cugraph/layout/force_atlas2.py | 7 +--- python/cugraph/cugraph/link_analysis/hits.py | 6 +-- .../cugraph/cugraph/link_analysis/pagerank.py | 6 +-- .../cugraph/link_prediction/jaccard.py | 18 +++------ .../cugraph/link_prediction/overlap.py | 6 +-- .../cugraph/link_prediction/sorensen.py | 12 ++---- .../cugraph/link_prediction/wjaccard.py | 6 +-- .../cugraph/link_prediction/woverlap.py | 6 +-- .../cugraph/link_prediction/wsorensen.py | 6 +-- python/cugraph/cugraph/sampling/node2vec.py | 6 +-- .../cugraph/cugraph/sampling/random_walks.py | 7 ++-- python/cugraph/cugraph/traversal/bfs.py | 12 ++---- python/cugraph/cugraph/traversal/sssp.py | 6 +-- .../cugraph/tree/minimum_spanning_tree.py | 12 ++---- 32 files changed, 90 insertions(+), 207 deletions(-) create mode 100644 python/cugraph/cugraph/experimental/datasets/metadata/__init__.py diff --git a/MANIFEST.in b/MANIFEST.in index ea12b9342b3..8b68ff0f2fa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include python/versioneer.py include python/cugraph/_version.py include cugraph/experimental/datasets/*.yaml -include cugraph/experimental/datasets/metadata/*.yaml \ No newline at end of file +include cugraph/experimental/datasets/metadata/*.yaml diff --git a/python/cugraph/MANIFEST.in b/python/cugraph/MANIFEST.in index 1f6d9f7a4d0..ef71a68a090 100644 --- a/python/cugraph/MANIFEST.in +++ b/python/cugraph/MANIFEST.in @@ -1,4 +1,4 @@ include versioneer.py include cugraph/_version.py include cugraph/experimental/datasets/*.yaml -include cugraph/experimental/datasets/metadata/*.yaml \ No newline at end of file +include cugraph/experimental/datasets/metadata/*.yaml diff --git a/python/cugraph/cugraph/centrality/betweenness_centrality.py b/python/cugraph/cugraph/centrality/betweenness_centrality.py index e677c02f627..8f8cb3fce95 100644 --- a/python/cugraph/cugraph/centrality/betweenness_centrality.py +++ b/python/cugraph/cugraph/centrality/betweenness_centrality.py @@ -106,10 +106,8 @@ def betweenness_centrality( Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> bc = cugraph.betweenness_centrality(G) """ @@ -235,11 +233,9 @@ def edge_betweenness_centrality( Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') - >>> ebc = cugraph.edge_betweenness_centrality(G) + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) + >>> bc = cugraph.betweenness_centrality(G) """ if weight is not None: diff --git a/python/cugraph/cugraph/centrality/degree_centrality.py b/python/cugraph/cugraph/centrality/degree_centrality.py index c7ef6549598..a57808b0ccb 100644 --- a/python/cugraph/cugraph/centrality/degree_centrality.py +++ b/python/cugraph/cugraph/centrality/degree_centrality.py @@ -42,10 +42,8 @@ def degree_centrality(G, normalized=True): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> dc = cugraph.degree_centrality(G) """ diff --git a/python/cugraph/cugraph/centrality/eigenvector_centrality.py b/python/cugraph/cugraph/centrality/eigenvector_centrality.py index 464c4b431cb..514cd84c69b 100644 --- a/python/cugraph/cugraph/centrality/eigenvector_centrality.py +++ b/python/cugraph/cugraph/centrality/eigenvector_centrality.py @@ -68,10 +68,8 @@ def eigenvector_centrality( Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> ec = cugraph.eigenvector_centrality(G) """ diff --git a/python/cugraph/cugraph/centrality/katz_centrality.py b/python/cugraph/cugraph/centrality/katz_centrality.py index 5aff9f2dd2f..569e53be5c0 100644 --- a/python/cugraph/cugraph/centrality/katz_centrality.py +++ b/python/cugraph/cugraph/centrality/katz_centrality.py @@ -107,10 +107,8 @@ def katz_centrality( Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> kc = cugraph.katz_centrality(G) """ diff --git a/python/cugraph/cugraph/community/ecg.py b/python/cugraph/cugraph/community/ecg.py index 7b5c8ced5fb..61ef7ce530d 100644 --- a/python/cugraph/cugraph/community/ecg.py +++ b/python/cugraph/cugraph/community/ecg.py @@ -61,11 +61,8 @@ def ecg(input_graph, min_weight=0.05, ensemble_size=16, weight=None): Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> parts = cugraph.ecg(G) """ diff --git a/python/cugraph/cugraph/community/egonet.py b/python/cugraph/cugraph/community/egonet.py index 8e9765100ab..e2f0493eb45 100644 --- a/python/cugraph/cugraph/community/egonet.py +++ b/python/cugraph/cugraph/community/egonet.py @@ -81,12 +81,8 @@ def ego_graph(G, n, radius=1, center=True, undirected=False, distance=None): Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> ego_graph = cugraph.ego_graph(G, 1, radius=2) """ @@ -157,12 +153,8 @@ def batched_ego_graphs( Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> b_ego_graph, offsets = cugraph.batched_ego_graphs(G, seeds=[1,5], ... radius=2) diff --git a/python/cugraph/cugraph/community/ktruss_subgraph.py b/python/cugraph/cugraph/community/ktruss_subgraph.py index c32c6ce177c..59b7c4e2ae6 100644 --- a/python/cugraph/cugraph/community/ktruss_subgraph.py +++ b/python/cugraph/cugraph/community/ktruss_subgraph.py @@ -67,11 +67,8 @@ def k_truss(G, k): Examples -------- - >>> import cudf # k_truss does not run on CUDA 11.5 - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> k_subgraph = cugraph.k_truss(G, 3) """ @@ -150,11 +147,8 @@ def ktruss_subgraph(G, k, use_weights=True): Examples -------- - >>> import cudf # ktruss_subgraph does not run on CUDA 11.5 - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> k_subgraph = cugraph.ktruss_subgraph(G, 3) """ diff --git a/python/cugraph/cugraph/community/leiden.py b/python/cugraph/cugraph/community/leiden.py index d10d5700b1a..ae282cda7ed 100644 --- a/python/cugraph/cugraph/community/leiden.py +++ b/python/cugraph/cugraph/community/leiden.py @@ -66,12 +66,8 @@ def leiden(G, max_iter=100, resolution=1.): Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> parts, modularity_score = cugraph.leiden(G) """ diff --git a/python/cugraph/cugraph/community/louvain.py b/python/cugraph/cugraph/community/louvain.py index 87591f61cbc..cf0e3cc6ac5 100644 --- a/python/cugraph/cugraph/community/louvain.py +++ b/python/cugraph/cugraph/community/louvain.py @@ -65,12 +65,8 @@ def louvain(G, max_iter=100, resolution=1.): Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> parts, modularity_score = cugraph.louvain(G) """ diff --git a/python/cugraph/cugraph/community/spectral_clustering.py b/python/cugraph/cugraph/community/spectral_clustering.py index 9415d545d6f..9796d07b4b8 100644 --- a/python/cugraph/cugraph/community/spectral_clustering.py +++ b/python/cugraph/cugraph/community/spectral_clustering.py @@ -71,12 +71,8 @@ def spectralBalancedCutClustering( Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.spectralBalancedCutClustering(G, 5) """ @@ -158,12 +154,8 @@ def spectralModularityMaximizationClustering( Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.spectralModularityMaximizationClustering(G, 5) """ @@ -226,12 +218,8 @@ def analyzeClustering_modularity(G, n_clusters, clustering, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.spectralBalancedCutClustering(G, 5) >>> score = cugraph.analyzeClustering_modularity(G, 5, df) @@ -297,12 +285,8 @@ def analyzeClustering_edge_cut(G, n_clusters, clustering, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr=None) + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.spectralBalancedCutClustering(G, 5) >>> score = cugraph.analyzeClustering_edge_cut(G, 5, df) @@ -365,12 +349,8 @@ def analyzeClustering_ratio_cut(G, n_clusters, clustering, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.spectralBalancedCutClustering(G, 5) >>> score = cugraph.analyzeClustering_ratio_cut(G, 5, df, 'vertex', ... 'cluster') diff --git a/python/cugraph/cugraph/community/subgraph_extraction.py b/python/cugraph/cugraph/community/subgraph_extraction.py index bc11dbd1294..206f38266b9 100644 --- a/python/cugraph/cugraph/community/subgraph_extraction.py +++ b/python/cugraph/cugraph/community/subgraph_extraction.py @@ -43,12 +43,8 @@ def subgraph(G, vertices): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> verts = np.zeros(3, dtype=np.int32) >>> verts[0] = 0 >>> verts[1] = 1 diff --git a/python/cugraph/cugraph/community/triangle_count.py b/python/cugraph/cugraph/community/triangle_count.py index 103999e7010..ce8539c1541 100644 --- a/python/cugraph/cugraph/community/triangle_count.py +++ b/python/cugraph/cugraph/community/triangle_count.py @@ -39,12 +39,8 @@ def triangles(G): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> count = cugraph.triangles(G) """ diff --git a/python/cugraph/cugraph/components/connectivity.py b/python/cugraph/cugraph/components/connectivity.py index c1601cd42bf..1ac78bc1e83 100644 --- a/python/cugraph/cugraph/components/connectivity.py +++ b/python/cugraph/cugraph/components/connectivity.py @@ -171,12 +171,8 @@ def weakly_connected_components(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr=None) + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.weakly_connected_components(G) """ @@ -269,12 +265,8 @@ def strongly_connected_components(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr=None) + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.strongly_connected_components(G) """ @@ -367,12 +359,8 @@ def connected_components(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', - ... delimiter = ' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr=None) + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.connected_components(G, connection="weak") """ diff --git a/python/cugraph/cugraph/cores/core_number.py b/python/cugraph/cugraph/cores/core_number.py index f5a1e00de9f..028c4f05b31 100644 --- a/python/cugraph/cugraph/cores/core_number.py +++ b/python/cugraph/cugraph/cores/core_number.py @@ -58,10 +58,8 @@ def core_number(G, degree_type=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.core_number(G) """ diff --git a/python/cugraph/cugraph/cores/k_core.py b/python/cugraph/cugraph/cores/k_core.py index 7e935c55558..d17076d0f50 100644 --- a/python/cugraph/cugraph/cores/k_core.py +++ b/python/cugraph/cugraph/cores/k_core.py @@ -74,10 +74,8 @@ def k_core(G, k=None, core_number=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> KCoreGraph = cugraph.k_core(G) """ diff --git a/python/cugraph/cugraph/experimental/datasets/metadata/__init__.py b/python/cugraph/cugraph/experimental/datasets/metadata/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/cugraph/cugraph/layout/force_atlas2.py b/python/cugraph/cugraph/layout/force_atlas2.py index ec05a3b8482..366a3009678 100644 --- a/python/cugraph/cugraph/layout/force_atlas2.py +++ b/python/cugraph/cugraph/layout/force_atlas2.py @@ -123,11 +123,8 @@ def on_train_end(self, positions): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], - ... header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> pos = cugraph.force_atlas2(G) """ diff --git a/python/cugraph/cugraph/link_analysis/hits.py b/python/cugraph/cugraph/link_analysis/hits.py index 820f7d6aba1..544e64aef08 100644 --- a/python/cugraph/cugraph/link_analysis/hits.py +++ b/python/cugraph/cugraph/link_analysis/hits.py @@ -79,10 +79,8 @@ def hits( Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> hits = cugraph.hits(G, max_iter = 50) """ diff --git a/python/cugraph/cugraph/link_analysis/pagerank.py b/python/cugraph/cugraph/link_analysis/pagerank.py index 1bf238141fc..ecb0ba6ea74 100644 --- a/python/cugraph/cugraph/link_analysis/pagerank.py +++ b/python/cugraph/cugraph/link_analysis/pagerank.py @@ -98,10 +98,8 @@ def pagerank( Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> pr = cugraph.pagerank(G, alpha = 0.85, max_iter = 500, tol = 1.0e-05) """ diff --git a/python/cugraph/cugraph/link_prediction/jaccard.py b/python/cugraph/cugraph/link_prediction/jaccard.py index 10bfd35f252..1e7ddc2ec43 100644 --- a/python/cugraph/cugraph/link_prediction/jaccard.py +++ b/python/cugraph/cugraph/link_prediction/jaccard.py @@ -53,10 +53,8 @@ def jaccard(input_graph, vertex_pair=None): you can get the interesting (non-zero) values that are part of the networkx solution by doing the following: - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> pairs = G.get_two_hop_neighbors() >>> df = cugraph.jaccard(G, pairs) @@ -100,10 +98,8 @@ def jaccard(input_graph, vertex_pair=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.jaccard(G) """ @@ -162,10 +158,8 @@ def jaccard_coefficient(G, ebunch=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.jaccard_coefficient(G) """ diff --git a/python/cugraph/cugraph/link_prediction/overlap.py b/python/cugraph/cugraph/link_prediction/overlap.py index 816c580747b..9318c379439 100644 --- a/python/cugraph/cugraph/link_prediction/overlap.py +++ b/python/cugraph/cugraph/link_prediction/overlap.py @@ -85,10 +85,8 @@ def overlap(input_graph, vertex_pair=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.overlap(G) """ diff --git a/python/cugraph/cugraph/link_prediction/sorensen.py b/python/cugraph/cugraph/link_prediction/sorensen.py index 4a88f6b1558..4a4bc8adcdb 100644 --- a/python/cugraph/cugraph/link_prediction/sorensen.py +++ b/python/cugraph/cugraph/link_prediction/sorensen.py @@ -68,10 +68,8 @@ def sorensen(input_graph, vertex_pair=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.sorensen(G) """ @@ -132,10 +130,8 @@ def sorensen_coefficient(G, ebunch=None): Examples -------- - >>> gdf = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(gdf, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.sorensen_coefficient(G) """ diff --git a/python/cugraph/cugraph/link_prediction/wjaccard.py b/python/cugraph/cugraph/link_prediction/wjaccard.py index 3ff00df11ec..68c093a052a 100644 --- a/python/cugraph/cugraph/link_prediction/wjaccard.py +++ b/python/cugraph/cugraph/link_prediction/wjaccard.py @@ -70,10 +70,8 @@ def jaccard_w(input_graph, weights, vertex_pair=None): Examples -------- >>> import random - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> # Create a dataframe containing the vertices with their >>> # corresponding weight >>> weights = cudf.DataFrame() diff --git a/python/cugraph/cugraph/link_prediction/woverlap.py b/python/cugraph/cugraph/link_prediction/woverlap.py index 10eca82e951..42509962b2a 100644 --- a/python/cugraph/cugraph/link_prediction/woverlap.py +++ b/python/cugraph/cugraph/link_prediction/woverlap.py @@ -69,10 +69,8 @@ def overlap_w(input_graph, weights, vertex_pair=None): Examples -------- >>> import random - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> # Create a dataframe containing the vertices with their >>> # corresponding weight >>> weights = cudf.DataFrame() diff --git a/python/cugraph/cugraph/link_prediction/wsorensen.py b/python/cugraph/cugraph/link_prediction/wsorensen.py index 69eb5b975c7..cacc4242257 100644 --- a/python/cugraph/cugraph/link_prediction/wsorensen.py +++ b/python/cugraph/cugraph/link_prediction/wsorensen.py @@ -65,10 +65,8 @@ def sorensen_w(input_graph, weights, vertex_pair=None): Examples -------- >>> import random - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> # Create a dataframe containing the vertices with their >>> # corresponding weight >>> weights = cudf.DataFrame() diff --git a/python/cugraph/cugraph/sampling/node2vec.py b/python/cugraph/cugraph/sampling/node2vec.py index 44af8e1182a..b0b2029153d 100644 --- a/python/cugraph/cugraph/sampling/node2vec.py +++ b/python/cugraph/cugraph/sampling/node2vec.py @@ -87,10 +87,8 @@ def node2vec(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> start_vertices = cudf.Series([0, 2], dtype=np.int32) >>> paths, weights, path_sizes = cugraph.node2vec(G, start_vertices, 3, ... True, 0.8, 0.5) diff --git a/python/cugraph/cugraph/sampling/random_walks.py b/python/cugraph/cugraph/sampling/random_walks.py index d7ce6057049..f3c0a7c965a 100644 --- a/python/cugraph/cugraph/sampling/random_walks.py +++ b/python/cugraph/cugraph/sampling/random_walks.py @@ -56,10 +56,9 @@ def random_walks(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1', edge_attr='2') + >>> from cugraph.experimental.datasets import karate + >>> M = karate.get_edgelist(fetch=True) + >>> G = karate.get_graph() >>> _, _, _ = cugraph.random_walks(G, M, 3) """ diff --git a/python/cugraph/cugraph/traversal/bfs.py b/python/cugraph/cugraph/traversal/bfs.py index 4938b6bb200..64d6dddb403 100644 --- a/python/cugraph/cugraph/traversal/bfs.py +++ b/python/cugraph/cugraph/traversal/bfs.py @@ -206,10 +206,8 @@ def bfs(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.bfs(G, 0) """ @@ -325,10 +323,8 @@ def bfs_edges(G, source, reverse=False, depth_limit=None, sort_neighbors=None): Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> df = cugraph.bfs_edges(G, 0) """ diff --git a/python/cugraph/cugraph/traversal/sssp.py b/python/cugraph/cugraph/traversal/sssp.py index 4533a834952..1428672559d 100644 --- a/python/cugraph/cugraph/traversal/sssp.py +++ b/python/cugraph/cugraph/traversal/sssp.py @@ -238,10 +238,8 @@ def sssp(G, Examples -------- - >>> M = cudf.read_csv(datasets_path / 'karate.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import karate + >>> G = karate.get_graph(fetch=True) >>> distances = cugraph.sssp(G, 0) >>> distances distance vertex predecessor diff --git a/python/cugraph/cugraph/tree/minimum_spanning_tree.py b/python/cugraph/cugraph/tree/minimum_spanning_tree.py index 8ad1af0f704..fe19e8ed1ff 100644 --- a/python/cugraph/cugraph/tree/minimum_spanning_tree.py +++ b/python/cugraph/cugraph/tree/minimum_spanning_tree.py @@ -92,10 +92,8 @@ def minimum_spanning_tree( Examples -------- - >>> M = cudf.read_csv(datasets_path / 'netscience.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import netscience + >>> G = netscience.get_graph(fetch=True) >>> G_mst = cugraph.minimum_spanning_tree(G) """ @@ -139,10 +137,8 @@ def maximum_spanning_tree( Examples -------- - >>> M = cudf.read_csv(datasets_path / 'netscience.csv', delimiter=' ', - ... dtype=['int32', 'int32', 'float32'], header=None) - >>> G = cugraph.Graph() - >>> G.from_cudf_edgelist(M, source='0', destination='1') + >>> from cugraph.experimental.datasets import netscience + >>> G = netscience.get_graph(fetch=True) >>> G_mst = cugraph.maximum_spanning_tree(G) """ From 5c321611bca02123b326dbb0c487328717c6a1e4 Mon Sep 17 00:00:00 2001 From: Don Acosta <97529984+acostadon@users.noreply.github.com> Date: Wed, 3 Aug 2022 16:55:36 -0400 Subject: [PATCH 15/19] Updates to Link Notebooks (#2456) Move, test and update Link analysis and link prediction notebooks to the new organization. Also respond to some review comments on some earlier notebook changes. This is part of epic relates to #1405 but does not close it. Authors: - Don Acosta (https://github.com/acostadon) Approvers: - Rick Ratzel (https://github.com/rlratzel) - Brad Rees (https://github.com/BradReesWork) URL: https://github.com/rapidsai/cugraph/pull/2456 --- notebooks/README.md | 24 +- notebooks/algorithms/README.md | 20 +- .../algorithms/centrality/Centrality.ipynb | 55 +- .../algorithms/layout/Force-Atlas2.ipynb | 17 +- .../{ => algorithms}/link_analysis/HITS.ipynb | 45 +- .../link_analysis/Pagerank.ipynb | 35 +- notebooks/algorithms/link_analysis/README.md | 36 + .../link_prediction/Jaccard-Similarity.ipynb | 33 +- .../link_prediction/Overlap-Similarity.ipynb | 25 +- .../algorithms/link_prediction/README.md | 41 ++ notebooks/applications/CostMatrix.ipynb | 641 ++++++++++++++++++ notebooks/centrality/Centrality.ipynb | 386 ----------- notebooks/img/Full-four_node_replication.png | Bin 0 -> 17291 bytes notebooks/img/graph_after_ghost.png | Bin 0 -> 37330 bytes notebooks/img/graph_after_replication.png | Bin 0 -> 33718 bytes notebooks/img/karate_hits.png | Bin 0 -> 60901 bytes notebooks/img/zachary_graph_hits.png | Bin 153084 -> 163578 bytes notebooks/link_prediction/README.md | 22 - 18 files changed, 817 insertions(+), 563 deletions(-) rename notebooks/{ => algorithms}/link_analysis/HITS.ipynb (86%) rename notebooks/{ => algorithms}/link_analysis/Pagerank.ipynb (89%) create mode 100644 notebooks/algorithms/link_analysis/README.md rename notebooks/{ => algorithms}/link_prediction/Jaccard-Similarity.ipynb (94%) rename notebooks/{ => algorithms}/link_prediction/Overlap-Similarity.ipynb (94%) create mode 100644 notebooks/algorithms/link_prediction/README.md create mode 100644 notebooks/applications/CostMatrix.ipynb delete mode 100644 notebooks/centrality/Centrality.ipynb create mode 100644 notebooks/img/Full-four_node_replication.png create mode 100644 notebooks/img/graph_after_ghost.png create mode 100644 notebooks/img/graph_after_replication.png create mode 100644 notebooks/img/karate_hits.png delete mode 100644 notebooks/link_prediction/README.md diff --git a/notebooks/README.md b/notebooks/README.md index 56eb1d5c317..41952c6cd30 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -30,11 +30,11 @@ This repository contains a collection of Jupyter Notebooks that outline how to r Layout | | | | | [Force-Atlas2](algorithms/layout/Force-Atlas2.ipynb) |A large graph visualization achieved with cuGraph. | | Link Analysis | | | -| | [Pagerank](link_analysis/Pagerank.ipynb) | Compute the PageRank of every vertex in a graph | -| | [HITS](link_analysis/HITS.ipynb) | Compute the HITS' Hub and Authority scores for every vertex in a graph | +| | [Pagerank](algorithms/link_analysis/Pagerank.ipynb) | Compute the PageRank of every vertex in a graph | +| | [HITS](algorithms/link_analysis/HITS.ipynb) | Compute the HITS' Hub and Authority scores for every vertex in a graph | | Link Prediction | | | -| | [Jaccard Similarity](link_prediction/Jaccard-Similarity.ipynb) | Compute vertex similarity score using both:
- Jaccard Similarity
- Weighted Jaccard | -| | [Overlap Similarity](link_prediction/Overlap-Similarity.ipynb) | Compute vertex similarity score using the Overlap Coefficient | +| | [Jaccard Similarity](algorithms/link_prediction/Jaccard-Similarity.ipynb) | Compute vertex similarity score using both:
- Jaccard Similarity
- Weighted Jaccard | +| | [Overlap Similarity](algorithms/link_prediction/Overlap-Similarity.ipynb) | Compute vertex similarity score using the Overlap Coefficient | | Sampling | | | [Random Walk](sampling/RandomWalk.ipynb) | Compute Random Walk for a various number of seeds and path lengths | | Traversal | | | @@ -61,21 +61,7 @@ Running the example in these notebooks requires: * CUDA 11.4+ * NVIDIA driver 450.51+ - - -#### Notebook Credits - -- Original Authors: Bradley Rees -- Last Edit: 04/19/2021 - -RAPIDS Versions: 0.19 - -Test Hardware -- GV100 32G, CUDA 9,2 - - - -##### Copyright +#### Copyright Copyright (c) 2019-2022, NVIDIA CORPORATION. All rights reserved. diff --git a/notebooks/algorithms/README.md b/notebooks/algorithms/README.md index 03262336fbc..cfac699ec8e 100644 --- a/notebooks/algorithms/README.md +++ b/notebooks/algorithms/README.md @@ -10,33 +10,33 @@ This repository contains a collection of Jupyter Notebooks that outline how to r | Folder | Notebook | Description | | --------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| Centrality | | | +| [Centrality](centrality/README.md) | | | | | [Centrality](centrality/Centrality.ipynb) | Compute and compare multiple (currently 5) centrality scores | | | [Katz](centrality/Katz.ipynb) | Compute the Katz centrality for every vertex | | | [Betweenness](centrality/Betweenness.ipynb) | Compute both Edge and Vertex Betweenness centrality | | | [Degree](centrality/Degree.ipynb) | Compute Degree Centraility for each vertex | | | [Eigenvector](centrality/Eigenvector.ipynb) | Compute Eigenvector for every vertex | -| Community | | | +|[Community](community/README.md) | | | | | [Louvain](community/Louvain.ipynb) | Identify clusters in a graph using both the Louvain and Leiden algorithms | | | [ECG](community/ECG.ipynb) | Identify clusters in a graph using the Ensemble Clustering for Graph | | | [K-Truss](community/ktruss.ipynb) | Extracts the K-Truss cluster | | | [Spectral-Clustering](community/Spectral-Clustering.ipynb) | Identify clusters in a graph using Spectral Clustering with both
- Balanced Cut
- Modularity Modularity | | | [Subgraph Extraction](community/Subgraph-Extraction.ipynb) | Compute a subgraph of the existing graph including only the specified vertices | | | [Triangle Counting](community/Triangle-Counting.ipynb) | Count the number of Triangle in a graph | -Components | | | +|[Components](components/README.md) | | | | | [Connected Components](components/ConnectedComponents.ipynb) | Find weakly and strongly connected components in a graph | -| Cores | | | +| [Cores](cores/README.md) | | | | | [core-number](cores/Core-number.ipynb) | Computes the core number for every vertex of a graph G. The core number of a vertex is a maximal subgraph that contains only that vertex and others of degree k or more. | | | [kcore](cores/kcore.ipynb) |Find the k-core of a graph which is a maximal subgraph that contains nodes of degree k or more.| Layout | | | | | [Force-Atlas2](layout/Force-Atlas2.ipynb) |A large graph visualization achieved with cuGraph. | -Gh($-pX+Ho&_&bE$E{yu)bA$`6B*-;F< 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

v5>h-z0LolT$wkUsGl&;+t93b%UoF^x;?k>mz`c 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%0Aj;Ysq-_EJy7Uqv`D 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 diff --git a/notebooks/link_prediction/README.md b/notebooks/link_prediction/README.md deleted file mode 100644 index 345602a05c1..00000000000 --- a/notebooks/link_prediction/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Vertex Similarity ----- - -In this folder we will explore and compare the various vertex similarity metrics available in cuGraph. [Vertex similarity](https://en.wikipedia.org/wiki/Similarity_(network_science)), as the name implies, is a measure how similar two vertices are. -Currently, cuGraph supports the following similarity metrics: -- Jaccard Similarity (also called the Jaccard Index) -- Overlap Coefficient -- Weight Jaccard - -Similarity can be between neighboring vertices (default) or second hop neighbors - -## Introduction - Common Neighbor Similarity - -One of the most common types of vertex similarity is to evaluate the neighborhood of vertex pairs and looks at the number of common neighbors. That type of similar comes from statistics and is based on set comparison. Both Jaccard and the Overlap Coefficient operate on sets, and in a graph setting, those sets are the list of neighboring vertices.
-For those that like math: The neighbors of a vertex, _v_, is defined as the set, _U_, of vertices connected by way of an edge to vertex v, or _N(v) = {U} where v ∈ V and ∀ u ∈ U ∃ edge(v,u)∈ E_. - -For the rest of this introduction, set __A__ will equate to _A = N(i)_ and set __B__ will equate to _B = N(j)_. That just make the rest of the text more readable. - -### Additional Reading -- [Similarity in graphs: Jaccard versus the Overlap Coefficient](https://medium.com/rapids-ai/similarity-in-graphs-jaccard-versus-the-overlap-coefficient-610e083b877d) -- [Wikipedia: Jaccard](https://en.wikipedia.org/wiki/Jaccard_index) -- [Wikipedia: Overlap Coefficient](https://en.wikipedia.org/wiki/Overlap_coefficient) From cdc563fce77947c655fe6ceecd6fb68d7b88bfd5 Mon Sep 17 00:00:00 2001 From: Joseph Nke <76006812+jnke2016@users.noreply.github.com> Date: Wed, 3 Aug 2022 20:36:44 -0500 Subject: [PATCH 16/19] Update PageRank to leverage pylibcugraph (#2467) This PR 1. Extends the capabilities of the `PageRank CAPI` to the `python cuGraph API` 2. Update or Add support for the parameters `personalization` `precomputed_vertex_out_weight` and `nstart` for both the SG and MG implementation closes #2455 closes #2430 Authors: - Joseph Nke (https://github.com/jnke2016) - Chuck Hastings (https://github.com/ChuckHastings) Approvers: - Chuck Hastings (https://github.com/ChuckHastings) - Seunghwa Kang (https://github.com/seunghwak) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2467 --- .../cugraph/detail/shuffle_wrappers.hpp | 19 + cpp/include/cugraph_c/centrality_algorithms.h | 57 ++- cpp/src/c_api/pagerank.cpp | 222 +++++++++-- cpp/src/detail/shuffle_wrappers.cu | 66 +++- cpp/tests/c_api/mg_bfs_test.c | 34 +- cpp/tests/c_api/mg_pagerank_test.c | 150 +++++++- cpp/tests/c_api/pagerank_test.c | 170 ++++++++- cpp/tests/layout/trust_worthiness.h | 3 +- python/cugraph/CMakeLists.txt | 2 - .../cugraph/dask/link_analysis/CMakeLists.txt | 25 -- .../dask/link_analysis/mg_pagerank.pxd | 34 -- .../link_analysis/mg_pagerank_wrapper.pyx | 144 ------- .../cugraph/dask/link_analysis/pagerank.py | 356 +++++++++++------- .../algorithms/link_analysis/pagerank_alg.py | 14 +- .../cugraph/link_analysis/CMakeLists.txt | 25 -- .../cugraph/link_analysis/pagerank.pxd | 36 -- .../cugraph/cugraph/link_analysis/pagerank.py | 162 ++++++-- .../link_analysis/pagerank_wrapper.pyx | 142 ------- .../graph_implementation/simpleGraph.py | 10 +- .../cugraph/tests/mg/test_mg_pagerank.py | 77 +++- python/cugraph/cugraph/tests/test_pagerank.py | 80 +++- python/cugraph/cugraph/tests/test_paths.py | 10 +- .../cugraph/cugraph/utilities/nx_factory.py | 5 +- .../pylibcugraph/pylibcugraph/CMakeLists.txt | 1 + python/pylibcugraph/pylibcugraph/__init__.py | 2 + .../_cugraph_c/centrality_algorithms.pxd | 10 +- python/pylibcugraph/pylibcugraph/pagerank.pyx | 80 ++-- .../pylibcugraph/personalized_pagerank.pyx | 252 +++++++++++++ .../pylibcugraph/tests/test_graph_sg.py | 2 +- .../pylibcugraph/tests/test_pagerank.py | 9 +- python/pylibcugraph/pylibcugraph/utils.pxd | 3 + python/pylibcugraph/pylibcugraph/utils.pyx | 50 ++- 32 files changed, 1534 insertions(+), 718 deletions(-) delete mode 100644 python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt delete mode 100644 python/cugraph/cugraph/dask/link_analysis/mg_pagerank.pxd delete mode 100644 python/cugraph/cugraph/dask/link_analysis/mg_pagerank_wrapper.pyx delete mode 100644 python/cugraph/cugraph/link_analysis/CMakeLists.txt delete mode 100644 python/cugraph/cugraph/link_analysis/pagerank.pxd delete mode 100644 python/cugraph/cugraph/link_analysis/pagerank_wrapper.pyx create mode 100644 python/pylibcugraph/pylibcugraph/personalized_pagerank.pyx diff --git a/cpp/include/cugraph/detail/shuffle_wrappers.hpp b/cpp/include/cugraph/detail/shuffle_wrappers.hpp index e0ba45d95ce..bbd14552c47 100644 --- a/cpp/include/cugraph/detail/shuffle_wrappers.hpp +++ b/cpp/include/cugraph/detail/shuffle_wrappers.hpp @@ -62,6 +62,25 @@ template rmm::device_uvector shuffle_ext_vertices_by_gpu_id( raft::handle_t const& handle, rmm::device_uvector&& d_vertices); +/** + * @brief Shuffle vertex/value tuples using the external vertex key function which returns the + * target GPU ID. + * + * @tparam vertex_t Type of vertex identifiers. Needs to be an integral type. + * @tparam value_t Type of values. + * + * @param[in] handle RAFT handle object to encapsulate resources (e.g. CUDA stream, communicator, + * @param[in] d_vertices Vertex IDs to shuffle + * @param[in] d_values Values to shuffle + * + * @return tuple containing device vector of shuffled vertices and device vector of shuffled values + */ +template +std::tuple, rmm::device_uvector> +shuffle_ext_vertices_and_values_by_gpu_id(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values); + /** * @brief Shuffle vertices using the internal vertex key function which returns the target GPU ID. * diff --git a/cpp/include/cugraph_c/centrality_algorithms.h b/cpp/include/cugraph_c/centrality_algorithms.h index ed21fcdce52..e197ac4b403 100644 --- a/cpp/include/cugraph_c/centrality_algorithms.h +++ b/cpp/include/cugraph_c/centrality_algorithms.h @@ -67,19 +67,31 @@ void cugraph_centrality_result_free(cugraph_centrality_result_t* result); * * @param [in] handle Handle for accessing resources * @param [in] graph Pointer to graph + * @param [in] precomputed_vertex_out_weight_vertices + * Optionally send in precomputed sum of vertex out weights + * (a performance optimization). This defines the vertices. + * Set to NULL if no value is passed. * @param [in] precomputed_vertex_out_weight_sums - * Optionally send in precomputed sume of vertex out weights + * Optionally send in precomputed sum of vertex out weights * (a performance optimization). Set to NULL if * no value is passed. + * @param [in] initial_guess_vertices + * Optionally send in an initial guess of the pagerank values + * (a performance optimization). This defines the vertices. + * Set to NULL if no value is passed. If NULL, initial PageRank + * values are set to 1.0 divided by the number of vertices in + * the graph. + * @param [in] initial_guess_values + * Optionally send in an initial guess of the pagerank values + * (a performance optimization). Set to NULL if + * no value is passed. If NULL, initial PageRank values are set + * to 1.0 divided by the number of vertices in the graph. * @param [in] alpha PageRank damping factor. * @param [in] epsilon Error tolerance to check convergence. Convergence is assumed * if the sum of the differences in PageRank values between two * consecutive iterations is less than the number of vertices * in the graph multiplied by @p epsilon. * @param [in] max_iterations Maximum number of PageRank iterations. - * @param [in] has_initial_guess If set to `true`, values in the PageRank output array (pointed by - * @p pageranks) is used as initial PageRank values. If false, initial PageRank values are set - * to 1.0 divided by the number of vertices in the graph. * @param [in] do_expensive_check A flag to run expensive checks for input arguments (if set to * `true`). * @param [out] result Opaque pointer to pagerank results @@ -90,11 +102,13 @@ void cugraph_centrality_result_free(cugraph_centrality_result_t* result); cugraph_error_code_t cugraph_pagerank( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_vertices, const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_sums, + const cugraph_type_erased_device_array_view_t* initial_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check, cugraph_centrality_result_t** result, cugraph_error_t** error); @@ -104,14 +118,27 @@ cugraph_error_code_t cugraph_pagerank( * * @param [in] handle Handle for accessing resources * @param [in] graph Pointer to graph + * @param [in] precomputed_vertex_out_weight_vertices + * Optionally send in precomputed sum of vertex out weights + * (a performance optimization). This defines the vertices. + * Set to NULL if no value is passed. * @param [in] precomputed_vertex_out_weight_sums - * Optionally send in precomputed sume of vertex out weights + * Optionally send in precomputed sum of vertex out weights * (a performance optimization). Set to NULL if * no value is passed. - * FIXME: Make this just [in], copy it if I need to temporarily modify internally - * @param [in/out] personalization_vertices Pointer to an array storing personalization vertex - * identifiers (compute personalized PageRank). Array might be modified if renumbering is enabled - * for the graph + * @param [in] initial_guess_vertices + * Optionally send in an initial guess of the pagerank values + * (a performance optimization). This defines the vertices. + * Set to NULL if no value is passed. If NULL, initial PageRank + * values are set to 1.0 divided by the number of vertices in + * the graph. + * @param [in] initial_guess_values + * Optionally send in an initial guess of the pagerank values + * (a performance optimization). Set to NULL if + * no value is passed. If NULL, initial PageRank values are set + * to 1.0 divided by the number of vertices in the graph. + * @param [in] personalization_vertices Pointer to an array storing personalization vertex + * identifiers (compute personalized PageRank). * @param [in] personalization_values Pointer to an array storing personalization values for the * vertices in the personalization set. * @param [in] alpha PageRank damping factor. @@ -120,9 +147,6 @@ cugraph_error_code_t cugraph_pagerank( * consecutive iterations is less than the number of vertices * in the graph multiplied by @p epsilon. * @param [in] max_iterations Maximum number of PageRank iterations. - * @param [in] has_initial_guess If set to `true`, values in the PageRank output array (pointed by - * @p pageranks) is used as initial PageRank values. If false, initial PageRank values are set - * to 1.0 divided by the number of vertices in the graph. * @param [in] do_expensive_check A flag to run expensive checks for input arguments (if set to * `true`). * @param [out] result Opaque pointer to pagerank results @@ -133,14 +157,15 @@ cugraph_error_code_t cugraph_pagerank( cugraph_error_code_t cugraph_personalized_pagerank( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_vertices, const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_sums, - // FIXME: Make this const, copy it if I need to temporarily modify internally - cugraph_type_erased_device_array_view_t* personalization_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_values, + const cugraph_type_erased_device_array_view_t* personalization_vertices, const cugraph_type_erased_device_array_view_t* personalization_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check, cugraph_centrality_result_t** result, cugraph_error_t** error); diff --git a/cpp/src/c_api/pagerank.cpp b/cpp/src/c_api/pagerank.cpp index 7621109d9d9..99fcca706b1 100644 --- a/cpp/src/c_api/pagerank.cpp +++ b/cpp/src/c_api/pagerank.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -33,36 +34,50 @@ namespace { struct pagerank_functor : public cugraph::c_api::abstract_functor { raft::handle_t const& handle_; cugraph::c_api::cugraph_graph_t* graph_{}; + cugraph::c_api::cugraph_type_erased_device_array_view_t const* + precomputed_vertex_out_weight_vertices_{}; cugraph::c_api::cugraph_type_erased_device_array_view_t const* precomputed_vertex_out_weight_sums_{}; - cugraph::c_api::cugraph_type_erased_device_array_view_t* personalization_vertices_{}; + cugraph::c_api::cugraph_type_erased_device_array_view_t const* initial_guess_vertices_{}; + cugraph::c_api::cugraph_type_erased_device_array_view_t const* initial_guess_values_{}; + cugraph::c_api::cugraph_type_erased_device_array_view_t const* personalization_vertices_{}; cugraph::c_api::cugraph_type_erased_device_array_view_t const* personalization_values_{}; double alpha_{}; double epsilon_{}; size_t max_iterations_{}; - bool has_initial_guess_{}; bool do_expensive_check_{}; cugraph::c_api::cugraph_centrality_result_t* result_{}; pagerank_functor( cugraph_resource_handle_t const* handle, cugraph_graph_t* graph, + cugraph_type_erased_device_array_view_t const* precomputed_vertex_out_weight_vertices, cugraph_type_erased_device_array_view_t const* precomputed_vertex_out_weight_sums, - cugraph_type_erased_device_array_view_t* personalization_vertices, + cugraph_type_erased_device_array_view_t const* initial_guess_vertices, + cugraph_type_erased_device_array_view_t const* initial_guess_values, + cugraph_type_erased_device_array_view_t const* personalization_vertices, cugraph_type_erased_device_array_view_t const* personalization_values, double alpha, double epsilon, size_t max_iterations, - bool has_initial_guess, bool do_expensive_check) : abstract_functor(), handle_(*reinterpret_cast(handle)->handle_), graph_(reinterpret_cast(graph)), + precomputed_vertex_out_weight_vertices_( + reinterpret_cast( + precomputed_vertex_out_weight_vertices)), precomputed_vertex_out_weight_sums_( reinterpret_cast( precomputed_vertex_out_weight_sums)), + initial_guess_vertices_( + reinterpret_cast( + initial_guess_vertices)), + initial_guess_values_( + reinterpret_cast( + initial_guess_values)), personalization_vertices_( - reinterpret_cast( + reinterpret_cast( personalization_vertices)), personalization_values_( reinterpret_cast( @@ -70,7 +85,6 @@ struct pagerank_functor : public cugraph::c_api::abstract_functor { alpha_(alpha), epsilon_(epsilon), max_iterations_(max_iterations), - has_initial_guess_(has_initial_guess), do_expensive_check_(do_expensive_check) { } @@ -104,40 +118,113 @@ struct pagerank_functor : public cugraph::c_api::abstract_functor { rmm::device_uvector pageranks(graph_view.local_vertex_partition_range_size(), handle_.get_stream()); + rmm::device_uvector personalization_vertices(0, handle_.get_stream()); + rmm::device_uvector personalization_values(0, handle_.get_stream()); + if (personalization_vertices_ != nullptr) { + personalization_vertices.resize(personalization_vertices_->size_, handle_.get_stream()); + personalization_values.resize(personalization_values_->size_, handle_.get_stream()); + + raft::copy(personalization_vertices.data(), + personalization_vertices_->as_type(), + personalization_vertices_->size_, + handle_.get_stream()); + raft::copy(personalization_values.data(), + personalization_values_->as_type(), + personalization_values_->size_, + handle_.get_stream()); + + if constexpr (multi_gpu) { + std::tie(personalization_vertices, personalization_values) = + cugraph::detail::shuffle_ext_vertices_and_values_by_gpu_id( + handle_, std::move(personalization_vertices), std::move(personalization_values)); + } // // Need to renumber personalization_vertices // - cugraph::renumber_ext_vertices( + cugraph::renumber_local_ext_vertices( handle_, - personalization_vertices_->as_type(), - personalization_vertices_->size_, + personalization_vertices.data(), + personalization_vertices.size(), number_map->data(), graph_view.local_vertex_partition_range_first(), graph_view.local_vertex_partition_range_last(), do_expensive_check_); } + rmm::device_uvector precomputed_vertex_out_weight_sums(0, handle_.get_stream()); + if (precomputed_vertex_out_weight_sums_ != nullptr) { + rmm::device_uvector precomputed_vertex_out_weight_vertices( + precomputed_vertex_out_weight_vertices_->size_, handle_.get_stream()); + precomputed_vertex_out_weight_sums.resize(precomputed_vertex_out_weight_sums_->size_, + handle_.get_stream()); + + raft::copy(precomputed_vertex_out_weight_vertices.data(), + precomputed_vertex_out_weight_vertices_->as_type(), + precomputed_vertex_out_weight_vertices_->size_, + handle_.get_stream()); + raft::copy(precomputed_vertex_out_weight_sums.data(), + precomputed_vertex_out_weight_sums_->as_type(), + precomputed_vertex_out_weight_sums_->size_, + handle_.get_stream()); + + precomputed_vertex_out_weight_sums = cugraph::detail:: + collect_local_vertex_values_from_ext_vertex_value_pairs( + handle_, + std::move(precomputed_vertex_out_weight_vertices), + std::move(precomputed_vertex_out_weight_sums), + *number_map, + graph_view.local_vertex_partition_range_first(), + graph_view.local_vertex_partition_range_last(), + weight_t{0}, + do_expensive_check_); + } + + if (initial_guess_values_ != nullptr) { + rmm::device_uvector initial_guess_vertices(initial_guess_vertices_->size_, + handle_.get_stream()); + rmm::device_uvector initial_guess_values(initial_guess_values_->size_, + handle_.get_stream()); + + raft::copy(initial_guess_vertices.data(), + initial_guess_vertices_->as_type(), + initial_guess_vertices.size(), + handle_.get_stream()); + + raft::copy(initial_guess_values.data(), + initial_guess_values_->as_type(), + initial_guess_values.size(), + handle_.get_stream()); + + pageranks = cugraph::detail:: + collect_local_vertex_values_from_ext_vertex_value_pairs( + handle_, + std::move(initial_guess_vertices), + std::move(initial_guess_values), + *number_map, + graph_view.local_vertex_partition_range_first(), + graph_view.local_vertex_partition_range_last(), + weight_t{0}, + do_expensive_check_); + } + cugraph::pagerank( handle_, graph_view, precomputed_vertex_out_weight_sums_ - ? std::make_optional(precomputed_vertex_out_weight_sums_->as_type()) - : std::nullopt, - personalization_vertices_ - ? std::make_optional(personalization_vertices_->as_type()) - : std::nullopt, - personalization_values_ - ? std::make_optional(personalization_values_->as_type()) + ? std::make_optional(precomputed_vertex_out_weight_sums.data()) : std::nullopt, + personalization_vertices_ ? std::make_optional(personalization_vertices.data()) + : std::nullopt, + personalization_values_ ? std::make_optional(personalization_values.data()) : std::nullopt, personalization_vertices_ - ? std::make_optional(static_cast(personalization_vertices_->size_)) + ? std::make_optional(static_cast(personalization_vertices.size())) : std::nullopt, pageranks.data(), static_cast(alpha_), static_cast(epsilon_), max_iterations_, - has_initial_guess_, + initial_guess_values_ != nullptr, do_expensive_check_); rmm::device_uvector vertex_ids(graph_view.local_vertex_partition_range_size(), @@ -156,24 +243,60 @@ struct pagerank_functor : public cugraph::c_api::abstract_functor { extern "C" cugraph_error_code_t cugraph_pagerank( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_vertices, const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_sums, + const cugraph_type_erased_device_array_view_t* initial_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check, cugraph_centrality_result_t** result, cugraph_error_t** error) { + if (precomputed_vertex_out_weight_vertices != nullptr) { + CAPI_EXPECTS(reinterpret_cast(graph)->vertex_type_ == + reinterpret_cast( + precomputed_vertex_out_weight_vertices) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and precomputed_vertex_out_weight_vertices must match", + *error); + CAPI_EXPECTS(reinterpret_cast(graph)->weight_type_ == + reinterpret_cast( + precomputed_vertex_out_weight_sums) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and precomputed_vertex_out_weight_sums must match", + *error); + } + if (initial_guess_vertices != nullptr) { + CAPI_EXPECTS(reinterpret_cast(graph)->vertex_type_ == + reinterpret_cast( + initial_guess_vertices) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and initial_guess_vertices must match", + *error); + CAPI_EXPECTS(reinterpret_cast(graph)->weight_type_ == + reinterpret_cast( + initial_guess_values) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and initial_guess_values must match", + *error); + } pagerank_functor functor(handle, graph, + precomputed_vertex_out_weight_vertices, precomputed_vertex_out_weight_sums, + initial_guess_vertices, + initial_guess_values, nullptr, nullptr, alpha, epsilon, max_iterations, - has_initial_guess, do_expensive_check); return cugraph::c_api::run_algorithm(graph, functor, result, error); @@ -182,26 +305,79 @@ extern "C" cugraph_error_code_t cugraph_pagerank( extern "C" cugraph_error_code_t cugraph_personalized_pagerank( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_vertices, const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_sums, - cugraph_type_erased_device_array_view_t* personalization_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_values, + const cugraph_type_erased_device_array_view_t* personalization_vertices, const cugraph_type_erased_device_array_view_t* personalization_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check, cugraph_centrality_result_t** result, cugraph_error_t** error) { + if (precomputed_vertex_out_weight_vertices != nullptr) { + CAPI_EXPECTS(reinterpret_cast(graph)->vertex_type_ == + reinterpret_cast( + precomputed_vertex_out_weight_vertices) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and precomputed_vertex_out_weight_vertices must match", + *error); + CAPI_EXPECTS(reinterpret_cast(graph)->weight_type_ == + reinterpret_cast( + precomputed_vertex_out_weight_sums) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and precomputed_vertex_out_weight_sums must match", + *error); + } + if (initial_guess_vertices != nullptr) { + CAPI_EXPECTS(reinterpret_cast(graph)->vertex_type_ == + reinterpret_cast( + initial_guess_vertices) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and initial_guess_vertices must match", + *error); + CAPI_EXPECTS(reinterpret_cast(graph)->weight_type_ == + reinterpret_cast( + initial_guess_values) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and initial_guess_values must match", + *error); + } + if (personalization_vertices != nullptr) { + CAPI_EXPECTS(reinterpret_cast(graph)->vertex_type_ == + reinterpret_cast( + personalization_vertices) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and personalization_vector must match", + *error); + CAPI_EXPECTS(reinterpret_cast(graph)->weight_type_ == + reinterpret_cast( + personalization_values) + ->type_, + CUGRAPH_INVALID_INPUT, + "vertex type of graph and personalization_vector must match", + *error); + } + pagerank_functor functor(handle, graph, + precomputed_vertex_out_weight_vertices, precomputed_vertex_out_weight_sums, + initial_guess_vertices, + initial_guess_values, personalization_vertices, personalization_values, alpha, epsilon, max_iterations, - has_initial_guess, do_expensive_check); return cugraph::c_api::run_algorithm(graph, functor, result, error); diff --git a/cpp/src/detail/shuffle_wrappers.cu b/cpp/src/detail/shuffle_wrappers.cu index c36e95d268f..af1008bbeca 100644 --- a/cpp/src/detail/shuffle_wrappers.cu +++ b/cpp/src/detail/shuffle_wrappers.cu @@ -220,6 +220,24 @@ rmm::device_uvector shuffle_vertices_by_gpu_id_impl( return d_rx_vertices; } +template +std::tuple, rmm::device_uvector> +shuffle_vertices_and_values_by_gpu_id_impl(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values, + func_t func) +{ + std::tie(d_vertices, d_values, std::ignore) = cugraph::groupby_gpu_id_and_shuffle_kv_pairs( + handle.get_comms(), + d_vertices.begin(), + d_vertices.end(), + d_values.begin(), + [key_func = func] __device__(auto val) { return key_func(val); }, + handle.get_stream()); + + return std::make_tuple(std::move(d_vertices), std::move(d_values)); +} + template rmm::device_uvector shuffle_ext_vertices_by_gpu_id( raft::handle_t const& handle, rmm::device_uvector&& d_vertices) @@ -256,22 +274,56 @@ rmm::device_uvector shuffle_int_vertices_by_gpu_id( return return_value; } -template rmm::device_uvector shuffle_ext_vertices_by_gpu_id( - raft::handle_t const& handle, rmm::device_uvector&& d_vertices); - -template rmm::device_uvector shuffle_ext_vertices_by_gpu_id( - raft::handle_t const& handle, rmm::device_uvector&& d_vertices); - template rmm::device_uvector shuffle_int_vertices_by_gpu_id( raft::handle_t const& handle, rmm::device_uvector&& d_vertices, std::vector const& vertex_partition_range_lasts); - template rmm::device_uvector shuffle_int_vertices_by_gpu_id( raft::handle_t const& handle, rmm::device_uvector&& d_vertices, std::vector const& vertex_partition_range_lasts); +template rmm::device_uvector shuffle_ext_vertices_by_gpu_id( + raft::handle_t const& handle, rmm::device_uvector&& d_vertices); + +template rmm::device_uvector shuffle_ext_vertices_by_gpu_id( + raft::handle_t const& handle, rmm::device_uvector&& d_vertices); + +template +std::tuple, rmm::device_uvector> +shuffle_ext_vertices_and_values_by_gpu_id(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values) +{ + auto const comm_size = handle.get_comms().get_size(); + + return shuffle_vertices_and_values_by_gpu_id_impl( + handle, + std::move(d_vertices), + std::move(d_values), + cugraph::detail::compute_gpu_id_from_ext_vertex_t{comm_size}); +} + +template std::tuple, rmm::device_uvector> +shuffle_ext_vertices_and_values_by_gpu_id(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values); + +template std::tuple, rmm::device_uvector> +shuffle_ext_vertices_and_values_by_gpu_id(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values); + +template std::tuple, rmm::device_uvector> +shuffle_ext_vertices_and_values_by_gpu_id(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values); + +template std::tuple, rmm::device_uvector> +shuffle_ext_vertices_and_values_by_gpu_id(raft::handle_t const& handle, + rmm::device_uvector&& d_vertices, + rmm::device_uvector&& d_values); + template rmm::device_uvector groupby_and_count_edgelist_by_local_partition_id( raft::handle_t const& handle, diff --git a/cpp/tests/c_api/mg_bfs_test.c b/cpp/tests/c_api/mg_bfs_test.c index 0fc1fc431b5..ae1146c6a49 100644 --- a/cpp/tests/c_api/mg_bfs_test.c +++ b/cpp/tests/c_api/mg_bfs_test.c @@ -25,8 +25,7 @@ typedef int32_t vertex_t; typedef int32_t edge_t; typedef float weight_t; -int generic_bfs_test( - const cugraph_resource_handle_t* p_handle, +int generic_bfs_test(const cugraph_resource_handle_t* p_handle, vertex_t* h_src, vertex_t* h_dst, weight_t* h_wgt, @@ -37,15 +36,16 @@ int generic_bfs_test( size_t num_edges, size_t num_seeds, size_t depth_limit, - bool_t store_transposed) { + bool_t store_transposed) +{ int test_ret_value = 0; cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; cugraph_error_t* ret_error; - cugraph_graph_t* p_graph = NULL; - cugraph_paths_result_t* paths_result = NULL; - cugraph_type_erased_device_array_t* p_sources = NULL; + cugraph_graph_t* p_graph = NULL; + cugraph_paths_result_t* paths_result = NULL; + cugraph_type_erased_device_array_t* p_sources = NULL; cugraph_type_erased_device_array_view_t* p_source_view = NULL; ret_code = @@ -63,16 +63,9 @@ int generic_bfs_test( TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "create_mg_test_graph failed."); - ret_code = cugraph_bfs(p_handle, - p_graph, - p_source_view, - FALSE, - 10000000, - TRUE, - TRUE, - &paths_result, - &ret_error); - + ret_code = cugraph_bfs( + p_handle, p_graph, p_source_view, FALSE, 10000000, TRUE, TRUE, &paths_result, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "cugraph_bfs failed."); @@ -80,9 +73,9 @@ int generic_bfs_test( cugraph_type_erased_device_array_view_t* distances; cugraph_type_erased_device_array_view_t* predecessors; - vertices = cugraph_paths_result_get_vertices(paths_result); + vertices = cugraph_paths_result_get_vertices(paths_result); predecessors = cugraph_paths_result_get_predecessors(paths_result); - distances = cugraph_paths_result_get_distances(paths_result); + distances = cugraph_paths_result_get_distances(paths_result); vertex_t h_vertices[num_vertices]; vertex_t h_predecessors[num_vertices]; @@ -106,8 +99,7 @@ int generic_bfs_test( TEST_ASSERT(test_ret_value, expected_distances[h_vertices[i]] == h_distances[i], "bfs distances don't match"); - - + TEST_ASSERT(test_ret_value, expected_predecessors[h_vertices[i]] == h_predecessors[i], "bfs predecessors don't match"); @@ -168,7 +160,7 @@ int main(int argc, char** argv) void* raft_handle = create_raft_handle(prows); handle = cugraph_create_resource_handle(raft_handle); - int result = 0; + int result = 0; result |= RUN_MG_TEST(test_bfs, handle); cugraph_free_resource_handle(handle); diff --git a/cpp/tests/c_api/mg_pagerank_test.c b/cpp/tests/c_api/mg_pagerank_test.c index 7c557d7bed8..8ac0c3070f5 100644 --- a/cpp/tests/c_api/mg_pagerank_test.c +++ b/cpp/tests/c_api/mg_pagerank_test.c @@ -42,7 +42,7 @@ int generic_pagerank_test(const cugraph_resource_handle_t* handle, cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; cugraph_error_t* ret_error; - cugraph_graph_t* p_graph = NULL; + cugraph_graph_t* p_graph = NULL; cugraph_centrality_result_t* p_result = NULL; ret_code = create_mg_test_graph( @@ -51,7 +51,7 @@ int generic_pagerank_test(const cugraph_resource_handle_t* handle, TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "create_mg_test_graph failed."); ret_code = cugraph_pagerank( - handle, p_graph, NULL, alpha, epsilon, max_iterations, FALSE, FALSE, &p_result, &ret_error); + handle, p_graph, NULL, NULL, NULL, NULL, alpha, epsilon, max_iterations, FALSE, &p_result, &ret_error); TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "cugraph_pagerank failed."); // NOTE: Because we get back vertex ids and pageranks, we can simply compare @@ -90,6 +90,118 @@ int generic_pagerank_test(const cugraph_resource_handle_t* handle, return test_ret_value; } +int generic_personalized_pagerank_test(const cugraph_resource_handle_t *handle, + vertex_t* h_src, + vertex_t* h_dst, + weight_t* h_wgt, + weight_t* h_result, + vertex_t* h_personalization_vertices, + weight_t* h_personalization_values, + size_t num_vertices, + size_t num_edges, + size_t num_personalization_vertices, + bool_t store_transposed, + double alpha, + double epsilon, + size_t max_iterations) +{ + int test_ret_value = 0; + + cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; + cugraph_error_t* ret_error; + + cugraph_graph_t* p_graph = NULL; + cugraph_centrality_result_t* p_result = NULL; + cugraph_type_erased_device_array_t* personalization_vertices = NULL; + cugraph_type_erased_device_array_t* personalization_values = NULL; + cugraph_type_erased_device_array_view_t* personalization_vertices_view = NULL; + cugraph_type_erased_device_array_view_t* personalization_values_view = NULL; + + data_type_id_t vertex_tid = INT32; + data_type_id_t weight_tid = FLOAT32; + + ret_code = create_mg_test_graph( + handle, h_src, h_dst, h_wgt, num_edges, store_transposed, FALSE, &p_graph, &ret_error); + + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "create_test_graph failed."); + TEST_ALWAYS_ASSERT(ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); + + if (cugraph_resource_handle_get_rank(handle) != 0) { + num_personalization_vertices = 0; + } + + ret_code = cugraph_type_erased_device_array_create( + handle, num_personalization_vertices, vertex_tid, &personalization_vertices, &ret_error); + TEST_ASSERT( + test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_vertices create failed."); + + ret_code = cugraph_type_erased_device_array_create( + handle, num_personalization_vertices, weight_tid, &personalization_values, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_values create failed."); + + personalization_vertices_view = cugraph_type_erased_device_array_view(personalization_vertices); + personalization_values_view = cugraph_type_erased_device_array_view(personalization_values); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + handle, personalization_vertices_view, (byte_t*)h_personalization_vertices, &ret_error); + TEST_ASSERT( + test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_vertices copy_from_host failed."); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + handle, personalization_values_view, (byte_t*)h_personalization_values, &ret_error); + TEST_ASSERT( + test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_values copy_from_host failed."); + + ret_code = cugraph_personalized_pagerank(handle, + p_graph, + NULL, + NULL, + NULL, + NULL, + personalization_vertices_view, + personalization_values_view, + alpha, + epsilon, + max_iterations, + FALSE, + &p_result, + &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "cugraph_personalized_pagerank failed."); + TEST_ALWAYS_ASSERT(ret_code == CUGRAPH_SUCCESS, "cugraph_personalized_pagerank failed."); + + cugraph_type_erased_device_array_view_t* vertices; + cugraph_type_erased_device_array_view_t* pageranks; + + vertices = cugraph_centrality_result_get_vertices(p_result); + pageranks = cugraph_centrality_result_get_values(p_result); + + size_t num_local_vertices = cugraph_type_erased_device_array_view_size(vertices); + + vertex_t h_vertices[num_local_vertices]; + weight_t h_pageranks[num_local_vertices]; + + ret_code = cugraph_type_erased_device_array_view_copy_to_host( + handle, (byte_t*)h_vertices, vertices, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = cugraph_type_erased_device_array_view_copy_to_host( + handle, (byte_t*)h_pageranks, pageranks, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + for (int i = 0; (i < num_local_vertices) && (test_ret_value == 0); ++i) { + TEST_ASSERT(test_ret_value, + nearlyEqual(h_result[h_vertices[i]], h_pageranks[i], 0.001), + "pagerank results don't match"); + } + + cugraph_centrality_result_free(p_result); + cugraph_mg_graph_free(p_graph); + cugraph_error_free(ret_error); + + return test_ret_value; +} + + int test_pagerank(const cugraph_resource_handle_t* handle) { size_t num_edges = 8; @@ -204,6 +316,39 @@ int test_pagerank_4_with_transpose(const cugraph_resource_handle_t* handle) max_iterations); } +int test_personalized_pagerank(const cugraph_resource_handle_t* handle) +{ + size_t num_edges = 3; + size_t num_vertices = 4; + + vertex_t h_src[] = {0, 1, 2}; + vertex_t h_dst[] = {1, 2, 3}; + weight_t h_wgt[] = {1.f, 1.f, 1.f}; + weight_t h_result[] = {0.0559233f, 0.159381f, 0.303244f, 0.481451f}; + + vertex_t h_personalized_vertices[] = {0, 1, 2, 3}; + weight_t h_personalized_values[] = {0.1, 0.2, 0.3, 0.4}; + + double alpha = 0.85; + double epsilon = 1.0e-6; + size_t max_iterations = 500; + + return generic_personalized_pagerank_test(handle, + h_src, + h_dst, + h_wgt, + h_result, + h_personalized_vertices, + h_personalized_values, + num_vertices, + num_edges, + num_vertices, + FALSE, + alpha, + epsilon, + max_iterations); +} + /******************************************************************************/ int main(int argc, char** argv) @@ -246,6 +391,7 @@ int main(int argc, char** argv) result |= RUN_MG_TEST(test_pagerank_with_transpose, handle); result |= RUN_MG_TEST(test_pagerank_4, handle); result |= RUN_MG_TEST(test_pagerank_4_with_transpose, handle); + result |= RUN_MG_TEST(test_personalized_pagerank, handle); cugraph_free_resource_handle(handle); } diff --git a/cpp/tests/c_api/pagerank_test.c b/cpp/tests/c_api/pagerank_test.c index b985fb428e6..048750da06c 100644 --- a/cpp/tests/c_api/pagerank_test.c +++ b/cpp/tests/c_api/pagerank_test.c @@ -41,8 +41,8 @@ int generic_pagerank_test(vertex_t* h_src, cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; cugraph_error_t* ret_error; - cugraph_resource_handle_t* p_handle = NULL; - cugraph_graph_t* p_graph = NULL; + cugraph_resource_handle_t* p_handle = NULL; + cugraph_graph_t* p_graph = NULL; cugraph_centrality_result_t* p_result = NULL; p_handle = cugraph_create_resource_handle(NULL); @@ -52,10 +52,20 @@ int generic_pagerank_test(vertex_t* h_src, p_handle, h_src, h_dst, h_wgt, num_edges, store_transposed, FALSE, FALSE, &p_graph, &ret_error); TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "create_test_graph failed."); - TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); - - ret_code = cugraph_pagerank( - p_handle, p_graph, NULL, alpha, epsilon, max_iterations, FALSE, FALSE, &p_result, &ret_error); + TEST_ALWAYS_ASSERT(ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); + + ret_code = cugraph_pagerank(p_handle, + p_graph, + NULL, + NULL, + NULL, + NULL, + alpha, + epsilon, + max_iterations, + FALSE, + &p_result, + &ret_error); TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "cugraph_pagerank failed."); cugraph_type_erased_device_array_view_t* vertices; @@ -89,6 +99,115 @@ int generic_pagerank_test(vertex_t* h_src, return test_ret_value; } +int generic_personalized_pagerank_test(vertex_t* h_src, + vertex_t* h_dst, + weight_t* h_wgt, + weight_t* h_result, + vertex_t* h_personalization_vertices, + weight_t* h_personalization_values, + size_t num_vertices, + size_t num_edges, + size_t num_personalization_vertices, + bool_t store_transposed, + double alpha, + double epsilon, + size_t max_iterations) +{ + int test_ret_value = 0; + + cugraph_error_code_t ret_code = CUGRAPH_SUCCESS; + cugraph_error_t* ret_error; + + cugraph_resource_handle_t* p_handle = NULL; + cugraph_graph_t* p_graph = NULL; + cugraph_centrality_result_t* p_result = NULL; + cugraph_type_erased_device_array_t* personalization_vertices = NULL; + cugraph_type_erased_device_array_t* personalization_values = NULL; + cugraph_type_erased_device_array_view_t* personalization_vertices_view = NULL; + cugraph_type_erased_device_array_view_t* personalization_values_view = NULL; + + data_type_id_t vertex_tid = INT32; + data_type_id_t weight_tid = FLOAT32; + + p_handle = cugraph_create_resource_handle(NULL); + TEST_ASSERT(test_ret_value, p_handle != NULL, "resource handle creation failed."); + + ret_code = create_test_graph( + p_handle, h_src, h_dst, h_wgt, num_edges, store_transposed, FALSE, FALSE, &p_graph, &ret_error); + + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "create_test_graph failed."); + TEST_ALWAYS_ASSERT(ret_code == CUGRAPH_SUCCESS, cugraph_error_message(ret_error)); + + ret_code = cugraph_type_erased_device_array_create( + p_handle, num_personalization_vertices, vertex_tid, &personalization_vertices, &ret_error); + TEST_ASSERT( + test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_vertices create failed."); + + ret_code = cugraph_type_erased_device_array_create( + p_handle, num_personalization_vertices, weight_tid, &personalization_values, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_values create failed."); + + personalization_vertices_view = cugraph_type_erased_device_array_view(personalization_vertices); + personalization_values_view = cugraph_type_erased_device_array_view(personalization_values); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + p_handle, personalization_vertices_view, (byte_t*)h_personalization_vertices, &ret_error); + TEST_ASSERT( + test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_vertices copy_from_host failed."); + + ret_code = cugraph_type_erased_device_array_view_copy_from_host( + p_handle, personalization_values_view, (byte_t*)h_personalization_values, &ret_error); + TEST_ASSERT( + test_ret_value, ret_code == CUGRAPH_SUCCESS, "personalization_values copy_from_host failed."); + + ret_code = cugraph_personalized_pagerank(p_handle, + p_graph, + NULL, + NULL, + NULL, + NULL, + personalization_vertices_view, + personalization_values_view, + alpha, + epsilon, + max_iterations, + FALSE, + &p_result, + &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "cugraph_personalized_pagerank failed."); + TEST_ALWAYS_ASSERT(ret_code == CUGRAPH_SUCCESS, "cugraph_personalized_pagerank failed."); + + cugraph_type_erased_device_array_view_t* vertices; + cugraph_type_erased_device_array_view_t* pageranks; + + vertices = cugraph_centrality_result_get_vertices(p_result); + pageranks = cugraph_centrality_result_get_values(p_result); + + vertex_t h_vertices[num_vertices]; + weight_t h_pageranks[num_vertices]; + + ret_code = cugraph_type_erased_device_array_view_copy_to_host( + p_handle, (byte_t*)h_vertices, vertices, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + ret_code = cugraph_type_erased_device_array_view_copy_to_host( + p_handle, (byte_t*)h_pageranks, pageranks, &ret_error); + TEST_ASSERT(test_ret_value, ret_code == CUGRAPH_SUCCESS, "copy_to_host failed."); + + for (int i = 0; (i < num_vertices) && (test_ret_value == 0); ++i) { + TEST_ASSERT(test_ret_value, + nearlyEqual(h_result[h_vertices[i]], h_pageranks[i], 0.001), + "pagerank results don't match"); + } + + cugraph_centrality_result_free(p_result); + cugraph_sg_graph_free(p_graph); + cugraph_free_resource_handle(p_handle); + cugraph_error_free(ret_error); + + return test_ret_value; +} + int test_pagerank() { size_t num_edges = 8; @@ -137,7 +256,8 @@ int test_pagerank_4() vertex_t h_src[] = {0, 1, 2}; vertex_t h_dst[] = {1, 2, 3}; weight_t h_wgt[] = {1.f, 1.f, 1.f}; - weight_t h_result[] = {0.11615584790706635f, 0.21488840878009796f, 0.29881080985069275f, 0.37014490365982056f}; + weight_t h_result[] = { + 0.11615584790706635f, 0.21488840878009796f, 0.29881080985069275f, 0.37014490365982056f}; double alpha = 0.85; double epsilon = 1.0e-6; @@ -155,7 +275,8 @@ int test_pagerank_4_with_transpose() vertex_t h_src[] = {0, 1, 2}; vertex_t h_dst[] = {1, 2, 3}; weight_t h_wgt[] = {1.f, 1.f, 1.f}; - weight_t h_result[] = {0.11615584790706635f, 0.21488840878009796f, 0.29881080985069275f, 0.37014490365982056f}; + weight_t h_result[] = { + 0.11615584790706635f, 0.21488840878009796f, 0.29881080985069275f, 0.37014490365982056f}; double alpha = 0.85; double epsilon = 1.0e-6; @@ -165,6 +286,38 @@ int test_pagerank_4_with_transpose() h_src, h_dst, h_wgt, h_result, num_vertices, num_edges, TRUE, alpha, epsilon, max_iterations); } +int test_personalized_pagerank() +{ + size_t num_edges = 3; + size_t num_vertices = 4; + + vertex_t h_src[] = {0, 1, 2}; + vertex_t h_dst[] = {1, 2, 3}; + weight_t h_wgt[] = {1.f, 1.f, 1.f}; + weight_t h_result[] = {0.0559233f, 0.159381f, 0.303244f, 0.481451f}; + + vertex_t h_personalized_vertices[] = {0, 1, 2, 3}; + weight_t h_personalized_values[] = {0.1, 0.2, 0.3, 0.4}; + + double alpha = 0.85; + double epsilon = 1.0e-6; + size_t max_iterations = 500; + + return generic_personalized_pagerank_test(h_src, + h_dst, + h_wgt, + h_result, + h_personalized_vertices, + h_personalized_values, + num_vertices, + num_edges, + num_vertices, + FALSE, + alpha, + epsilon, + max_iterations); +} + /******************************************************************************/ int main(int argc, char** argv) @@ -174,5 +327,6 @@ int main(int argc, char** argv) result |= RUN_TEST(test_pagerank_with_transpose); result |= RUN_TEST(test_pagerank_4); result |= RUN_TEST(test_pagerank_4_with_transpose); + result |= RUN_TEST(test_personalized_pagerank); return result; } diff --git a/cpp/tests/layout/trust_worthiness.h b/cpp/tests/layout/trust_worthiness.h index 5a112ea3c6b..3e6b018d6c5 100644 --- a/cpp/tests/layout/trust_worthiness.h +++ b/cpp/tests/layout/trust_worthiness.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2021, NVIDIA CORPORATION. + * Copyright (c) 2020-2022, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,7 +33,6 @@ double euclidian_dist(const std::vector& x, const std::vector& y) std::vector> pairwise_distances(const std::vector>& X) { std::vector> distance_matrix(X.size(), std::vector(X[0].size())); -#pragma omp parallel for for (size_t i = 0; i < X.size(); ++i) { for (size_t j = 0; j < i; ++j) { const float val = euclidian_dist(X[i], X[j]); diff --git a/python/cugraph/CMakeLists.txt b/python/cugraph/CMakeLists.txt index ee929ca6e27..540b8d6d2af 100644 --- a/python/cugraph/CMakeLists.txt +++ b/python/cugraph/CMakeLists.txt @@ -70,13 +70,11 @@ add_subdirectory(cugraph/dask/centrality) add_subdirectory(cugraph/dask/comms) add_subdirectory(cugraph/dask/community) add_subdirectory(cugraph/dask/components) -add_subdirectory(cugraph/dask/link_analysis) add_subdirectory(cugraph/dask/structure) add_subdirectory(cugraph/generators) add_subdirectory(cugraph/internals) add_subdirectory(cugraph/layout) add_subdirectory(cugraph/linear_assignment) -add_subdirectory(cugraph/link_analysis) add_subdirectory(cugraph/link_prediction) add_subdirectory(cugraph/sampling) add_subdirectory(cugraph/structure) diff --git a/python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt b/python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt deleted file mode 100644 index b204a6b6927..00000000000 --- a/python/cugraph/cugraph/dask/link_analysis/CMakeLists.txt +++ /dev/null @@ -1,25 +0,0 @@ -# ============================================================================= -# Copyright (c) 2022, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except -# in compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under the License -# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -# or implied. See the License for the specific language governing permissions and limitations under -# the License. -# ============================================================================= - -set(cython_sources mg_pagerank_wrapper.pyx) -set(linked_libraries cugraph::cugraph) -rapids_cython_create_modules( - CXX - SOURCE_FILES "${cython_sources}" - LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX link_analysis_ -) - -foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) - set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../../library") -endforeach() diff --git a/python/cugraph/cugraph/dask/link_analysis/mg_pagerank.pxd b/python/cugraph/cugraph/dask/link_analysis/mg_pagerank.pxd deleted file mode 100644 index 4b47f43dd87..00000000000 --- a/python/cugraph/cugraph/dask/link_analysis/mg_pagerank.pxd +++ /dev/null @@ -1,34 +0,0 @@ -# -# Copyright (c) 2020-2021, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from cugraph.structure.graph_utilities cimport * -from libcpp cimport bool - - -cdef extern from "cugraph/utilities/cython.hpp" namespace "cugraph::cython": - - cdef void call_pagerank[vertex_t, weight_t]( - const handle_t &handle, - const graph_container_t &g, - vertex_t *identifiers, - weight_t *pagerank, - vertex_t size, - vertex_t *personalization_subset, - weight_t *personalization_values, - double alpha, - double tolerance, - long long max_iter, - bool has_guess) except + diff --git a/python/cugraph/cugraph/dask/link_analysis/mg_pagerank_wrapper.pyx b/python/cugraph/cugraph/dask/link_analysis/mg_pagerank_wrapper.pyx deleted file mode 100644 index 3ae81de95ff..00000000000 --- a/python/cugraph/cugraph/dask/link_analysis/mg_pagerank_wrapper.pyx +++ /dev/null @@ -1,144 +0,0 @@ -# -# Copyright (c) 2020-2022, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from cugraph.structure.utils_wrapper import * -from cugraph.dask.link_analysis cimport mg_pagerank as c_pagerank -import cudf -from cugraph.structure.graph_utilities cimport * -import cugraph.structure.graph_primtypes_wrapper as graph_primtypes_wrapper -from libc.stdint cimport uintptr_t -from cython.operator cimport dereference as deref -import numpy as np - - -def mg_pagerank(input_df, - src_col_name, - dst_col_name, - num_global_verts, - num_global_edges, - vertex_partition_offsets, - rank, - handle, - segment_offsets, - alpha=0.85, - max_iter=100, - tol=1.0e-5, - personalization=None, - nstart=None): - """ - Call pagerank - """ - cdef size_t handle_size_t = handle.getHandle() - handle_ = handle_size_t - - src = input_df[src_col_name] - dst = input_df[dst_col_name] - vertex_t = src.dtype - if num_global_edges > (2**31 - 1): - edge_t = np.dtype("int64") - else: - edge_t = vertex_t - if "value" in input_df.columns: - weights = input_df['value'] - weight_t = weights.dtype - is_weighted = True - raise NotImplementedError # FIXME: c_edge_weights is always set to NULL - else: - weights = None - weight_t = np.dtype("float32") - is_weighted = False - - # FIXME: Offsets and indices are currently hardcoded to int, but this may - # not be acceptable in the future. - numberTypeMap = {np.dtype("int32") : numberTypeEnum.int32Type, - np.dtype("int64") : numberTypeEnum.int64Type, - np.dtype("float32") : numberTypeEnum.floatType, - np.dtype("double") : numberTypeEnum.doubleType} - - # FIXME: needs to be edge_t type not int - cdef int num_local_edges = len(src) - - cdef uintptr_t c_src_vertices = src.__cuda_array_interface__['data'][0] - cdef uintptr_t c_dst_vertices = dst.__cuda_array_interface__['data'][0] - cdef uintptr_t c_edge_weights = NULL - if weights is not None: - c_edge_weights = weights.__cuda_array_interface__['data'][0] - - # FIXME: data is on device, move to host (to_pandas()), convert to np array and access pointer to pass to C - vertex_partition_offsets_host = vertex_partition_offsets.values_host - cdef uintptr_t c_vertex_partition_offsets = vertex_partition_offsets_host.__array_interface__['data'][0] - - cdef vector[int] v_segment_offsets_32 - cdef vector[long] v_segment_offsets_64 - cdef uintptr_t c_segment_offsets - if (vertex_t == np.dtype("int32")): - v_segment_offsets_32 = segment_offsets - c_segment_offsets = v_segment_offsets_32.data() - else: - v_segment_offsets_64 = segment_offsets - c_segment_offsets = v_segment_offsets_64.data() - - cdef graph_container_t graph_container - - populate_graph_container(graph_container, - handle_[0], - c_src_vertices, c_dst_vertices, c_edge_weights, - c_vertex_partition_offsets, - c_segment_offsets, - len(segment_offsets) - 1, - ((numberTypeMap[vertex_t])), - ((numberTypeMap[edge_t])), - ((numberTypeMap[weight_t])), - num_local_edges, - num_global_verts, num_global_edges, - is_weighted, - False, - True, True) - - df = cudf.DataFrame() - df['vertex'] = cudf.Series(np.arange(vertex_partition_offsets.iloc[rank], vertex_partition_offsets.iloc[rank+1]), dtype=vertex_t) - df['pagerank'] = cudf.Series(np.zeros(len(df['vertex']), dtype=weight_t)) - - cdef uintptr_t c_identifier = df['vertex'].__cuda_array_interface__['data'][0]; - cdef uintptr_t c_pagerank_val = df['pagerank'].__cuda_array_interface__['data'][0]; - - cdef uintptr_t c_pers_vtx = NULL - cdef uintptr_t c_pers_val = NULL - cdef int sz = 0 - - if personalization is not None: - sz = personalization['vertex'].shape[0] - personalization['vertex'] = personalization['vertex'].astype(vertex_t) - personalization['values'] = personalization['values'].astype(weight_t) - c_pers_vtx = personalization['vertex'].__cuda_array_interface__['data'][0] - c_pers_val = personalization['values'].__cuda_array_interface__['data'][0] - - if vertex_t == np.int32: - if (df['pagerank'].dtype == np.float32): - c_pagerank.call_pagerank[int, float](handle_[0], graph_container, c_identifier, c_pagerank_val, sz, c_pers_vtx, c_pers_val, - alpha, tol, max_iter, 0) - else: - c_pagerank.call_pagerank[int, double](handle_[0], graph_container, c_identifier, c_pagerank_val, sz, c_pers_vtx, c_pers_val, - alpha, tol, max_iter, 0) - else: - if (df['pagerank'].dtype == np.float32): - c_pagerank.call_pagerank[long, float](handle_[0], graph_container, c_identifier, c_pagerank_val, sz, c_pers_vtx, c_pers_val, - alpha, tol, max_iter, 0) - else: - c_pagerank.call_pagerank[long, double](handle_[0], graph_container, c_identifier, c_pagerank_val, sz, c_pers_vtx, c_pers_val, - alpha, tol, max_iter, 0) - - return df diff --git a/python/cugraph/cugraph/dask/link_analysis/pagerank.py b/python/cugraph/cugraph/dask/link_analysis/pagerank.py index 04ee580a34f..b60e2d81bd4 100644 --- a/python/cugraph/cugraph/dask/link_analysis/pagerank.py +++ b/python/cugraph/cugraph/dask/link_analysis/pagerank.py @@ -13,65 +13,133 @@ # limitations under the License. # -from dask.distributed import wait, default_client -from cugraph.dask.common.input_utils import (get_distributed_data, - get_vertex_partition_offsets) -from cugraph.dask.link_analysis import mg_pagerank_wrapper as mg_pagerank +from dask.distributed import wait import cugraph.dask.comms.comms as Comms import dask_cudf -from dask.dataframe.shuffle import rearrange_by_column - - -def call_pagerank(sID, - data, - src_col_name, - dst_col_name, - num_verts, - num_edges, - vertex_partition_offsets, - aggregate_segment_offsets, - alpha, - max_iter, - tol, - personalization, - nstart): - wid = Comms.get_worker_id(sID) - handle = Comms.get_handle(sID) - local_size = len(aggregate_segment_offsets) // Comms.get_n_workers(sID) - segment_offsets = \ - aggregate_segment_offsets[local_size * wid: local_size * (wid + 1)] - return mg_pagerank.mg_pagerank(data[0], - src_col_name, - dst_col_name, - num_verts, - num_edges, - vertex_partition_offsets, - wid, - handle, - segment_offsets, - alpha, - max_iter, - tol, - personalization, - nstart) +import cudf +import numpy as np +import warnings +from cugraph.dask.common.input_utils import get_distributed_data +from pylibcugraph import (ResourceHandle, + pagerank as pylibcugraph_pagerank, + personalized_pagerank as pylibcugraph_p_pagerank + ) -def pagerank(input_graph, - alpha=0.85, - personalization=None, - max_iter=100, - tol=1.0e-5, - nstart=None): +def convert_to_cudf(cp_arrays): + """ + Creates a cudf DataFrame from cupy arrays from pylibcugraph wrapper + """ + cupy_vertices, cupy_pagerank = cp_arrays + df = cudf.DataFrame() + df["vertex"] = cupy_vertices + df["pagerank"] = cupy_pagerank + + return df + + +# FIXME: Move this function to the utility module so that it can be +# shared by other algos +def ensure_valid_dtype(input_graph, input_df, input_df_name): + if input_graph.properties.weighted is False: + edge_attr_dtype = np.float64 + else: + edge_attr_dtype = input_graph.input_df["value"].dtype + + input_df_dtype = input_df["values"].dtype + if input_df_dtype != edge_attr_dtype: + warning_msg = (f"PageRank requires '{input_df_name}' values " + "to match the graph's 'edge_attr' type. " + f"edge_attr type is: {edge_attr_dtype} and got " + f"'{input_df_name}' values of type: " + f"{input_df_dtype}.") + warnings.warn(warning_msg, UserWarning) + input_df = input_df.astype( + {"values": edge_attr_dtype}) + + return input_df + + +def renumber_vertices(input_graph, input_df): + input_df = input_graph.add_internal_vertex_id( + input_df, "vertex", "vertex").compute() + + return input_df + + +def _call_plc_pagerank(sID, + mg_graph_x, + pre_vtx_o_wgt_vertices, + pre_vtx_o_wgt_sums, + initial_guess_vertices, + initial_guess_values, + alpha, + epsilon, + max_iterations, + do_expensive_check): + + return pylibcugraph_pagerank( + resource_handle=ResourceHandle( + Comms.get_handle(sID).getHandle() + ), + graph=mg_graph_x, + precomputed_vertex_out_weight_vertices=pre_vtx_o_wgt_vertices, + precomputed_vertex_out_weight_sums=pre_vtx_o_wgt_sums, + initial_guess_vertices=initial_guess_vertices, + initial_guess_values=initial_guess_values, + alpha=alpha, + epsilon=epsilon, + max_iterations=max_iterations, + do_expensive_check=do_expensive_check + ) + + +def _call_plc_personalized_pagerank(sID, + mg_graph_x, + pre_vtx_o_wgt_vertices, + pre_vtx_o_wgt_sums, + data_personalization, + initial_guess_vertices, + initial_guess_values, + alpha, + epsilon, + max_iterations, + do_expensive_check): + personalization_vertices = data_personalization["vertex"] + personalization_values = data_personalization["values"] + return pylibcugraph_p_pagerank( + resource_handle=ResourceHandle( + Comms.get_handle(sID).getHandle() + ), + graph=mg_graph_x, + precomputed_vertex_out_weight_vertices=pre_vtx_o_wgt_vertices, + precomputed_vertex_out_weight_sums=pre_vtx_o_wgt_sums, + personalization_vertices=personalization_vertices, + personalization_values=personalization_values, + initial_guess_vertices=initial_guess_vertices, + initial_guess_values=initial_guess_values, + alpha=alpha, + epsilon=epsilon, + max_iterations=max_iterations, + do_expensive_check=do_expensive_check + ) + + +def pagerank(input_graph, + alpha=0.85, personalization=None, + precomputed_vertex_out_weight=None, + max_iter=100, tol=1.0e-5, nstart=None): """ Find the PageRank values for each vertex in a graph using multiple GPUs. cuGraph computes an approximation of the Pagerank using the power method. The input graph must contain edge list as dask-cudf dataframe with one partition per GPU. + All edges will have an edge_attr value of 1.0 if not provided. Parameters ---------- - input_graph : cugraph.DiGraph + input_graph : cugraph.Graph cuGraph graph descriptor, should contain the connectivity information as dask cudf edge list dataframe(edge weights are not used for this algorithm). @@ -84,19 +152,29 @@ def pagerank(input_graph, personalization : cudf.Dataframe, optional (default=None) GPU Dataframe containing the personalization information. - Currently not supported. - + (a performance optimization) personalization['vertex'] : cudf.Series Subset of vertices of graph for personalization personalization['values'] : cudf.Series Personalization values for vertices + precomputed_vertex_out_weight : cudf.Dataframe, optional (default=None) + GPU Dataframe containing the precomputed vertex out weight + (a performance optimization) + information. + precomputed_vertex_out_weight['vertex'] : cudf.Series + Subset of vertices of graph for precomputed_vertex_out_weight + precomputed_vertex_out_weight['sums'] : cudf.Series + Corresponding precomputed sum of outgoing vertices weight + max_iter : int, optional (default=100) - The maximum number of iterations before an answer is returned. + The maximum number of iterations before an answer is returned. This can + be used to limit the execution time and do an early exit before the + solver reaches the convergence tolerance. If this value is lower or equal to 0 cuGraph will use the default - value, which is 30. + value, which is 100. - tol : float, optional (default=1.0e-5) + tol : float, optional (default=1e-05) Set the tolerance the approximation, this parameter should be a small magnitude value. The lower the tolerance the better the approximation. If this value is @@ -105,8 +183,13 @@ def pagerank(input_graph, numerical roundoff. Usually values between 0.01 and 0.00001 are acceptable. - nstart : not supported - initial guess for pagerank + nstart : cudf.Dataframe, optional (default=None) + GPU Dataframe containing the initial guess for pagerank. + (a performance optimization) + nstart['vertex'] : cudf.Series + Subset of vertices of graph for initial guess for pagerank values + nstart['values'] : cudf.Series + Pagerank values for vertices Returns ------- @@ -114,6 +197,16 @@ def pagerank(input_graph, GPU data frame containing two dask_cudf.Series of size V: the vertex identifiers and the corresponding PageRank values. + NOTE: if the input cugraph.Graph was created using the renumber=False + option of any of the from_*_edgelist() methods, pagerank assumes that + the vertices in the edgelist are contiguous and start from 0. + If the actual set of vertices in the edgelist is not + contiguous (has gaps) or does not start from zero, pagerank will assume + the "missing" vertices are isolated vertices in the graph, and will + compute and return pagerank values for each. If this is not the desired + behavior, ensure the input cugraph.Graph is created from the + from_*_edgelist() functions with the renumber=True option (the default) + ddf['vertex'] : dask_cudf.Series Contains the vertex identifiers ddf['pagerank'] : dask_cudf.Series @@ -136,94 +229,95 @@ def pagerank(input_graph, >>> pr = dcg.pagerank(dg) """ - nstart = None - client = default_client() + # Initialize dask client + client = input_graph._client - input_graph.compute_renumber_edge_list(transposed=True) + initial_guess_vertices = None + initial_guess_values = None + precomputed_vertex_out_weight_vertices = None + precomputed_vertex_out_weight_sums = None - ddf = input_graph.edgelist.edgelist_df - vertex_partition_offsets = get_vertex_partition_offsets(input_graph) - num_verts = vertex_partition_offsets.iloc[-1] - num_edges = len(ddf) - data = get_distributed_data(ddf) + do_expensive_check = False - src_col_name = input_graph.renumber_map.renumbered_src_col_name - dst_col_name = input_graph.renumber_map.renumbered_dst_col_name + # FIXME: Distribute the 'precomputed_vertex_out_weight' + # across GPUs for performance optimization + if precomputed_vertex_out_weight is not None: + if input_graph.renumbered is True: + precomputed_vertex_out_weight = renumber_vertices( + input_graph, precomputed_vertex_out_weight) + precomputed_vertex_out_weight_vertices = \ + precomputed_vertex_out_weight["vertex"] + precomputed_vertex_out_weight_sums = \ + precomputed_vertex_out_weight["sums"] + + # FIXME: Distribute the 'nstart' across GPUs for performance optimization + if nstart is not None: + if input_graph.renumbered is True: + nstart = renumber_vertices(input_graph, nstart) + nstart = ensure_valid_dtype( + input_graph, nstart, "nstart") + initial_guess_vertices = nstart["vertex"] + initial_guess_values = nstart["values"] if personalization is not None: if input_graph.renumbered is True: - personalization = input_graph.add_internal_vertex_id( - personalization, "vertex", "vertex" - ) + personalization = renumber_vertices(input_graph, personalization) + personalization = ensure_valid_dtype( + input_graph, personalization, "personalization") - # Function to assign partition id to personalization dataframe - def _set_partitions_pre(s, divisions): - partitions = divisions.searchsorted(s, side="right") - 1 - partitions[ - divisions.tail(1).searchsorted(s, side="right").astype("bool") - ] = (len(divisions) - 2) - return partitions - - # Assign partition id column as per vertex_partition_offsets - df = personalization - by = ['vertex'] - meta = df._meta._constructor_sliced([0]) - divisions = vertex_partition_offsets - partitions = df[by].map_partitions( - _set_partitions_pre, divisions=divisions, meta=meta - ) - - df2 = df.assign(_partitions=partitions) - - # Shuffle personalization values according to the partition id - df3 = rearrange_by_column( - df2, - "_partitions", - max_branch=None, - npartitions=len(divisions) - 1, - shuffle="tasks", - ignore_index=False, - ).drop(columns=["_partitions"]) - - p_data = get_distributed_data(df3) - - result = [client.submit(call_pagerank, - Comms.get_session_id(), - wf[1], - src_col_name, - dst_col_name, - num_verts, - num_edges, - vertex_partition_offsets, - input_graph.aggregate_segment_offsets, - alpha, - max_iter, - tol, - p_data.worker_to_parts[wf[0]][0], - nstart, - workers=[wf[0]]) - for idx, wf in enumerate(data.worker_to_parts.items())] + personalization_ddf = dask_cudf.from_cudf( + personalization, npartitions=len(Comms.get_workers())) + + data_prsztn = get_distributed_data(personalization_ddf) + + result = [ + client.submit( + _call_plc_personalized_pagerank, + Comms.get_session_id(), + input_graph._plc_graph[w], + precomputed_vertex_out_weight_vertices, + precomputed_vertex_out_weight_sums, + data_personalization[0], + initial_guess_vertices, + initial_guess_values, + alpha, + tol, + max_iter, + do_expensive_check, + workers=[w], + ) + for w, data_personalization in data_prsztn.worker_to_parts.items() + ] else: - result = [client.submit(call_pagerank, - Comms.get_session_id(), - wf[1], - src_col_name, - dst_col_name, - num_verts, - num_edges, - vertex_partition_offsets, - input_graph.aggregate_segment_offsets, - alpha, - max_iter, - tol, - personalization, - nstart, - workers=[wf[0]]) - for idx, wf in enumerate(data.worker_to_parts.items())] + result = [ + client.submit( + _call_plc_pagerank, + Comms.get_session_id(), + input_graph._plc_graph[w], + precomputed_vertex_out_weight_vertices, + precomputed_vertex_out_weight_sums, + initial_guess_vertices, + initial_guess_values, + alpha, + tol, + max_iter, + do_expensive_check, + workers=[w], + ) + for w in Comms.get_workers() + ] + wait(result) - ddf = dask_cudf.from_delayed(result) + + cudf_result = [client.submit(convert_to_cudf, + cp_arrays) + for cp_arrays in result] + + wait(cudf_result) + + ddf = dask_cudf.from_delayed(cudf_result) if input_graph.renumbered: - return input_graph.unrenumber(ddf, 'vertex') + ddf = input_graph.unrenumber(ddf, "vertex") return ddf diff --git a/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py b/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py index 4ffe01aadce..c046a1bfd0b 100644 --- a/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py +++ b/python/cugraph/cugraph/experimental/compat/nx/algorithms/link_analysis/pagerank_alg.py @@ -115,10 +115,10 @@ def pagerank( local_nstart = create_cudf_from_dict(nstart) return cugraph.pagerank( G, - alpha, - local_pers, - max_iter, - tol, - local_nstart, - weight, - dangling) + alpha=alpha, + personalization=local_pers, + max_iter=max_iter, + tol=tol, + nstart=local_nstart, + weight=weight, + dangling=dangling) diff --git a/python/cugraph/cugraph/link_analysis/CMakeLists.txt b/python/cugraph/cugraph/link_analysis/CMakeLists.txt deleted file mode 100644 index 30dbe239ea9..00000000000 --- a/python/cugraph/cugraph/link_analysis/CMakeLists.txt +++ /dev/null @@ -1,25 +0,0 @@ -# ============================================================================= -# Copyright (c) 2022, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except -# in compliance with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software distributed under the License -# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -# or implied. See the License for the specific language governing permissions and limitations under -# the License. -# ============================================================================= - -set(cython_sources pagerank_wrapper.pyx) -set(linked_libraries cugraph::cugraph) -rapids_cython_create_modules( - CXX - SOURCE_FILES "${cython_sources}" - LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX link_analysis_ -) - -foreach(cython_module IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) - set_target_properties(${cython_module} PROPERTIES INSTALL_RPATH "\$ORIGIN;\$ORIGIN/../library") -endforeach() diff --git a/python/cugraph/cugraph/link_analysis/pagerank.pxd b/python/cugraph/cugraph/link_analysis/pagerank.pxd deleted file mode 100644 index ed8f763b3ca..00000000000 --- a/python/cugraph/cugraph/link_analysis/pagerank.pxd +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright (c) 2019-2021, NVIDIA CORPORATION. -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# cython: profile=False -# distutils: language = c++ -# cython: embedsignature = True -# cython: language_level = 3 - -from cugraph.structure.graph_utilities cimport * -from libcpp cimport bool - - -cdef extern from "cugraph/utilities/cython.hpp" namespace "cugraph::cython": - - cdef void call_pagerank[VT,WT]( - const handle_t &handle, - const graph_container_t &g, - VT *identifiers, - WT *pagerank, - VT size, - VT *personalization_subset, - WT *personalization_values, - double alpha, - double tolerance, - long long max_iter, - bool has_guess) except + diff --git a/python/cugraph/cugraph/link_analysis/pagerank.py b/python/cugraph/cugraph/link_analysis/pagerank.py index ecb0ba6ea74..c0eb3a02ddb 100644 --- a/python/cugraph/cugraph/link_analysis/pagerank.py +++ b/python/cugraph/cugraph/link_analysis/pagerank.py @@ -11,17 +11,57 @@ # See the License for the specific language governing permissions and # limitations under the License. -import cudf - -from cugraph.link_analysis import pagerank_wrapper from cugraph.utilities import (ensure_cugraph_obj_for_nx, df_score_to_dictionary, ) +import cudf +import numpy as np +import warnings + +from pylibcugraph import (pagerank as pylibcugraph_pagerank, + personalized_pagerank as pylibcugraph_p_pagerank, + ResourceHandle + ) + + +def renumber_vertices(input_graph, input_df): + if len(input_graph.renumber_map.implementation.col_names) > 1: + cols = input_df.columns[:-1].to_list() + else: + cols = 'vertex' + input_df = input_graph.add_internal_vertex_id( + input_df, "vertex", cols + ) + + return input_df + + +# FIXME: Move this function to the utility module so that it can be +# shared by other algos +def ensure_valid_dtype(input_graph, input_df, input_df_name): + if input_graph.edgelist.weights is False: + edge_attr_dtype = np.float32 + else: + edge_attr_dtype = input_graph.edgelist.edgelist_df["weights"].dtype + + input_df_dtype = input_df["values"].dtype + if input_df_dtype != edge_attr_dtype: + warning_msg = (f"PageRank requires '{input_df_name}' values " + "to match the graph's 'edge_attr' type. " + f"edge_attr type is: {edge_attr_dtype} and got " + f"'{input_df_name}' values of type: " + f"{input_df_dtype}.") + warnings.warn(warning_msg, UserWarning) + input_df = input_df.astype( + {"values": edge_attr_dtype}) + + return input_df def pagerank( - G, alpha=0.85, personalization=None, max_iter=100, tol=1.0e-5, nstart=None, - weight=None, dangling=None + G, alpha=0.85, personalization=None, + precomputed_vertex_out_weight=None, + max_iter=100, tol=1.0e-5, nstart=None, weight=None, dangling=None ): """ Find the PageRank score for every vertex in a graph. cuGraph computes an @@ -30,8 +70,7 @@ def pagerank( increases when the tolerance descreases and/or alpha increases toward the limiting value of 1. The user is free to use default values or to provide inputs for the initial guess, tolerance and maximum number of iterations. - - Parameters + Parameters. All edges will have an edge_attr value of 1.0 if not provided. ---------- G : cugraph.Graph or networkx.Graph cuGraph graph descriptor, should contain the connectivity information @@ -46,12 +85,20 @@ def pagerank( personalization : cudf.Dataframe, optional (default=None) GPU Dataframe containing the personalization information. - + (a performance optimization) personalization['vertex'] : cudf.Series Subset of vertices of graph for personalization personalization['values'] : cudf.Series Personalization values for vertices + precomputed_vertex_out_weight : cudf.Dataframe, optional (default=None) + GPU Dataframe containing the precomputed vertex out weight + information(a performance optimization). + precomputed_vertex_out_weight['vertex'] : cudf.Series + Subset of vertices of graph for precomputed_vertex_out_weight + precomputed_vertex_out_weight['sums'] : cudf.Series + Corresponding precomputed sum of outgoing vertices weight + max_iter : int, optional (default=100) The maximum number of iterations before an answer is returned. This can be used to limit the execution time and do an early exit before the @@ -70,7 +117,7 @@ def pagerank( nstart : cudf.Dataframe, optional (default=None) GPU Dataframe containing the initial guess for pagerank. - + (a performance optimization). nstart['vertex'] : cudf.Series Subset of vertices of graph for initial guess for pagerank values nstart['values'] : cudf.Series @@ -90,21 +137,51 @@ def pagerank( GPU data frame containing two cudf.Series of size V: the vertex identifiers and the corresponding PageRank values. + NOTE: if the input cugraph.Graph was created using the renumber=False + option of any of the from_*_edgelist() methods, pagerank assumes that + the vertices in the edgelist are contiguous and start from 0. + If the actual set of vertices in the edgelist is not + contiguous (has gaps) or does not start from zero, pagerank will assume + the "missing" vertices are isolated vertices in the graph, and will + compute and return pagerank values for each. If this is not the desired + behavior, ensure the input cugraph.Graph is created from the + from_*_edgelist() functions with the renumber=True option (the default) + df['vertex'] : cudf.Series Contains the vertex identifiers df['pagerank'] : cudf.Series Contains the PageRank score - - Examples -------- >>> from cugraph.experimental.datasets import karate >>> G = karate.get_graph(fetch=True) >>> pr = cugraph.pagerank(G, alpha = 0.85, max_iter = 500, tol = 1.0e-05) - """ + initial_guess_vertices = None + initial_guess_values = None + pre_vtx_o_wgt_vertices = None + pre_vtx_o_wgt_sums = None + G, isNx = ensure_cugraph_obj_for_nx(G, weight) + do_expensive_check = False + + if nstart is not None: + if G.renumbered is True: + nstart = renumber_vertices(G, nstart) + nstart = ensure_valid_dtype( + G, nstart, "nstart") + initial_guess_vertices = nstart["vertex"] + initial_guess_values = nstart["values"] + + if precomputed_vertex_out_weight is not None: + if G.renumbered is True: + precomputed_vertex_out_weight = renumber_vertices( + G, precomputed_vertex_out_weight) + pre_vtx_o_wgt_vertices = \ + precomputed_vertex_out_weight["vertex"] + pre_vtx_o_wgt_sums = \ + precomputed_vertex_out_weight["sums"] if personalization is not None: if not isinstance(personalization, cudf.DataFrame): @@ -113,32 +190,49 @@ def pagerank( "currently not supported" ) if G.renumbered is True: - if len(G.renumber_map.implementation.col_names) > 1: - cols = personalization.columns[:-1].to_list() - else: - cols = 'vertex' - personalization = G.add_internal_vertex_id( - personalization, "vertex", cols - ) - - if nstart is not None: - if G.renumbered is True: - if len(G.renumber_map.implementation.col_names) > 1: - cols = nstart.columns[:-1].to_list() - else: - cols = 'vertex' - nstart = G.add_internal_vertex_id( - nstart, "vertex", cols + personalization = renumber_vertices( + G, personalization) + + personalization = ensure_valid_dtype( + G, personalization, "personalization") + + vertex, pagerank_values = \ + pylibcugraph_p_pagerank( + resource_handle=ResourceHandle(), + graph=G._plc_graph, + precomputed_vertex_out_weight_vertices=pre_vtx_o_wgt_vertices, + precomputed_vertex_out_weight_sums=pre_vtx_o_wgt_sums, + personalization_vertices=personalization["vertex"], + personalization_values=personalization["values"], + initial_guess_vertices=initial_guess_vertices, + initial_guess_values=initial_guess_values, + alpha=alpha, + epsilon=tol, + max_iterations=max_iter, + do_expensive_check=do_expensive_check) + else: + vertex, pagerank_values = \ + pylibcugraph_pagerank( + resource_handle=ResourceHandle(), + graph=G._plc_graph, + precomputed_vertex_out_weight_vertices=pre_vtx_o_wgt_vertices, + precomputed_vertex_out_weight_sums=pre_vtx_o_wgt_sums, + initial_guess_vertices=initial_guess_vertices, + initial_guess_values=initial_guess_values, + alpha=alpha, + epsilon=tol, + max_iterations=max_iter, + do_expensive_check=do_expensive_check ) - df = pagerank_wrapper.pagerank( - G, alpha, personalization, max_iter, tol, nstart - ) + df = cudf.DataFrame() + df["vertex"] = vertex + df["pagerank"] = pagerank_values if G.renumbered: df = G.unrenumber(df, "vertex") if isNx is True: - return df_score_to_dictionary(df, 'pagerank') - else: - return df + df = df_score_to_dictionary(df, 'pagerank') + + return df diff --git a/python/cugraph/cugraph/link_analysis/pagerank_wrapper.pyx b/python/cugraph/cugraph/link_analysis/pagerank_wrapper.pyx deleted file mode 100644 index d94a61b4016..00000000000 --- a/python/cugraph/cugraph/link_analysis/pagerank_wrapper.pyx +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright (c) 2019-2021, NVIDIA CORPORATION. -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# cython: profile=False -# distutils: language = c++ -# cython: embedsignature = True -# cython: language_level = 3 - -from cugraph.link_analysis.pagerank cimport call_pagerank -from cugraph.structure.graph_utilities cimport * -from libcpp cimport bool -from libc.stdint cimport uintptr_t -from cugraph.structure import graph_primtypes_wrapper -import cudf -import numpy as np - - -def pagerank(input_graph, alpha=0.85, personalization=None, max_iter=100, tol=1.0e-5, nstart=None): - """ - Call pagerank - """ - - cdef unique_ptr[handle_t] handle_ptr - handle_ptr.reset(new handle_t()) - handle_ = handle_ptr.get(); - - [src, dst] = graph_primtypes_wrapper.datatype_cast([input_graph.edgelist.edgelist_df['src'], input_graph.edgelist.edgelist_df['dst']], [np.int32, np.int64]) - weights = None - if input_graph.edgelist.weights: - [weights] = graph_primtypes_wrapper.datatype_cast([input_graph.edgelist.edgelist_df['weights']], [np.float32, np.float64]) - - num_verts = input_graph.number_of_vertices() - num_edges = input_graph.number_of_edges(directed_edges=True) - # FIXME: needs to be edge_t type not int - cdef int num_local_edges = len(src) - cdef uintptr_t c_edge_weights = NULL - if weights is not None: - c_edge_weights = weights.__cuda_array_interface__['data'][0] - weight_t = weights.dtype - is_weighted = True - else: - weight_t = np.dtype("float32") - is_weighted = False - - df = cudf.DataFrame() - df['vertex'] = cudf.Series(np.arange(num_verts, dtype=src.dtype)) - df['pagerank'] = cudf.Series(np.zeros(num_verts, dtype=weight_t)) - - cdef bool has_guess = 0 - if nstart is not None: - if len(nstart) != num_verts: - raise ValueError('nstart must have initial guess for all vertices') - df['pagerank'][nstart['vertex']] = nstart['values'] - has_guess = 1 - - cdef uintptr_t c_identifier = df['vertex'].__cuda_array_interface__['data'][0]; - cdef uintptr_t c_pagerank_val = df['pagerank'].__cuda_array_interface__['data'][0]; - - cdef uintptr_t c_pers_vtx = NULL - cdef uintptr_t c_pers_val = NULL - cdef int sz = 0 - - cdef uintptr_t c_src_vertices = src.__cuda_array_interface__['data'][0] - cdef uintptr_t c_dst_vertices = dst.__cuda_array_interface__['data'][0] - - personalization_id_series = None - - is_symmetric = not input_graph.is_directed() - - # FIXME: Offsets and indices are currently hardcoded to int, but this may - # not be acceptable in the future. - numberTypeMap = {np.dtype("int32") : numberTypeEnum.int32Type, - np.dtype("int64") : numberTypeEnum.int64Type, - np.dtype("float32") : numberTypeEnum.floatType, - np.dtype("double") : numberTypeEnum.doubleType} - - if personalization is not None: - sz = personalization['vertex'].shape[0] - personalization['vertex'] = personalization['vertex'].astype(src.dtype) - personalization['values'] = personalization['values'].astype(df['pagerank'].dtype) - c_pers_vtx = personalization['vertex'].__cuda_array_interface__['data'][0] - c_pers_val = personalization['values'].__cuda_array_interface__['data'][0] - - cdef graph_container_t graph_container - populate_graph_container(graph_container, - handle_[0], - c_src_vertices, c_dst_vertices, c_edge_weights, - NULL, - NULL, - 0, - ((numberTypeMap[src.dtype])), - ((numberTypeMap[src.dtype])), - ((numberTypeMap[weight_t])), - num_local_edges, - num_verts, num_edges, - is_weighted, - is_symmetric, - True, - False) - - if (df['pagerank'].dtype == np.float32): - if (df['vertex'].dtype == np.int32): - call_pagerank[int, float](handle_[0], graph_container, - c_identifier, - c_pagerank_val, sz, - c_pers_vtx, c_pers_val, - alpha, tol, - max_iter, has_guess) - else: - call_pagerank[long, float](handle_[0], graph_container, - c_identifier, - c_pagerank_val, sz, - c_pers_vtx, c_pers_val, - alpha, tol, - max_iter, has_guess) - - else: - if (df['vertex'].dtype == np.int32): - call_pagerank[int, double](handle_[0], graph_container, - c_identifier, - c_pagerank_val, sz, - c_pers_vtx, c_pers_val, - alpha, tol, - max_iter, has_guess) - else: - call_pagerank[long, double](handle_[0], graph_container, - c_identifier, - c_pagerank_val, sz, - c_pers_vtx, c_pers_val, - alpha, tol, - max_iter, has_guess) - return df diff --git a/python/cugraph/cugraph/structure/graph_implementation/simpleGraph.py b/python/cugraph/cugraph/structure/graph_implementation/simpleGraph.py index 32ee82a30f6..889d7c3218e 100644 --- a/python/cugraph/cugraph/structure/graph_implementation/simpleGraph.py +++ b/python/cugraph/cugraph/structure/graph_implementation/simpleGraph.py @@ -201,7 +201,8 @@ def __from_edgelist( self._make_plc_graph( value_col=value_col, - store_transposed=store_transposed + store_transposed=store_transposed, + renumber=renumber ) def to_pandas_edgelist(self, source='src', destination='dst', @@ -763,7 +764,10 @@ def _degree(self, vertex_subset, direction=Direction.ALL): return df - def _make_plc_graph(self, value_col=None, store_transposed=False): + def _make_plc_graph(self, + value_col=None, + store_transposed=False, + renumber=True): if value_col is None: value_col = cudf.Series( cupy.ones(len(self.edgelist.edgelist_df), dtype='float32') @@ -788,7 +792,7 @@ def _make_plc_graph(self, value_col=None, store_transposed=False): dst_array=self.edgelist.edgelist_df['dst'], weight_array=value_col, store_transposed=store_transposed, - renumber=False, + renumber=renumber, do_expensive_check=False ) diff --git a/python/cugraph/cugraph/tests/mg/test_mg_pagerank.py b/python/cugraph/cugraph/tests/mg/test_mg_pagerank.py index 7ed651679fa..9bc835a2320 100644 --- a/python/cugraph/cugraph/tests/mg/test_mg_pagerank.py +++ b/python/cugraph/cugraph/tests/mg/test_mg_pagerank.py @@ -16,6 +16,7 @@ import gc import cugraph import dask_cudf +from cugraph.testing import utils import cudf # from cugraph.dask.common.mg_utils import is_single_gpu from cugraph.testing.utils import RAPIDS_DATASET_ROOT_DIR_PATH @@ -47,8 +48,20 @@ def personalize(vertices, personalization_perc): return cu_personalization, personalization +# ============================================================================= +# Parameters +# ============================================================================= PERSONALIZATION_PERC = [0, 10, 50] IS_DIRECTED = [True, False] +HAS_GUESS = [0, 1] +HAS_PRECOMPUTED = [0, 1] + + +# ============================================================================= +# Pytest Setup / Teardown - called for each test function +# ============================================================================= +def setup_function(): + gc.collect() # @pytest.mark.skipif( @@ -56,8 +69,10 @@ def personalize(vertices, personalization_perc): # ) @pytest.mark.parametrize("personalization_perc", PERSONALIZATION_PERC) @pytest.mark.parametrize("directed", IS_DIRECTED) -def test_dask_pagerank(dask_client, personalization_perc, directed): - gc.collect() +@pytest.mark.parametrize("has_precomputed_vertex_out_weight", HAS_PRECOMPUTED) +@pytest.mark.parametrize("has_guess", HAS_GUESS) +def test_dask_pagerank(dask_client, personalization_perc, directed, + has_precomputed_vertex_out_weight, has_guess): input_data_path = (RAPIDS_DATASET_ROOT_DIR_PATH / "karate.csv").as_posix() @@ -80,21 +95,41 @@ def test_dask_pagerank(dask_client, personalization_perc, directed): ) g = cugraph.Graph(directed=directed) - g.from_cudf_edgelist(df, "src", "dst") + g.from_cudf_edgelist(df, "src", "dst", "value") dg = cugraph.Graph(directed=directed) - dg.from_dask_cudf_edgelist(ddf, "src", "dst") + dg.from_dask_cudf_edgelist(ddf, "src", "dst", "value") personalization = None + pre_vtx_o_wgt = None + nstart = None + max_iter = 100 + has_precomputed_vertex_out_weight if personalization_perc != 0: personalization, p = personalize( g.nodes(), personalization_perc ) + if has_precomputed_vertex_out_weight == 1: + df = df[["src", "value"]] + pre_vtx_o_wgt = df.groupby( + ['src'], as_index=False).sum().rename( + columns={"src": "vertex", "value": "sums"}) + + if has_guess == 1: + nstart = cugraph.pagerank( + g, personalization=personalization, tol=1e-6).rename( + columns={"pagerank": "values"}) + max_iter = 20 expected_pr = cugraph.pagerank( - g, personalization=personalization, tol=1e-6 + g, personalization=personalization, + precomputed_vertex_out_weight=pre_vtx_o_wgt, + max_iter=max_iter, tol=1e-6, nstart=nstart ) - result_pr = dcg.pagerank(dg, personalization=personalization, tol=1e-6) + result_pr = dcg.pagerank( + dg, personalization=personalization, + precomputed_vertex_out_weight=pre_vtx_o_wgt, + max_iter=max_iter, tol=1e-6, nstart=nstart) result_pr = result_pr.compute() err = 0 @@ -114,3 +149,33 @@ def test_dask_pagerank(dask_client, personalization_perc, directed): if diff > tol * 1.1: err = err + 1 assert err == 0 + + +def test_pagerank_invalid_personalization_dtype(dask_client): + input_data_path = (utils.RAPIDS_DATASET_ROOT_DIR_PATH / + "karate.csv").as_posix() + + chunksize = dcg.get_chunksize(input_data_path) + ddf = dask_cudf.read_csv( + input_data_path, + chunksize=chunksize, + delimiter=" ", + names=["src", "dst", "value"], + dtype=["int32", "int32", "float32"], + ) + + dg = cugraph.Graph(directed=True) + dg.from_dask_cudf_edgelist( + ddf, source='src', destination='dst', + edge_attr="value", renumber=True) + + personalization_vec = cudf.DataFrame() + personalization_vec['vertex'] = [17, 26] + personalization_vec['values'] = [0.5, 0.75] + warning_msg = ("PageRank requires 'personalization' values to match the " + "graph's 'edge_attr' type. edge_attr type is: " + "float32 and got 'personalization' values " + "of type: float64.") + + with pytest.warns(UserWarning, match=warning_msg): + dcg.pagerank(dg, personalization=personalization_vec) diff --git a/python/cugraph/cugraph/tests/test_pagerank.py b/python/cugraph/cugraph/tests/test_pagerank.py index b9329fc09bd..f862ccf858d 100644 --- a/python/cugraph/cugraph/tests/test_pagerank.py +++ b/python/cugraph/cugraph/tests/test_pagerank.py @@ -47,7 +47,8 @@ def cudify(d): return cuD -def cugraph_call(G, max_iter, tol, alpha, personalization, nstart): +def cugraph_call(G, max_iter, tol, alpha, personalization, + nstart, pre_vtx_o_wgt): # cugraph Pagerank Call t1 = time.time() df = cugraph.pagerank( @@ -56,6 +57,7 @@ def cugraph_call(G, max_iter, tol, alpha, personalization, nstart): max_iter=max_iter, tol=tol, personalization=personalization, + precomputed_vertex_out_weight=pre_vtx_o_wgt, nstart=nstart, ) t2 = time.time() - t1 @@ -130,11 +132,22 @@ def networkx_call(Gnx, max_iter, tol, alpha, personalization_perc, nnz_vtx): return pr, personalization +# ============================================================================= +# Parameters +# ============================================================================= MAX_ITERATIONS = [500] TOLERANCE = [1.0e-06] ALPHA = [0.85] PERSONALIZATION_PERC = [0, 10, 50] HAS_GUESS = [0, 1] +HAS_PRECOMPUTED = [0, 1] + + +# ============================================================================= +# Pytest Setup / Teardown - called for each test function +# ============================================================================= +def setup_function(): + gc.collect() # FIXME: the default set of datasets includes an asymmetric directed graph @@ -145,16 +158,18 @@ def networkx_call(Gnx, max_iter, tol, alpha, personalization_perc, nnz_vtx): # https://github.com/rapidsai/cugraph/issues/533 # + @pytest.mark.parametrize("graph_file", utils.DATASETS) @pytest.mark.parametrize("max_iter", MAX_ITERATIONS) @pytest.mark.parametrize("tol", TOLERANCE) @pytest.mark.parametrize("alpha", ALPHA) @pytest.mark.parametrize("personalization_perc", PERSONALIZATION_PERC) @pytest.mark.parametrize("has_guess", HAS_GUESS) +@pytest.mark.parametrize("has_precomputed_vertex_out_weight", HAS_PRECOMPUTED) def test_pagerank( - graph_file, max_iter, tol, alpha, personalization_perc, has_guess + graph_file, max_iter, tol, alpha, personalization_perc, has_guess, + has_precomputed_vertex_out_weight ): - gc.collect() # NetworkX PageRank M = utils.read_csv_for_nx(graph_file) @@ -169,6 +184,7 @@ def test_pagerank( ) cu_nstart = None + pre_vtx_o_wgt = None if has_guess == 1: cu_nstart = cudify(networkx_pr) max_iter = 20 @@ -176,10 +192,20 @@ def test_pagerank( # cuGraph PageRank cu_M = utils.read_csv_file(graph_file) - G = cugraph.DiGraph() - G.from_cudf_edgelist(cu_M, source="0", destination="1", edge_attr="2") + G = cugraph.Graph(directed=True) + G.from_cudf_edgelist( + cu_M, source="0", destination="1", edge_attr="2", + legacy_renum_only=True) + + if has_precomputed_vertex_out_weight == 1: + df = G.view_edge_list()[["src", "weights"]] + pre_vtx_o_wgt = df.groupby( + ['src'], as_index=False).sum().rename( + columns={"src": "vertex", "weights": "sums"}) - cugraph_pr = cugraph_call(G, max_iter, tol, alpha, cu_prsn, cu_nstart) + cugraph_pr = cugraph_call( + G, max_iter, tol, alpha, cu_prsn, cu_nstart, + pre_vtx_o_wgt) # Calculating mismatch networkx_pr = sorted(networkx_pr.items(), key=lambda x: x[0]) @@ -204,7 +230,6 @@ def test_pagerank( def test_pagerank_nx( graph_file, max_iter, tol, alpha, personalization_perc, has_guess ): - gc.collect() # NetworkX PageRank M = utils.read_csv_for_nx(graph_file) @@ -249,10 +274,11 @@ def test_pagerank_nx( @pytest.mark.parametrize("alpha", ALPHA) @pytest.mark.parametrize("personalization_perc", PERSONALIZATION_PERC) @pytest.mark.parametrize("has_guess", HAS_GUESS) +@pytest.mark.parametrize("has_precomputed_vertex_out_weight", HAS_PRECOMPUTED) def test_pagerank_multi_column( - graph_file, max_iter, tol, alpha, personalization_perc, has_guess + graph_file, max_iter, tol, alpha, personalization_perc, has_guess, + has_precomputed_vertex_out_weight ): - gc.collect() # NetworkX PageRank M = utils.read_csv_for_nx(graph_file) @@ -268,6 +294,7 @@ def test_pagerank_multi_column( ) cu_nstart = None + pre_vtx_o_wgt = None if has_guess == 1: cu_nstart_temp = cudify(networkx_pr) max_iter = 100 @@ -292,11 +319,17 @@ def test_pagerank_multi_column( cu_M["dst_1"] = cu_M["dst_0"] + 1000 cu_M["weights"] = cudf.Series(M["weight"]) - cu_G = cugraph.DiGraph() + cu_G = cugraph.Graph(directed=True) cu_G.from_cudf_edgelist(cu_M, source=["src_0", "src_1"], destination=["dst_0", "dst_1"], edge_attr="weights") + if has_precomputed_vertex_out_weight == 1: + df = cu_M[["src_0", "src_1", "weights"]] + pre_vtx_o_wgt = df.groupby( + ['src_0', "src_1"], as_index=False).sum().rename( + columns={"weights": "sums"}) + df = cugraph.pagerank( cu_G, alpha=alpha, @@ -304,6 +337,7 @@ def test_pagerank_multi_column( tol=tol, personalization=cu_prsn, nstart=cu_nstart, + precomputed_vertex_out_weight=pre_vtx_o_wgt ) cugraph_pr = [] @@ -326,3 +360,29 @@ def test_pagerank_multi_column( err = err + 1 print("Mismatches:", err) assert err < (0.01 * len(cugraph_pr)) + + +def test_pagerank_invalid_personalization_dtype(): + input_data_path = (utils.RAPIDS_DATASET_ROOT_DIR_PATH / + "karate.csv").as_posix() + M = utils.read_csv_for_nx(input_data_path) + G = cugraph.Graph(directed=True) + cu_M = cudf.DataFrame() + cu_M["src"] = cudf.Series(M["0"]) + cu_M["dst"] = cudf.Series(M["1"]) + + cu_M["weights"] = cudf.Series(M["weight"]) + G.from_cudf_edgelist( + cu_M, source="src", destination="dst", edge_attr="weights" + ) + + personalization_vec = cudf.DataFrame() + personalization_vec['vertex'] = [17, 26] + personalization_vec['values'] = [0.5, 0.75] + warning_msg = ("PageRank requires 'personalization' values to match the " + "graph's 'edge_attr' type. edge_attr type is: " + "float32 and got 'personalization' values " + "of type: float64.") + + with pytest.warns(UserWarning, match=warning_msg): + cugraph.pagerank(G, personalization=personalization_vec) diff --git a/python/cugraph/cugraph/tests/test_paths.py b/python/cugraph/cugraph/tests/test_paths.py index 56cc9b3cd50..7aaa1146d8b 100644 --- a/python/cugraph/cugraph/tests/test_paths.py +++ b/python/cugraph/cugraph/tests/test_paths.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2021, NVIDIA CORPORATION. +# Copyright (c) 2019-2022, NVIDIA CORPORATION. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -13,6 +13,7 @@ import sys from tempfile import NamedTemporaryFile +import math import cudf from cupy.sparse import coo_matrix as cupy_coo_matrix @@ -153,9 +154,14 @@ def test_shortest_path_length_invalid_vertexes(graphs): def test_shortest_path_length_no_path(graphs): cugraph_G, nx_G, cupy_df = graphs + # FIXME: In case there is no path between two vertices, the + # result can be either the max of float32 or float64 + max_float_32 = (2 - math.pow(2, -23))*math.pow(2, 127) + path_1_to_8 = cugraph.shortest_path_length(cugraph_G, 1, 8) assert path_1_to_8 == sys.float_info.max - assert path_1_to_8 == cugraph.shortest_path_length(nx_G, "1", "8") + assert cugraph.shortest_path_length(nx_G, "1", "8") in \ + [max_float_32, path_1_to_8] assert path_1_to_8 == cugraph.shortest_path_length(cupy_df, 1, 8) diff --git a/python/cugraph/cugraph/utilities/nx_factory.py b/python/cugraph/cugraph/utilities/nx_factory.py index afbb0dbab2c..c491d63241c 100644 --- a/python/cugraph/cugraph/utilities/nx_factory.py +++ b/python/cugraph/cugraph/utilities/nx_factory.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2021, NVIDIA CORPORATION. +# Copyright (c) 2020-2022, NVIDIA CORPORATION. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -52,6 +52,9 @@ def convert_weighted_named_to_gdf(NX_G, weight): _gdf['dst'] = dst _gdf['weight'] = wt + # FIXME: The weight dtype is hardcoded. + _gdf = _gdf.astype({'weight': 'float32'}) + return _gdf diff --git a/python/pylibcugraph/pylibcugraph/CMakeLists.txt b/python/pylibcugraph/pylibcugraph/CMakeLists.txt index c5ae32a0b2a..b8c76173750 100644 --- a/python/pylibcugraph/pylibcugraph/CMakeLists.txt +++ b/python/pylibcugraph/pylibcugraph/CMakeLists.txt @@ -23,6 +23,7 @@ set(cython_sources katz_centrality.pyx node2vec.pyx pagerank.pyx + personalized_pagerank.pyx resource_handle.pyx sssp.pyx triangle_count.pyx diff --git a/python/pylibcugraph/pylibcugraph/__init__.py b/python/pylibcugraph/pylibcugraph/__init__.py index 7d604bf2dbb..3dc53352eab 100644 --- a/python/pylibcugraph/pylibcugraph/__init__.py +++ b/python/pylibcugraph/pylibcugraph/__init__.py @@ -33,6 +33,8 @@ from pylibcugraph.pagerank import pagerank +from pylibcugraph.personalized_pagerank import personalized_pagerank + from pylibcugraph.sssp import sssp from pylibcugraph.hits import hits diff --git a/python/pylibcugraph/pylibcugraph/_cugraph_c/centrality_algorithms.pxd b/python/pylibcugraph/pylibcugraph/_cugraph_c/centrality_algorithms.pxd index 3aede21176c..8e8b1c8e923 100644 --- a/python/pylibcugraph/pylibcugraph/_cugraph_c/centrality_algorithms.pxd +++ b/python/pylibcugraph/pylibcugraph/_cugraph_c/centrality_algorithms.pxd @@ -56,11 +56,13 @@ cdef extern from "cugraph_c/centrality_algorithms.h": cugraph_pagerank( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_vertices, const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_sums, + const cugraph_type_erased_device_array_view_t* initial_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check, cugraph_centrality_result_t** result, cugraph_error_t** error @@ -70,13 +72,15 @@ cdef extern from "cugraph_c/centrality_algorithms.h": cugraph_personalized_pagerank( const cugraph_resource_handle_t* handle, cugraph_graph_t* graph, + const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_vertices, const cugraph_type_erased_device_array_view_t* precomputed_vertex_out_weight_sums, - cugraph_type_erased_device_array_view_t* personalization_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_vertices, + const cugraph_type_erased_device_array_view_t* initial_guess_values, + const cugraph_type_erased_device_array_view_t* personalization_vertices, const cugraph_type_erased_device_array_view_t* personalization_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check, cugraph_centrality_result_t** result, cugraph_error_t** error diff --git a/python/pylibcugraph/pylibcugraph/pagerank.pyx b/python/pylibcugraph/pylibcugraph/pagerank.pyx index 17ac5876591..1a5d1a49801 100644 --- a/python/pylibcugraph/pylibcugraph/pagerank.pyx +++ b/python/pylibcugraph/pylibcugraph/pagerank.pyx @@ -14,6 +14,8 @@ # Have cython use python 3 syntax # cython: language_level = 3 +from libc.stdint cimport uintptr_t + from pylibcugraph._cugraph_c.resource_handle cimport ( bool_t, data_type_id_t, @@ -25,6 +27,7 @@ from pylibcugraph._cugraph_c.error cimport ( ) from pylibcugraph._cugraph_c.array cimport ( cugraph_type_erased_device_array_view_t, + cugraph_type_erased_device_array_view_create, ) from pylibcugraph._cugraph_c.graph cimport ( cugraph_graph_t, @@ -46,16 +49,20 @@ from pylibcugraph.utils cimport ( assert_success, assert_CAI_type, copy_to_cupy_array, + get_c_type_from_numpy_type, + create_cugraph_type_erased_device_array_view_from_py_obj, ) def pagerank(ResourceHandle resource_handle, _GPUGraph graph, + precomputed_vertex_out_weight_vertices, precomputed_vertex_out_weight_sums, + initial_guess_vertices, + initial_guess_values, double alpha, double epsilon, size_t max_iterations, - bool_t has_initial_guess, bool_t do_expensive_check): """ Find the PageRank score for every vertex in a graph by computing an @@ -70,20 +77,32 @@ def pagerank(ResourceHandle resource_handle, Handle to the underlying device resources needed for referencing data and running algorithms. - graph : SGGraph + graph : SGGraph or MGGraph The input graph. - precomputed_vertex_out_weight_sums : None - This parameter is unsupported in this release and only None is - accepted. + precomputed_vertex_out_weight_vertices: device array type + Subset of vertices of graph for precomputed_vertex_out_weight + (a performance optimization) + + precomputed_vertex_out_weight_sums : device array type + Corresponding precomputed sum of outgoing vertices weight + (a performance optimization) + + initial_guess_vertices : device array type + Subset of vertices of graph for initial guess for pagerank values + (a performance optimization) - alpha : float + initial_guess_values : device array type + Pagerank values for vertices + (a performance optimization) + + alpha : double The damping factor alpha represents the probability to follow an outgoing edge, standard value is 0.85. Thus, 1.0-alpha is the probability to “teleport” to a random vertex. Alpha should be greater than 0.0 and strictly lower than 1.0. - epsilon : float + epsilon : double Set the tolerance the approximation, this parameter should be a small magnitude value. The lower the tolerance the better the approximation. If this value is @@ -92,18 +111,14 @@ def pagerank(ResourceHandle resource_handle, numerical roundoff. Usually values between 0.01 and 0.00001 are acceptable. - max_iterations : int + max_iterations : size_t The maximum number of iterations before an answer is returned. This can be used to limit the execution time and do an early exit before the solver reaches the convergence tolerance. If this value is lower or equal to 0 cuGraph will use the default value, which is 100. - has_initial_guess : bool - This parameter is unsupported in this release and only False is - accepted. - - do_expensive_check : bool + do_expensive_check : bool_t If True, performs more extensive tests on the inputs to ensure validitity, at the expense of increased run time. @@ -128,9 +143,8 @@ def pagerank(ResourceHandle resource_handle, ... resource_handle, graph_props, srcs, dsts, weights, ... store_transposed=True, renumber=False, do_expensive_check=False) >>> (vertices, pageranks) = pylibcugraph.pagerank( - ... resource_handle, G, None, alpha=0.85, epsilon=1.0e-6, - ... max_iterations=500, has_initial_guess=False, - ... do_expensive_check=False) + ... resource_handle, G, None, None, None, None, alpha=0.85, + ... epsilon=1.0e-6, max_iterations=500, do_expensive_check=False) >>> vertices array([0, 1, 2, 3], dtype=int32) >>> pageranks @@ -151,23 +165,31 @@ def pagerank(ResourceHandle resource_handle, raise RuntimeError("pagerank requires the numpy package, which could " "not be imported") - if has_initial_guess is True: - raise ValueError("has_initial_guess must be False for the current " - "release.") + cdef cugraph_type_erased_device_array_view_t* \ + initial_guess_vertices_view_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + initial_guess_vertices) - assert_CAI_type(precomputed_vertex_out_weight_sums, - "precomputed_vertex_out_weight_sums", - allow_None=True) - # FIXME: assert that precomputed_vertex_out_weight_sums type == weight type + cdef cugraph_type_erased_device_array_view_t* \ + initial_guess_values_view_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + initial_guess_values) cdef cugraph_resource_handle_t* c_resource_handle_ptr = \ resource_handle.c_resource_handle_ptr cdef cugraph_graph_t* c_graph_ptr = graph.c_graph_ptr + + cdef cugraph_type_erased_device_array_view_t* \ + precomputed_vertex_out_weight_vertices_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + precomputed_vertex_out_weight_vertices) + + # FIXME: assert that precomputed_vertex_out_weight_sums + # type == weight type cdef cugraph_type_erased_device_array_view_t* \ - precomputed_vertex_out_weight_sums_ptr = NULL - if precomputed_vertex_out_weight_sums: - raise NotImplementedError("None is temporarily the only supported " - "value for precomputed_vertex_out_weight_sums") + precomputed_vertex_out_weight_sums_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + precomputed_vertex_out_weight_sums) cdef cugraph_centrality_result_t* result_ptr cdef cugraph_error_code_t error_code @@ -175,11 +197,13 @@ def pagerank(ResourceHandle resource_handle, error_code = cugraph_pagerank(c_resource_handle_ptr, c_graph_ptr, + precomputed_vertex_out_weight_vertices_ptr, precomputed_vertex_out_weight_sums_ptr, + initial_guess_vertices_view_ptr, + initial_guess_values_view_ptr, alpha, epsilon, max_iterations, - has_initial_guess, do_expensive_check, &result_ptr, &error_ptr) diff --git a/python/pylibcugraph/pylibcugraph/personalized_pagerank.pyx b/python/pylibcugraph/pylibcugraph/personalized_pagerank.pyx new file mode 100644 index 00000000000..7ada3dd8538 --- /dev/null +++ b/python/pylibcugraph/pylibcugraph/personalized_pagerank.pyx @@ -0,0 +1,252 @@ +# Copyright (c) 2022, NVIDIA CORPORATION. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Have cython use python 3 syntax +# cython: language_level = 3 + +from libc.stdint cimport uintptr_t + +from pylibcugraph._cugraph_c.resource_handle cimport ( + bool_t, + data_type_id_t, + cugraph_resource_handle_t, +) +from pylibcugraph._cugraph_c.error cimport ( + cugraph_error_code_t, + cugraph_error_t, +) +from pylibcugraph._cugraph_c.array cimport ( + cugraph_type_erased_device_array_view_t, + cugraph_type_erased_device_array_view_create, +) +from pylibcugraph._cugraph_c.graph cimport ( + cugraph_graph_t, +) +from pylibcugraph._cugraph_c.centrality_algorithms cimport ( + cugraph_centrality_result_t, + cugraph_personalized_pagerank, + cugraph_centrality_result_get_vertices, + cugraph_centrality_result_get_values, + cugraph_centrality_result_free, +) +from pylibcugraph.resource_handle cimport ( + ResourceHandle, +) +from pylibcugraph.graphs cimport ( + _GPUGraph, +) +from pylibcugraph.utils cimport ( + assert_success, + assert_CAI_type, + copy_to_cupy_array, + get_c_type_from_numpy_type, + create_cugraph_type_erased_device_array_view_from_py_obj, +) + + +def personalized_pagerank(ResourceHandle resource_handle, + _GPUGraph graph, + precomputed_vertex_out_weight_vertices, + precomputed_vertex_out_weight_sums, + initial_guess_vertices, + initial_guess_values, + personalization_vertices, + personalization_values, + double alpha, + double epsilon, + size_t max_iterations, + bool_t do_expensive_check): + """ + Find the PageRank score for every vertex in a graph by computing an + approximation of the Pagerank eigenvector using the power method. The + number of iterations depends on the properties of the network itself; it + increases when the tolerance descreases and/or alpha increases toward the + limiting value of 1. + + Parameters + ---------- + resource_handle : ResourceHandle + Handle to the underlying device resources needed for referencing data + and running algorithms. + + graph : SGGraph or MGGraph + The input graph. + + precomputed_vertex_out_weight_vertices: device array type + Subset of vertices of graph for precomputed_vertex_out_weight + (a performance optimization) + + precomputed_vertex_out_weight_sums : device array type + Corresponding precomputed sum of outgoing vertices weight + (a performance optimization) + + initial_guess_vertices : device array type + Subset of vertices of graph for initial guess for pagerank values + (a performance optimization) + + initial_guess_values : device array type + Pagerank values for vertices + (a performance optimization) + + personalization_vertices : device array type + Subset of vertices of graph for personalization + (a performance optimization) + + personalization_values : device array type + Personalization values for vertices + (a performance optimization) + + alpha : double + The damping factor alpha represents the probability to follow an + outgoing edge, standard value is 0.85. + Thus, 1.0-alpha is the probability to “teleport” to a random vertex. + Alpha should be greater than 0.0 and strictly lower than 1.0. + + epsilon : double + Set the tolerance the approximation, this parameter should be a small + magnitude value. + The lower the tolerance the better the approximation. If this value is + 0.0f, cuGraph will use the default value which is 1.0E-5. + Setting too small a tolerance can lead to non-convergence due to + numerical roundoff. Usually values between 0.01 and 0.00001 are + acceptable. + + max_iterations : size_t + The maximum number of iterations before an answer is returned. This can + be used to limit the execution time and do an early exit before the + solver reaches the convergence tolerance. + If this value is lower or equal to 0 cuGraph will use the default + value, which is 100. + + do_expensive_check : bool_t + If True, performs more extensive tests on the inputs to ensure + validitity, at the expense of increased run time. + + Returns + ------- + A tuple of device arrays, where the first item in the tuple is a device + array containing the vertex identifiers, and the second item is a device + array containing the pagerank values for the corresponding vertices. For + example, the vertex identifier at the ith element of the vertex array has + the pagerank value of the ith element in the pagerank array. + + Examples + -------- + >>> import pylibcugraph, cupy, numpy + >>> srcs = cupy.asarray([0, 1, 2], dtype=numpy.int32) + >>> dsts = cupy.asarray([1, 2, 3], dtype=numpy.int32) + >>> weights = cupy.asarray([1.0, 1.0, 1.0], dtype=numpy.float32) + >>> personalization_vertices = cupy.asarray([0, 2], dtype=numpy.int32) + >>> personalization_values = cupy.asarray( + ... [0.008309, 0.991691], dtype=numpy.float32) + >>> resource_handle = pylibcugraph.ResourceHandle() + >>> graph_props = pylibcugraph.GraphProperties( + ... is_symmetric=False, is_multigraph=False) + >>> G = pylibcugraph.SGGraph( + ... resource_handle, graph_props, srcs, dsts, weights, + ... store_transposed=True, renumber=False, do_expensive_check=False) + >>> (vertices, pageranks) = pylibcugraph.personalized_pagerank( + ... resource_handle, G, None, None, None, None, alpha=0.85, + ... personalization_vertices=personalization_vertices, + ... personalization_values=personalization_values, epsilon=1.0e-6, + ... max_iterations=500, + ... do_expensive_check=False) + >>> vertices + array([0, 1, 2, 3], dtype=int32) + >>> pageranks + array([0.00446455, 0.00379487, 0.53607565, 0.45566472 ], dtype=float32) + """ + + # FIXME: import these modules here for now until a better pattern can be + # used for optional imports (perhaps 'import_optional()' from cugraph), or + # these are made hard dependencies. + try: + import cupy + except ModuleNotFoundError: + raise RuntimeError("pagerank requires the cupy package, which could " + "not be imported") + try: + import numpy + except ModuleNotFoundError: + raise RuntimeError("pagerank requires the numpy package, which could " + "not be imported") + + cdef cugraph_type_erased_device_array_view_t* \ + initial_guess_vertices_view_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + initial_guess_vertices) + + cdef cugraph_type_erased_device_array_view_t* \ + initial_guess_values_view_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + initial_guess_values) + + cdef cugraph_resource_handle_t* c_resource_handle_ptr = \ + resource_handle.c_resource_handle_ptr + cdef cugraph_graph_t* c_graph_ptr = graph.c_graph_ptr + + cdef cugraph_type_erased_device_array_view_t* \ + precomputed_vertex_out_weight_vertices_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + precomputed_vertex_out_weight_vertices) + + # FIXME: assert that precomputed_vertex_out_weight_sums + # type == weight type + cdef cugraph_type_erased_device_array_view_t* \ + precomputed_vertex_out_weight_sums_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + precomputed_vertex_out_weight_sums) + + cdef cugraph_type_erased_device_array_view_t* \ + personalization_vertices_view_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + personalization_vertices) + + cdef cugraph_type_erased_device_array_view_t* \ + personalization_values_view_ptr = \ + create_cugraph_type_erased_device_array_view_from_py_obj( + personalization_values) + + cdef cugraph_centrality_result_t* result_ptr + cdef cugraph_error_code_t error_code + cdef cugraph_error_t* error_ptr + + error_code = cugraph_personalized_pagerank(c_resource_handle_ptr, + c_graph_ptr, + precomputed_vertex_out_weight_vertices_ptr, + precomputed_vertex_out_weight_sums_ptr, + initial_guess_vertices_view_ptr, + initial_guess_values_view_ptr, + personalization_vertices_view_ptr, + personalization_values_view_ptr, + alpha, + epsilon, + max_iterations, + do_expensive_check, + &result_ptr, + &error_ptr) + assert_success(error_code, error_ptr, "cugraph_personalized_pagerank") + + # Extract individual device array pointers from result and copy to cupy + # arrays for returning. + cdef cugraph_type_erased_device_array_view_t* vertices_ptr = \ + cugraph_centrality_result_get_vertices(result_ptr) + cdef cugraph_type_erased_device_array_view_t* pageranks_ptr = \ + cugraph_centrality_result_get_values(result_ptr) + + cupy_vertices = copy_to_cupy_array(c_resource_handle_ptr, vertices_ptr) + cupy_pageranks = copy_to_cupy_array(c_resource_handle_ptr, pageranks_ptr) + + cugraph_centrality_result_free(result_ptr) + + return (cupy_vertices, cupy_pageranks) diff --git a/python/pylibcugraph/pylibcugraph/tests/test_graph_sg.py b/python/pylibcugraph/pylibcugraph/tests/test_graph_sg.py index 4b3adb51233..b387e8cf58d 100644 --- a/python/pylibcugraph/pylibcugraph/tests/test_graph_sg.py +++ b/python/pylibcugraph/pylibcugraph/tests/test_graph_sg.py @@ -93,7 +93,7 @@ def test_sg_graph(graph_data): del g else: - with pytest.raises(RuntimeError): + with pytest.raises(ValueError): SGGraph(resource_handle, graph_props, device_srcs, diff --git a/python/pylibcugraph/pylibcugraph/tests/test_pagerank.py b/python/pylibcugraph/pylibcugraph/tests/test_pagerank.py index aa61c607f3c..9c15101cb9d 100644 --- a/python/pylibcugraph/pylibcugraph/tests/test_pagerank.py +++ b/python/pylibcugraph/pylibcugraph/tests/test_pagerank.py @@ -95,16 +95,21 @@ def test_pagerank(sg_transposed_graph_objs): (expected_verts, expected_pageranks) = _test_data[ds_name] precomputed_vertex_out_weight_sums = None - has_initial_guess = False do_expensive_check = False + precomputed_vertex_out_weight_vertices = None + precomputed_vertex_out_weight_sums = None + initial_guess_vertices = None + initial_guess_values = None result = pagerank(resource_handle, g, + precomputed_vertex_out_weight_vertices, precomputed_vertex_out_weight_sums, + initial_guess_vertices, + initial_guess_values, _alpha, _epsilon, _max_iterations, - has_initial_guess, do_expensive_check) num_expected_verts = len(expected_verts) diff --git a/python/pylibcugraph/pylibcugraph/utils.pxd b/python/pylibcugraph/pylibcugraph/utils.pxd index 83f534c297e..5b461d93e76 100644 --- a/python/pylibcugraph/pylibcugraph/utils.pxd +++ b/python/pylibcugraph/pylibcugraph/utils.pxd @@ -50,3 +50,6 @@ cdef copy_to_cupy_array( cdef copy_to_cupy_array_ids( cugraph_resource_handle_t* c_resource_handle_ptr, cugraph_type_erased_device_array_view_t* device_array_view_ptr) + +cdef cugraph_type_erased_device_array_view_t* \ + create_cugraph_type_erased_device_array_view_from_py_obj(python_obj) diff --git a/python/pylibcugraph/pylibcugraph/utils.pyx b/python/pylibcugraph/pylibcugraph/utils.pyx index 8ae9e680c5d..962c50fc29c 100644 --- a/python/pylibcugraph/pylibcugraph/utils.pyx +++ b/python/pylibcugraph/pylibcugraph/utils.pyx @@ -27,28 +27,59 @@ from pylibcugraph._cugraph_c.array cimport ( cugraph_type_erased_device_array_view_free, ) +from pylibcugraph._cugraph_c.error cimport ( + cugraph_error_message, + cugraph_error_free +) + # FIXME: add tests for this cdef assert_success(cugraph_error_code_t code, cugraph_error_t* err, api_name): if code != cugraph_error_code_t.CUGRAPH_SUCCESS: + c_error = cugraph_error_message(err) + if isinstance(c_error, bytes): + c_error = c_error.decode() + else: + c_error = str(c_error) + + cugraph_error_free(err) + if code == cugraph_error_code_t.CUGRAPH_UNKNOWN_ERROR: code_str = "CUGRAPH_UNKNOWN_ERROR" + error_msg = f"non-success value returned from {api_name}: {code_str} "\ + f"{c_error}" + raise RuntimeError(error_msg) elif code == cugraph_error_code_t.CUGRAPH_INVALID_HANDLE: code_str = "CUGRAPH_INVALID_HANDLE" + error_msg = f"non-success value returned from {api_name}: {code_str} "\ + f"{c_error}" + raise ValueError(error_msg) elif code == cugraph_error_code_t.CUGRAPH_ALLOC_ERROR: code_str = "CUGRAPH_ALLOC_ERROR" + error_msg = f"non-success value returned from {api_name}: {code_str} "\ + f"{c_error}" + raise MemoryError(error_msg) elif code == cugraph_error_code_t.CUGRAPH_INVALID_INPUT: code_str = "CUGRAPH_INVALID_INPUT" + error_msg = f"non-success value returned from {api_name}: {code_str} "\ + f"{c_error}" + raise ValueError(error_msg) elif code == cugraph_error_code_t.CUGRAPH_NOT_IMPLEMENTED: code_str = "CUGRAPH_NOT_IMPLEMENTED" + error_msg = f"non-success value returned from {api_name}: {code_str}\ "\ + f"{c_error}" + raise NotImplementedError(error_msg) elif code == cugraph_error_code_t.CUGRAPH_UNSUPPORTED_TYPE_COMBINATION: code_str = "CUGRAPH_UNSUPPORTED_TYPE_COMBINATION" + error_msg = f"non-success value returned from {api_name}: {code_str} "\ + f"{c_error}" + raise ValueError(error_msg) else: code_str = "unknown error code" - # FIXME: extract message using cugraph_error_message() - # FIXME: If error_ptr has a value, free it using cugraph_error_free() - raise RuntimeError(f"non-success value returned from {api_name}: {code_str}") + error_msg = f"non-success value returned from {api_name}: {code_str} "\ + f"{c_error}" + raise RuntimeError(error_msg) cdef assert_CAI_type(obj, var_name, allow_None=False): @@ -188,3 +219,16 @@ cdef copy_to_cupy_array_ids( return cupy_array +cdef cugraph_type_erased_device_array_view_t* \ + create_cugraph_type_erased_device_array_view_from_py_obj(python_obj): + cdef uintptr_t cai_ptr = NULL + cdef cugraph_type_erased_device_array_view_t* view_ptr = NULL + if python_obj is not None: + cai_ptr = python_obj.__cuda_array_interface__["data"][0] + view_ptr = cugraph_type_erased_device_array_view_create( + cai_ptr, + len(python_obj), + get_c_type_from_numpy_type(python_obj.dtype)) + + return view_ptr + From e70fc6f595393fbf6f80fdb12ef2d90b00a97fbd Mon Sep 17 00:00:00 2001 From: Ralph Liu <106174412+oorliu@users.noreply.github.com> Date: Thu, 4 Aug 2022 14:39:19 -0400 Subject: [PATCH 17/19] Update `k_core.py` to Check for Graph Direction (#2507) closes #2505 `cugraph.k_core()` will check if the input graph is directed or undirected. If directed, the method will raise a `ValueError`, specifying that the input must be an undirected instance. Authors: - Ralph Liu (https://github.com/oorliu) Approvers: - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2507 --- python/cugraph/cugraph/cores/k_core.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/cugraph/cugraph/cores/k_core.py b/python/cugraph/cugraph/cores/k_core.py index d17076d0f50..4f1ad0f16fb 100644 --- a/python/cugraph/cugraph/cores/k_core.py +++ b/python/cugraph/cugraph/cores/k_core.py @@ -19,7 +19,6 @@ from cugraph.utilities import (ensure_cugraph_obj_for_nx, cugraph_to_nx, ) -from cugraph.structure.graph_classes import Graph def _call_plc_core_number(G): @@ -85,8 +84,8 @@ def k_core(G, k=None, core_number=None): mytype = type(G) KCoreGraph = mytype() - if mytype is not Graph: - raise Exception("directed graph not supported") + if G.is_directed(): + raise ValueError("G must be an undirected Graph instance") if core_number is not None: if G.renumbered is True: From f8e4aaa267b447c53b086f9d9004dc3d3ab928f1 Mon Sep 17 00:00:00 2001 From: GALI PREM SAGAR Date: Thu, 4 Aug 2022 14:39:43 -0500 Subject: [PATCH 18/19] Defer loading of `custom.js` (#2506) This PR switches the loading of `custom.js` to defer because we will need the entire page to be loading until the methods in this script can even execute correctly. xref: https://github.com/rapidsai/cudf/pull/11465 Authors: - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - AJ Schmidt (https://github.com/ajschmidt8) - Rick Ratzel (https://github.com/rlratzel) URL: https://github.com/rapidsai/cugraph/pull/2506 --- docs/cugraph/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cugraph/source/conf.py b/docs/cugraph/source/conf.py index 99f789c948f..ac4ab5e5d09 100644 --- a/docs/cugraph/source/conf.py +++ b/docs/cugraph/source/conf.py @@ -213,7 +213,7 @@ def setup(app): app.add_css_file("https://docs.rapids.ai/assets/css/custom.css") - app.add_js_file("https://docs.rapids.ai/assets/js/custom.js") + app.add_js_file("https://docs.rapids.ai/assets/js/custom.js", loading_method="defer") app.add_css_file("references.css") From f24b06683d21ad2312d6355cda8cced472f6efc1 Mon Sep 17 00:00:00 2001 From: AJ Schmidt Date: Mon, 1 Aug 2022 11:11:43 -0400 Subject: [PATCH 19/19] fix versions for branch-22.10 --- ci/release/update-version.sh | 2 ++ fetch_rapids.cmake | 2 +- python/cugraph/CMakeLists.txt | 2 +- python/pylibcugraph/CMakeLists.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ci/release/update-version.sh b/ci/release/update-version.sh index 9aec4bd7a25..992303b9f6a 100755 --- a/ci/release/update-version.sh +++ b/ci/release/update-version.sh @@ -47,6 +47,8 @@ sed_runner 's/'"CUGRAPH VERSION .* LANGUAGES C CXX CUDA)"'/'"CUGRAPH VERSION ${N sed_runner 's|'"branch-.*/RAPIDS.cmake"'|'"branch-${NEXT_SHORT_TAG}/RAPIDS.cmake"'|g' cpp/CMakeLists.txt sed_runner 's/'"CUGRAPH_ETL VERSION .* LANGUAGES C CXX CUDA)"'/'"CUGRAPH_ETL VERSION ${NEXT_FULL_TAG} LANGUAGES C CXX CUDA)"'/g' cpp/libcugraph_etl/CMakeLists.txt sed_runner 's|'"branch-.*/RAPIDS.cmake"'|'"branch-${NEXT_SHORT_TAG}/RAPIDS.cmake"'|g' cpp/libcugraph_etl/CMakeLists.txt +sed_runner "s/set(pylibcugraph_version .*)/set(pylibcugraph_version ${NEXT_FULL_TAG})/g" python/pylibcugraph/CMakeLists.txt +sed_runner "s/set(cugraph_version .*)/set(cugraph_version ${NEXT_FULL_TAG})/g" python/cugraph/CMakeLists.txt # RTD update sed_runner 's/version = .*/version = '"'${NEXT_SHORT_TAG}'"'/g' docs/cugraph/source/conf.py diff --git a/fetch_rapids.cmake b/fetch_rapids.cmake index 2b5c7e9d352..ba003800773 100644 --- a/fetch_rapids.cmake +++ b/fetch_rapids.cmake @@ -11,7 +11,7 @@ # or implied. See the License for the specific language governing permissions and limitations under # the License. # ============================================================================= -file(DOWNLOAD https://raw.githubusercontent.com/rapidsai/rapids-cmake/branch-22.08/RAPIDS.cmake +file(DOWNLOAD https://raw.githubusercontent.com/rapidsai/rapids-cmake/branch-22.10/RAPIDS.cmake ${CMAKE_BINARY_DIR}/RAPIDS.cmake ) include(${CMAKE_BINARY_DIR}/RAPIDS.cmake) diff --git a/python/cugraph/CMakeLists.txt b/python/cugraph/CMakeLists.txt index 540b8d6d2af..e6254a39d04 100644 --- a/python/cugraph/CMakeLists.txt +++ b/python/cugraph/CMakeLists.txt @@ -14,7 +14,7 @@ cmake_minimum_required(VERSION 3.20.1 FATAL_ERROR) -set(cugraph_version 22.08.00) +set(cugraph_version 22.10.00) include(../../fetch_rapids.cmake) diff --git a/python/pylibcugraph/CMakeLists.txt b/python/pylibcugraph/CMakeLists.txt index 9126536e472..ef5b4043379 100644 --- a/python/pylibcugraph/CMakeLists.txt +++ b/python/pylibcugraph/CMakeLists.txt @@ -14,7 +14,7 @@ cmake_minimum_required(VERSION 3.20.1 FATAL_ERROR) -set(pylibcugraph_version 22.08.00) +set(pylibcugraph_version 22.10.00) include(../../fetch_rapids.cmake)