diff --git a/.github/workflows/ci-matrix.yml b/.github/workflows/ci-matrix.yml index 3055245c8..089f09a18 100644 --- a/.github/workflows/ci-matrix.yml +++ b/.github/workflows/ci-matrix.yml @@ -148,6 +148,15 @@ jobs: if: matrix.os == 'windows' uses: ilammy/msvc-dev-cmd@v1 + - name: Run clang-format check (Linux) + if: matrix.os == 'linux' + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/llvm-snapshot.asc + echo "deb http://apt.llvm.org/noble llvm-toolchain-noble-18 main" | sudo tee /etc/apt/sources.list.d/llvm18.list + sudo apt-get update + sudo apt-get install -y clang-format-18 + ./check_code.sh + - name: Build and test (Windows) if: matrix.os == 'windows' shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5a2d3d5b..e7c97ae05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -449,9 +449,9 @@ jobs: - name: Install prerequisites run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/llvm-snapshot.asc - echo "deb http://apt.llvm.org/noble llvm-toolchain-noble-17 main" | sudo tee /etc/apt/sources.list.d/llvm17.list + echo "deb http://apt.llvm.org/noble llvm-toolchain-noble-18 main" | sudo tee /etc/apt/sources.list.d/llvm18.list sudo apt-get update - sudo apt-get install clang-format-17 python3-yaml + sudo apt-get install clang-format-18 python3-yaml - name: Checkout networkit uses: actions/checkout@v4 with: @@ -460,5 +460,3 @@ jobs: run: | set -e ./check_code.sh -v - env: - NETWORKIT_OVERRIDE_CLANG_FORMAT: "clang-format-17" diff --git a/extrafiles/tooling/nktooling/__init__.py b/extrafiles/tooling/nktooling/__init__.py index b9eb7a62f..969f128f4 100644 --- a/extrafiles/tooling/nktooling/__init__.py +++ b/extrafiles/tooling/nktooling/__init__.py @@ -7,7 +7,7 @@ READONLY = True VERBOSE = False SHOW_DIFFS = True -CLANG_FORMAT_VERSION = 17 +CLANG_FORMAT_VERSION = 18 def setup(args = None): """ diff --git a/include/networkit/centrality/PageRank.hpp b/include/networkit/centrality/PageRank.hpp index 80a8dd513..04c3c6699 100644 --- a/include/networkit/centrality/PageRank.hpp +++ b/include/networkit/centrality/PageRank.hpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include @@ -31,6 +33,13 @@ namespace NetworKit { * the PageRank values from the size of the input graph. To enable this, set the matching parameter * to true. Note that sink-node handling is automatically activated if normalization is used. * + * Personalized PageRank (also known as "random walk with restart" or topic-sensitive PageRank) is + * supported by passing a non-empty teleportation distribution to the constructor. The math is + * identical to standard PageRank except that the teleportation vector @f$ p @f$ (originally + * @f$ 1/n @f$) is taken from the user instead of being uniform. Dangling-node mass is also + * distributed according to @f$ p @f$. See "The PageRank citation ranking: Bringing order to the + * web." by L. Brin et al. (1999) and "Topic-sensitive PageRank" by Haveliwala (2002). + * * NOTE: There is an inconsistency in the definition in Newman's book (Ch. 7) regarding * directed graphs; we follow the verbal description, which requires to sum over the incoming * edges (as opposed to outgoing ones). @@ -38,6 +47,14 @@ namespace NetworKit { class PageRank final : public Centrality { public: + // PageRank holds an std::atomic and a const-reference base, both of which make + // the class non-movable. The static factories therefore construct a PageRank as a + // prvalue and rely on C++17 guaranteed copy elision for the return; see the private + // constructor below. Users always go through the public constructor or one of the + // static factories — no copying/moving of PageRank is supported. + PageRank(const PageRank &) = delete; + PageRank &operator=(const PageRank &) = delete; + enum Norm { L1_NORM, L2_NORM, @@ -53,13 +70,114 @@ class PageRank final : public Centrality { * @param[in] G Graph to be processed. * @param[in] damp Damping factor of the PageRank algorithm. * @param[in] tol Error tolerance for PageRank iteration. - * @param[in] distributeSinks Set to distribute PageRank values for sink nodes. (default = - * false) * @param[in] normalized Set to normalize Page-Rank values in order to decouple it from the * network size. (default = false) + * @param[in] distributeSinks Set to distribute PageRank values for sink nodes. (default = + * NO_SINK_HANDLING) + * @param[in] personalization Teleportation distribution for personalized PageRank. Pass an + * empty vector (the default) to recover the original uniform teleportation @f$ 1/n @f$. + * Otherwise, the vector's length must be at least @c G.upperNodeIdBound(); each entry + * @c w[u] is the (unnormalized) teleportation weight for node @c u and must be non-negative. + * The vector is normalized internally so that the resulting distribution sums to one. An + * exception is thrown if all entries are zero. + * + * @note For very large graphs, prefer #forSource / #forSources / #forWeights, which + * avoid materializing an @c upperNodeIdBound()-sized personalization vector. This + * constructor auto-detects sparse personalization (at most #SPARSE_THRESHOLD non-zero + * entries) and uses the memory-efficient representation internally; the + * @c forSource-style factories skip even the one-time scan. */ PageRank(const Graph &G, double damp = 0.85, double tol = 1e-8, bool normalized = false, - SinkHandling distributeSinks = SinkHandling::NO_SINK_HANDLING); + SinkHandling distributeSinks = SinkHandling::NO_SINK_HANDLING, + std::vector personalization = {}); + + /** + * Memory-efficient single-source personalized PageRank (random walk with restart from + * @a source). Avoids allocating the @c upperNodeIdBound()-sized teleportation vector and + * uses a fast path in @ref run() that touches only the teleportation target; recommended + * for very large graphs. + * + * @param G Graph to be processed. + * @param source Node to teleport to (must exist in @a G). + * @param damp Damping factor (default 0.85). + * @param tol Error tolerance (default 1e-8). + * @param normalized Normalize PageRank scores (default false). + * @param distributeSinks How to handle sink nodes (default NO_SINK_HANDLING). + */ + static PageRank forSource(const Graph &G, node source, double damp = 0.85, double tol = 1e-8, + bool normalized = false, + SinkHandling distributeSinks = SinkHandling::NO_SINK_HANDLING); + + /** + * Memory-efficient @c k-source personalized PageRank with uniform teleportation over + * @a sources (each source gets weight @c 1/|sources|). Avoids the + * @c upperNodeIdBound()-sized vector. + * + * @param G Graph to be processed. + * @param sources Non-empty set of teleportation targets. + */ + static PageRank forSources(const Graph &G, const std::vector &sources, double damp = 0.85, + double tol = 1e-8, bool normalized = false, + SinkHandling distributeSinks = SinkHandling::NO_SINK_HANDLING); + + /** + * Memory-efficient @c k-source personalized PageRank with positive per-source weights. + * The weights are normalized to sum to one. Avoids the @c upperNodeIdBound()-sized vector. + * + * @param G Graph to be processed. + * @param weightedSources Non-empty list of (node, weight) pairs with weight > 0. + */ + static PageRank forWeights(const Graph &G, + const std::vector> &weightedSources, + double damp = 0.85, double tol = 1e-8, bool normalized = false, + SinkHandling distributeSinks = SinkHandling::NO_SINK_HANDLING); + + /** + * Maximum number of non-zero entries in a personalization vector for which the + * constructor switches to the memory-efficient sparse representation. Above this + * threshold the dense @c upperNodeIdBound()-sized vector is used. The + * @c forSource-style factories always use the sparse representation regardless of + * this value. + */ + static constexpr count SPARSE_THRESHOLD = 64; + + /** + * Builds a personalization vector that teleports exclusively to a single @a source node. + * Builds a personalization vector that teleports exclusively to a single @a source node. + * The returned vector has length @c G.upperNodeIdBound() and sums to one; useful as input + * for the @c personalization constructor argument of #PageRank. + * + * @param G Graph the personalization vector is built for. + * @param source Node to teleport to. + * @return Personalization vector of size @c G.upperNodeIdBound() with a single non-zero entry. + */ + static std::vector personalizationFromSource(const Graph &G, node source); + + /** + * Builds a personalization vector that teleports uniformly to any node in @a sources. + * The returned vector has length @c G.upperNodeIdBound() and sums to one. + * + * @param G Graph the personalization vector is built for. + * @param sources Nodes to teleport to (must be non-empty). + * @return Personalization vector of size @c G.upperNodeIdBound() with uniform non-zero + * entries on the @a sources. + */ + static std::vector personalizationFromSources(const Graph &G, + const std::vector &sources); + + /** + * Builds a personalization vector from a set of weighted source nodes. Weights must be + * positive; nodes missing from @a weightedSources receive a weight of zero. The returned + * vector has length @c G.upperNodeIdBound() and sums to one. + * + * @param G Graph the personalization vector is built for. + * @param weightedSources Pairs of (node, weight) where weight > 0. Must be non-empty and the + * sum of weights must be positive. + * @return Personalization vector of size @c G.upperNodeIdBound(). + */ + static std::vector + personalizationFromWeights(const Graph &G, + const std::vector> &weightedSources); /** * Computes page rank on the graph passed in constructor. @@ -87,12 +205,38 @@ class PageRank final : public Centrality { // Norm used as stopping criterion Norm norm = Norm::L2_NORM; + /** + * Constructor that takes the sparse personalization representation directly. Used by + * the @c forSource / @c forSources / @c forWeights static factories (and by language + * bindings) to build a PageRank without ever materializing an + * @c upperNodeIdBound()-sized personalization vector. The @a sparseSpec must sum to 1 + * and contain no duplicate entries; the caller is responsible for validation. + * + * @note Prefer the public constructor or one of the @c forX static factories. This + * overload skips input validation and is mainly intended for binding implementations + * and for the static factories themselves. + */ + PageRank(const Graph &G, double damp, double tol, bool normalized, SinkHandling distributeSinks, + std::vector> sparseSpec); + private: double damp; double tol; count iterations; bool normalized; SinkHandling distributeSinks; + + // Personalization storage. Exactly one of three configurations is active at a time: + // * sparse.empty() && dense.empty(): uniform teleportation (1/n). + // * !sparse.empty(): sparse representation of size k, the number of teleportation + // sources. Entries sum to 1. Used when the number of non-zero entries is at most + // SPARSE_THRESHOLD, or whenever the user goes through forSource/forSources/forWeights. + // * !dense.empty(): dense representation of size upperNodeIdBound(). Entries sum to 1. + // Used when the user passes a personalization vector with more than SPARSE_THRESHOLD + // non-zero entries to the public constructor. + std::vector> sparse; + std::vector dense; + std::atomic max; }; diff --git a/networkit/centrality.pyx b/networkit/centrality.pyx index b5e31c6ce..02bfe0c75 100644 --- a/networkit/centrality.pyx +++ b/networkit/centrality.pyx @@ -2327,27 +2327,47 @@ class SinkHandling(object): cdef extern from "": cdef cppclass _PageRank "NetworKit::PageRank" (_Centrality): - _PageRank(_Graph, double damp, double tol, bool_t normalized, _SinkHandling distributeSinks) except + + _PageRank(_Graph, double damp, double tol, bool_t normalized, _SinkHandling distributeSinks, vector[double] personalization) except + + _PageRank(_Graph, double damp, double tol, bool_t normalized, _SinkHandling distributeSinks, vector[pair[node, double]] sparse) except + count numberOfIterations() except + _Norm norm count maxIterations + @staticmethod + vector[double] personalizationFromSource(_Graph, node) except + + @staticmethod + vector[double] personalizationFromSources(_Graph, vector[node]) except + + @staticmethod + vector[double] personalizationFromWeights(_Graph, vector[pair[node, double]]) except + + @staticmethod + _PageRank forSource(_Graph, node, double, double, bool_t, _SinkHandling) except + + @staticmethod + _PageRank forSources(_Graph, vector[node], double, double, bool_t, _SinkHandling) except + + @staticmethod + _PageRank forWeights(_Graph, vector[pair[node, double]], double, double, bool_t, _SinkHandling) except + cdef class PageRank(Centrality): - """ - PageRank(G, damp=0.85, tol=1e-9, normalized=False, distributeSinks=SinkHandling.NoSinkHandling) - + """ + PageRank(G, damp=0.85, tol=1e-8, normalized=False, distributeSinks=SinkHandling.NoSinkHandling, personalization=None) + Compute PageRank as node centrality measure. In the default mode this computation is in line with the original paper "The PageRank citation ranking: Bringing order to the web." by L. Brin et al (1999). In later publications ("PageRank revisited." by M. Brinkmeyer et al. (2005) amongst others) sink-node handling - was added for directed graphs in order to comply with the theoretical assumptions by the underlying - Markov chain model. This can be activated by setting the matching parameter to true. By default + was added for directed graphs in order to comply with the theoretical assumptions by the underlying + Markov chain model. This can be activated by setting the matching parameter to true. By default this is disabled, since it is an addition to the original definition. - + Page-Rank values can also be normalized by post-processed according to "Comparing Apples and Oranges: Normalized PageRank for Evolving Graphs" by Berberich et al. (2007). This decouples the PageRank values from the size of the input graph. To enable this, set the matching parameter to true. Note that, sink-node handling is automatically activated if normalization is used. + Personalized PageRank (also known as random walk with restart) is enabled by passing a + non-empty :code:`personalization` vector. The vector must have length + :code:`G.upperNodeIdBound()` and contain non-negative teleportation weights; it is + normalized internally so its entries sum to one. Use the static helpers + :code:`PageRank.personalizationFromSource`, :code:`personalizationFromSources` or + :code:`personalizationFromWeights` to build common vectors. + Parameter :code:`distributeSinks` can be one of the following: - networkit.centrality.SinkHandling.NO_SINK_HANDLING @@ -2364,13 +2384,234 @@ cdef class PageRank(Centrality): distributeSinks: networkit.centrality.SinkHandling, optional Set to distribute PageRank values for sink nodes. Default: SinkHandling.NO_SINK_HANDLING normalized : bool, optional - If the results should be normalized by the lower bound of scores. + If the results should be normalized by the lower bound of scores. This decouples the PageRank values from the size of the input graph. Default: False - """ - - def __cinit__(self, Graph G, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING): + personalization : list of float, optional + Teleportation weights for personalized PageRank. Length must be at least + :code:`G.upperNodeIdBound()`, every entry must be non-negative, and at least one + entry must be positive. The vector is normalized internally. Default: :code:`None` + (uniform teleportation 1/n, the standard PageRank). + """ + + def __cinit__(self, Graph G=None, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING, personalization=None): + # `G=None` allows the forSource / forSources / forWeights static factories to + # allocate the object via `PageRank.__new__(PageRank)` and then directly set `self._this` + # to a C++ PageRank built with the sparse personalization representation. When `G` is + # None, `__cinit__` is a no-op and the static factory is responsible for fully + # initializing the instance. + if G is None: + return + cdef vector[double] pers + if personalization is not None: + pers = personalization self._G = G - self._this = new _PageRank(dereference(G._this), damp, tol, normalized, distributeSinks) + self._this = new _PageRank(dereference(G._this), damp, tol, normalized, <_SinkHandling>distributeSinks, pers) + + @staticmethod + def personalizationFromSource(Graph G, source): + """ + personalizationFromSource(G, source) + + Build a personalization vector that teleports exclusively to a single ``source`` node. + The returned list has length ``G.upperNodeIdBound()`` and sums to one; pass it as the + ``personalization`` argument of the :py:class:`PageRank` constructor to obtain + personalized PageRank with that source. + + Parameters + ---------- + G : networkit.Graph + Graph the personalization vector is built for. + source : int + Node to teleport to. + + Returns + ------- + list of float + Personalization vector of size ``G.upperNodeIdBound()`` with a single non-zero entry. + """ + return _PageRank.personalizationFromSource(dereference(G._this), source) + + @staticmethod + def personalizationFromSources(Graph G, sources): + """ + personalizationFromSources(G, sources) + + Build a personalization vector that teleports uniformly to any node in ``sources``. + The returned list has length ``G.upperNodeIdBound()`` and sums to one. + + Parameters + ---------- + G : networkit.Graph + Graph the personalization vector is built for. + sources : list of int + Nodes to teleport to (must be non-empty). + + Returns + ------- + list of float + Personalization vector of size ``G.upperNodeIdBound()`` with uniform non-zero + entries on the ``sources``. + """ + cdef vector[node] src = sources + return _PageRank.personalizationFromSources(dereference(G._this), src) + + @staticmethod + def personalizationFromWeights(Graph G, weightedSources): + """ + personalizationFromWeights(G, weightedSources) + + Build a personalization vector from a set of weighted source nodes. Weights must be + non-negative; nodes missing from ``weightedSources`` receive a weight of zero. The + returned list has length ``G.upperNodeIdBound()`` and sums to one. + + Parameters + ---------- + G : networkit.Graph + Graph the personalization vector is built for. + weightedSources : list of (int, float) + Pairs of (node, weight). Must be non-empty. + + Returns + ------- + list of float + Personalization vector of size ``G.upperNodeIdBound()``. + """ + cdef vector[pair[node, double]] ws + cdef pair[node, double] entry + cdef node u + cdef double w + for pair_item in weightedSources: + u, w = pair_item + entry.first = u + entry.second = w + ws.push_back(entry) + return _PageRank.personalizationFromWeights(dereference(G._this), ws) + + @staticmethod + def forSource(Graph G, source, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING): + """ + forSource(G, source, damp=0.85, tol=1e-8, normalized=False, distributeSinks=SinkHandling.NoSinkHandling) + + Memory-efficient single-source personalized PageRank (random walk with restart from + ``source``). Avoids materializing the ``G.upperNodeIdBound()``-sized teleportation + vector entirely and uses a fast path in ``run()`` that touches only the + teleportation target; recommended for very large graphs. + + Parameters + ---------- + G : networkit.Graph + Graph to be processed. + source : int + Node to teleport to (must exist in ``G``). + damp : float, optional + Damping factor (default 0.85). + tol : float, optional + Error tolerance for iteration (default 1e-8). + normalized : bool, optional + If True, apply post-run normalization (default False). + distributeSinks : networkit.centrality.SinkHandling, optional + How to handle sink nodes (default NO_SINK_HANDLING). + + Returns + ------- + networkit.centrality.PageRank + Configured PageRank instance; call ``run()`` to compute scores. + """ + if not G.hasNode(source): + raise RuntimeError("Personalization source node does not exist in the graph.") + cdef vector[pair[node, double]] spec + cdef pair[node, double] entry + entry.first = source + entry.second = 1.0 + spec.push_back(entry) + cdef PageRank pr = PageRank.__new__(PageRank) + pr._G = G + pr._this = new _PageRank(dereference(G._this), damp, tol, normalized, <_SinkHandling>distributeSinks, spec) + return pr + + @staticmethod + def forSources(Graph G, sources, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING): + """ + forSources(G, sources, damp=0.85, tol=1e-8, normalized=False, distributeSinks=SinkHandling.NoSinkHandling) + + Memory-efficient k-source personalized PageRank with uniform teleportation over + ``sources`` (each gets weight ``1 / |sources|``). Avoids materializing the full + ``G.upperNodeIdBound()``-sized vector. + + Parameters + ---------- + G : networkit.Graph + Graph to be processed. + sources : list of int + Non-empty set of teleportation targets. + damp, tol, normalized, distributeSinks : + See :py:meth:`forSource`. + + Returns + ------- + networkit.centrality.PageRank + Configured PageRank instance. + """ + if len(sources) == 0: + raise RuntimeError("Personalization source set must be non-empty.") + for s in sources: + if not G.hasNode(s): + raise RuntimeError("Personalization source node does not exist in the graph.") + cdef vector[node] src = sources + cdef vector[pair[node, double]] spec + cdef pair[node, double] entry + cdef double w = 1.0 / src.size() + for s in src: + entry.first = s + entry.second = w + spec.push_back(entry) + cdef PageRank pr = PageRank.__new__(PageRank) + pr._G = G + pr._this = new _PageRank(dereference(G._this), damp, tol, normalized, <_SinkHandling>distributeSinks, spec) + return pr + + @staticmethod + def forWeights(Graph G, weightedSources, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING): + """ + forWeights(G, weightedSources, damp=0.85, tol=1e-8, normalized=False, distributeSinks=SinkHandling.NoSinkHandling) + + Memory-efficient k-source personalized PageRank with positive per-source weights. + Weights are normalized to sum to one. Avoids materializing the full + ``G.upperNodeIdBound()``-sized vector. + + Parameters + ---------- + G : networkit.Graph + Graph to be processed. + weightedSources : list of (int, float) + Non-empty list of ``(node, weight)`` pairs with weight > 0. + damp, tol, normalized, distributeSinks : + See :py:meth:`forSource`. + + Returns + ------- + networkit.centrality.PageRank + Configured PageRank instance. + """ + if len(weightedSources) == 0: + raise RuntimeError("Personalization weighted source set must be non-empty.") + cdef vector[pair[node, double]] ws + cdef pair[node, double] entry + cdef node u + cdef double w + for pair_item in weightedSources: + u, w = pair_item + if not G.hasNode(u): + raise RuntimeError("Personalization source node does not exist in the graph.") + if w < 0.0: + raise RuntimeError("Personalization weight for node %d is negative (%g)." % (u, w)) + entry.first = u + entry.second = w + ws.push_back(entry) + cdef PageRank pr = PageRank.__new__(PageRank) + pr._G = G + pr._this = new _PageRank(dereference(G._this), damp, tol, normalized, <_SinkHandling>distributeSinks, ws) + return pr def numberOfIterations(self): """ diff --git a/networkit/cpp/centrality/PageRank.cpp b/networkit/cpp/centrality/PageRank.cpp index 53aad5fa5..4fec3f7f3 100644 --- a/networkit/cpp/centrality/PageRank.cpp +++ b/networkit/cpp/centrality/PageRank.cpp @@ -11,21 +11,230 @@ #include #include +#include +#include + namespace NetworKit { +namespace { + +// Validates a personalization vector in a single O(z) pass. On success, fills @a normalized +// (must already have size z) with the input divided by its sum, and reports the count of +// non-zero entries. Throws @c std::runtime_error on invalid input. +void validateAndNormalizeInto(const std::vector &raw, count z, + std::vector &normalized, count &nonZeroCount) { + if (raw.size() < z) { + std::ostringstream oss; + oss << "Personalization vector has size " << raw.size() + << " but the graph has upperNodeIdBound() == " << z + << "; the vector must cover every node id."; + throw std::runtime_error(oss.str()); + } + normalized.assign(z, 0.0); + double sum = 0.0; + nonZeroCount = 0; + for (count u = 0; u < z; ++u) { + if (raw[u] < 0.0) { + std::ostringstream oss; + oss << "Personalization weight for node " << u << " is negative (" << raw[u] << ")."; + throw std::runtime_error(oss.str()); + } + if (raw[u] > 0.0) { + normalized[u] = raw[u]; + sum += raw[u]; + ++nonZeroCount; + } + } + if (sum <= 0.0) { + throw std::runtime_error( + "Personalization vector sums to zero; at least one node must have a positive weight."); + } + const double inv = 1.0 / sum; + for (count u = 0; u < z; ++u) { + normalized[u] *= inv; + } +} + +} // namespace + +PageRank::PageRank(const Graph &G, double damp, double tol, bool normalized, + SinkHandling distributeSinks, std::vector personalization) + : Centrality(G, true), damp(damp), tol(tol), normalized(normalized), + distributeSinks(distributeSinks) { + if (personalization.empty()) { + // Uniform teleportation: leave both sparse and dense empty. + return; + } + const count z = G.upperNodeIdBound(); + std::vector normalizedP; + count nonZeroCount = 0; + validateAndNormalizeInto(personalization, z, normalizedP, nonZeroCount); + + if (nonZeroCount <= SPARSE_THRESHOLD) { + // Build the sparse representation. Memory is O(k) instead of O(z). + sparse.reserve(nonZeroCount); + for (count u = 0; u < z; ++u) { + if (normalizedP[u] > 0.0) { + sparse.emplace_back(u, normalizedP[u]); + } + } + } else { + // Too many non-zero entries; keep the dense representation. + dense = std::move(normalizedP); + } +} + PageRank::PageRank(const Graph &G, double damp, double tol, bool normalized, - SinkHandling distributeSinks) + SinkHandling distributeSinks, std::vector> sparseSpec) : Centrality(G, true), damp(damp), tol(tol), normalized(normalized), - distributeSinks(distributeSinks) {} + distributeSinks(distributeSinks), sparse(std::move(sparseSpec)) {} + +PageRank PageRank::forSource(const Graph &G, node source, double damp, double tol, bool normalized, + SinkHandling distributeSinks) { + if (!G.hasNode(source)) { + throw std::runtime_error("Personalization source node does not exist in the graph."); + } + // Build a prvalue that the caller's storage is constructed into (C++17 guaranteed + // copy elision — PageRank is not movable). + std::vector> spec; + spec.emplace_back(source, 1.0); + return PageRank(G, damp, tol, normalized, distributeSinks, std::move(spec)); +} + +PageRank PageRank::forSources(const Graph &G, const std::vector &sources, double damp, + double tol, bool normalized, SinkHandling distributeSinks) { + if (sources.empty()) { + throw std::runtime_error("Personalization source set must be non-empty."); + } + for (node s : sources) { + if (!G.hasNode(s)) { + throw std::runtime_error("Personalization source node does not exist in the graph."); + } + } + std::vector> spec; + spec.reserve(sources.size()); + const double w = 1.0 / static_cast(sources.size()); + for (node s : sources) { + spec.emplace_back(s, w); + } + return PageRank(G, damp, tol, normalized, distributeSinks, std::move(spec)); +} + +PageRank PageRank::forWeights(const Graph &G, + const std::vector> &weightedSources, + double damp, double tol, bool normalized, + SinkHandling distributeSinks) { + if (weightedSources.empty()) { + throw std::runtime_error("Personalization weighted source set must be non-empty."); + } + for (const auto &entry : weightedSources) { + if (!G.hasNode(entry.first)) { + throw std::runtime_error("Personalization source node does not exist in the graph."); + } + if (entry.second < 0.0) { + std::ostringstream oss; + oss << "Personalization weight for node " << entry.first << " is negative (" + << entry.second << ")."; + throw std::runtime_error(oss.str()); + } + } + // Build a per-node weight vector and run the standard validator / normalizer. We can't + // skip this even though the inputs are already "weights" because we need to enforce + // sum > 0 and the non-negativity check. + std::vector raw(G.upperNodeIdBound(), 0.0); + for (const auto &entry : weightedSources) { + raw[entry.first] = entry.second; + } + std::vector normalizedP; + count nonZeroCount = 0; + validateAndNormalizeInto(raw, G.upperNodeIdBound(), normalizedP, nonZeroCount); + + std::vector> spec; + spec.reserve(nonZeroCount); + for (count u = 0; u < G.upperNodeIdBound(); ++u) { + if (normalizedP[u] > 0.0) { + spec.emplace_back(u, normalizedP[u]); + } + } + return PageRank(G, damp, tol, normalized, distributeSinks, std::move(spec)); +} + +std::vector PageRank::personalizationFromSource(const Graph &G, node source) { + if (!G.hasNode(source)) { + throw std::runtime_error("Personalization source node does not exist in the graph."); + } + std::vector p(G.upperNodeIdBound(), 0.0); + p[source] = 1.0; + return p; +} + +std::vector PageRank::personalizationFromSources(const Graph &G, + const std::vector &sources) { + if (sources.empty()) { + throw std::runtime_error("Personalization source set must be non-empty."); + } + for (node s : sources) { + if (!G.hasNode(s)) { + throw std::runtime_error("Personalization source node does not exist in the graph."); + } + } + std::vector p(G.upperNodeIdBound(), 0.0); + const double w = 1.0 / static_cast(sources.size()); + for (node s : sources) { + p[s] = w; + } + return p; +} + +std::vector +PageRank::personalizationFromWeights(const Graph &G, + const std::vector> &weightedSources) { + if (weightedSources.empty()) { + throw std::runtime_error("Personalization weighted source set must be non-empty."); + } + std::vector p(G.upperNodeIdBound(), 0.0); + for (const auto &entry : weightedSources) { + if (!G.hasNode(entry.first)) { + throw std::runtime_error("Personalization source node does not exist in the graph."); + } + if (entry.second < 0.0) { + std::ostringstream oss; + oss << "Personalization weight for node " << entry.first << " is negative (" + << entry.second << ")."; + throw std::runtime_error(oss.str()); + } + p[entry.first] = entry.second; + } + // Reuse the validator / normalizer. + std::vector normalizedP; + count nonZeroCount = 0; + validateAndNormalizeInto(p, G.upperNodeIdBound(), normalizedP, nonZeroCount); + (void)nonZeroCount; + return normalizedP; +} void PageRank::run() { Aux::SignalHandler handler; const auto n = G.numberOfNodes(); const auto z = G.upperNodeIdBound(); + const bool hasSparse = !sparse.empty(); + const bool hasDense = !dense.empty(); + const double teleportBase = 1.0 - damp; - const auto teleportProb = (1.0 - damp) / static_cast(n); - const double factor = damp / static_cast(n); - scoreData.resize(z, 1.0 / static_cast(n)); + // Initialize scores from the personalization distribution. Sparse stores only the k + // non-zero entries, so the rest stay at zero; dense and uniform read from a single value + // (dense[u] or 1/n) per node. + scoreData.resize(z, 0.0); + if (hasDense) { + G.parallelForNodes([&](const node u) { scoreData[u] = dense[u]; }); + } else if (hasSparse) { + for (const auto &entry : sparse) { + scoreData[entry.first] = entry.second; + } + } else { + const double uniformInit = 1.0 / static_cast(n); + G.parallelForNodes([&](const node u) { scoreData[u] = uniformInit; }); + } std::vector pr = scoreData; std::vector deg(z, 0.0); @@ -33,6 +242,10 @@ void PageRank::run() { std::vector sinks; if (G.isDirected() && ((distributeSinks == SinkHandling::DISTRIBUTE_SINKS) || normalized)) { + // Reserve the worst case (every node is a sink) so push_back never reallocates. + // In typical web-like graphs ~10-30% of nodes are sinks, so the over-allocation is + // modest and bounded by 8·n bytes. + sinks.reserve(n); G.forNodes([&](const node u) { if (G.degree(u) == 0) { sinks.push_back(u); @@ -62,32 +275,73 @@ void PageRank::run() { return G.parallelSumForNodes(sumL1Norm) <= tol; }); + // Hot-loop body split into three specialized variants so the personalization cost is + // O(1) per node in the sparse case (a tight loop over k entries, k <= SPARSE_THRESHOLD) + // and the dense/uniform cases keep their original single-multiply form. + auto stepSparse = [&](const node u) { + pr[u] = 0.0; + G.forInEdgesOf(u, [&](const node, const node v, const edgeweight w) { + pr[u] += scoreData[v] * w / deg[v]; + }); + pr[u] *= damp; + for (const auto &entry : sparse) { + if (u == entry.first) { + pr[u] += teleportBase * entry.second; + break; + } + } + }; + auto stepDense = [&](const node u) { + pr[u] = 0.0; + G.forInEdgesOf(u, [&](const node, const node v, const edgeweight w) { + pr[u] += scoreData[v] * w / deg[v]; + }); + pr[u] *= damp; + pr[u] += teleportBase * dense[u]; + }; + auto stepUniform = [&](const node u) { + pr[u] = 0.0; + G.forInEdgesOf(u, [&](const node, const node v, const edgeweight w) { + pr[u] += scoreData[v] * w / deg[v]; + }); + pr[u] *= damp; + pr[u] += teleportBase / static_cast(n); + }; + bool isConverged = false; do { handler.assureRunning(); - G.balancedParallelForNodes([&](const node u) { - pr[u] = 0.0; - G.forInEdgesOf(u, [&](const node u, const node v, const edgeweight w) { - // note: inconsistency in definition in Newman's book (Ch. 7) regarding - // directed graphs we follow the verbal description, which requires to - // sum over the incoming edges - pr[u] += scoreData[v] * w / deg[v]; - }); - pr[u] *= damp; - pr[u] += teleportProb; - }); + if (hasSparse) { + G.balancedParallelForNodes(stepSparse); + } else if (hasDense) { + G.balancedParallelForNodes(stepDense); + } else { + G.balancedParallelForNodes(stepUniform); + } // For directed graphs sink-handling is needed to fulfill |pr| == 1 in each step. Otherwise - // probability mass would be leaked, creating wrong results. For this, we add edges from - // sinks to all other nodes. This is described amongst others in "PageRank revisited." - // by M. Brinkmeyer et al. (2005). + // probability mass would be leaked, creating wrong results. For this, we redistribute + // each sink's mass in proportion to the personalization distribution p. With uniform p + // this is equivalent to distributing 1/n per node. With sparse p we touch only the k + // sources, so the cost is O(k) per iteration rather than O(n). if (G.isDirected() && ((distributeSinks == SinkHandling::DISTRIBUTE_SINKS) || normalized)) { - double totalSinkContrib = 0.0; -#pragma omp parallel for reduction(+ : totalSinkContrib) + double totalSinkPr = 0.0; +#pragma omp parallel for reduction(+ : totalSinkPr) for (omp_index i = 0; i < static_cast(nSinks); i++) { - totalSinkContrib += factor * scoreData[sinks[i]]; + totalSinkPr += scoreData[sinks[i]]; + } + const double totalSinkContrib = damp * totalSinkPr; + if (hasSparse) { + for (const auto &entry : sparse) { + pr[entry.first] += totalSinkContrib * entry.second; + } + } else if (hasDense) { + G.balancedParallelForNodes( + [&](const node u) { pr[u] += totalSinkContrib * dense[u]; }); + } else { + G.balancedParallelForNodes( + [&](const node u) { pr[u] += totalSinkContrib / static_cast(n); }); } - G.balancedParallelForNodes([&](const node u) { pr[u] += totalSinkContrib; }); } ++iterations; @@ -97,7 +351,9 @@ void PageRank::run() { handler.assureRunning(); - // Post-processing for normalized PageRank + // Post-processing for normalized PageRank. The normalization rescales by a uniform factor + // and therefore applies to personalized PageRank unchanged; it just sets the (constant) + // target value the average score should take. if (normalized) { double normFactor; if (G.isDirected()) { @@ -109,7 +365,7 @@ void PageRank::run() { } normFactor = (1.0 / static_cast(n)) * ((1.0 - damp) + (damp * sum)); } else { - normFactor = teleportProb; + normFactor = (1.0 - damp) / static_cast(n); } G.parallelForNodes([&](const node u) { scoreData[u] /= normFactor; }); diff --git a/networkit/cpp/centrality/test/CentralityGTest.cpp b/networkit/cpp/centrality/test/CentralityGTest.cpp index 2989ec8ae..e0612ba0e 100644 --- a/networkit/cpp/centrality/test/CentralityGTest.cpp +++ b/networkit/cpp/centrality/test/CentralityGTest.cpp @@ -564,6 +564,371 @@ TEST_P(CentralityGTest, testNormalizedPageRank) { doTest(PageRank::Norm::L2_NORM); } +TEST_F(CentralityGTest, testPersonalizedPageRankDirectedTriangle) { + // Directed triangle 0 -> 1 -> 2 -> 0 with damp=0.85 and personalization + // p = (1, 0, 0). By symmetry pr[1] = 0.85 * pr[0] and pr[2] = 0.85^2 * pr[0], + // so pr[0] * (1 - 0.85^3) = 0.15 -> pr[0] ~= 0.3887, pr[1] ~= 0.3304, + // pr[2] ~= 0.2809 (sums to 1). + count n = 3; + GraphW G(n, /*weighted=*/false, /*directed=*/true); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 0); + + auto pers = PageRank::personalizationFromSource(G, 0); + ASSERT_EQ(pers.size(), G.upperNodeIdBound()); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); + pr.run(); + auto scores = pr.scores(); + + constexpr double tol = 1e-4; + EXPECT_NEAR(scores[0], 0.3887, tol); + EXPECT_NEAR(scores[1], 0.3304, tol); + EXPECT_NEAR(scores[2], 0.2809, tol); + EXPECT_NEAR(scores[0] + scores[1] + scores[2], 1.0, tol); +} + +TEST_F(CentralityGTest, testPersonalizedPageRankUndirectedTriangle) { + // Undirected triangle 0-1-2-0 with damp=0.85 and personalization p = (1, 0, 0). + // By symmetry pr[1] = pr[2]. The steady state solves + // pr[0] = 0.15 + 0.85 * pr[1] + // pr[1] = 0.425 * (pr[0] + pr[1]) + // giving pr[0] = 3.45/8.55 ~= 0.4035, pr[1] = pr[2] = 17/23 * pr[0] ~= 0.2982 (sums to 1). + count n = 3; + GraphW G(n, /*weighted=*/false, /*directed=*/false); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 0); + + auto pers = PageRank::personalizationFromSource(G, 0); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); + pr.run(); + auto scores = pr.scores(); + + constexpr double tol = 1e-4; + EXPECT_NEAR(scores[0], 3.45 / 8.55, tol); + EXPECT_NEAR(scores[1], (17.0 / 23.0) * (3.45 / 8.55), tol); + EXPECT_NEAR(scores[2], (17.0 / 23.0) * (3.45 / 8.55), tol); + EXPECT_NEAR(scores[0] + scores[1] + scores[2], 1.0, tol); +} + +TEST_F(CentralityGTest, testPersonalizedPageRankMatchesUniformFromFactory) { + // On a graph with no sinks, personalized PageRank from a single source with + // teleport probability 1 at that source should give a different answer than + // standard uniform PageRank (the source node receives more mass). + count n = 4; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 3); + G.addEdge(3, 0); + + PageRank uniform(G, 0.85, 1e-10); + uniform.run(); + auto uniformScores = uniform.scores(); + + auto pers = PageRank::personalizationFromSource(G, 0); + PageRank ppr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); + ppr.run(); + auto pprScores = ppr.scores(); + + // Personalized PageRank should favor the source node (0) more strongly. + EXPECT_GT(pprScores[0], uniformScores[0] * 1.2); + // Both distributions should still sum to 1. + constexpr double tol = 1e-6; + EXPECT_NEAR(pprScores[0] + pprScores[1] + pprScores[2] + pprScores[3], 1.0, tol); + EXPECT_NEAR(uniformScores[0] + uniformScores[1] + uniformScores[2] + uniformScores[3], 1.0, + tol); +} + +TEST_F(CentralityGTest, testPersonalizedPageRankWithSinks) { + // Personalized PageRank on a directed graph with a sink, with sink distribution + // enabled. The sink's mass is redistributed according to p, so this exercises + // the personalized sink-handling path. + count n = 4; + GraphW G(n, false, true); + G.addEdge(0, 1); + G.addEdge(1, 2); + // Node 3 is a sink (no outgoing edges). + + auto pers = PageRank::personalizationFromSource(G, 0); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::DISTRIBUTE_SINKS, + pers); + pr.run(); + auto scores = pr.scores(); + + constexpr double tol = 1e-6; + EXPECT_NEAR(scores[0] + scores[1] + scores[2] + scores[3], 1.0, tol); + // The source node has the highest score. + EXPECT_GE(scores[0], scores[1]); + EXPECT_GE(scores[0], scores[2]); + EXPECT_GE(scores[0], scores[3]); +} + +TEST_F(CentralityGTest, testPersonalizedPageRankFromMultipleSources) { + // personalizationFromSources should distribute mass uniformly across the + // sources; for a symmetric graph, the source nodes should share the highest + // scores and the non-sources should share lower scores. + count n = 4; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 3); + G.addEdge(3, 0); + G.addEdge(0, 2); + G.addEdge(1, 3); + + auto pers = PageRank::personalizationFromSources(G, {0, 1}); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); + pr.run(); + auto scores = pr.scores(); + + // Sources 0 and 1 are connected to each other and to nodes 2 and 3; they + // should each have a strictly higher score than 2 and 3. + EXPECT_GT(scores[0], scores[2]); + EXPECT_GT(scores[1], scores[2]); + EXPECT_GT(scores[0], scores[3]); + EXPECT_GT(scores[1], scores[3]); + EXPECT_NEAR(scores[0] + scores[1] + scores[2] + scores[3], 1.0, 1e-6); +} + +TEST_F(CentralityGTest, testPersonalizedPageRankFromWeightedSources) { + // personalizationFromWeights with a strong weight on a single source should + // put the bulk of the teleportation mass on that node. + count n = 3; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 0); + + auto pers = PageRank::personalizationFromWeights(G, {{0, 10.0}, {1, 1.0}, {2, 1.0}}); + ASSERT_EQ(pers.size(), G.upperNodeIdBound()); + // Should be normalized: p[0] = 10/12, p[1] = p[2] = 1/12. + constexpr double tol = 1e-12; + EXPECT_NEAR(pers[0], 10.0 / 12.0, tol); + EXPECT_NEAR(pers[1], 1.0 / 12.0, tol); + EXPECT_NEAR(pers[2], 1.0 / 12.0, tol); + + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); + pr.run(); + auto scores = pr.scores(); + // Source 0 has 10x weight, so it should have the largest score. + EXPECT_GT(scores[0], scores[1]); + EXPECT_GT(scores[0], scores[2]); +} + +TEST_F(CentralityGTest, testPersonalizedPageRankValidation) { + // Building PageRank with an invalid personalization vector must throw. + count n = 3; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + + // Too small: missing entries for upper node ids. + std::vector tooSmall(1, 1.0); + EXPECT_THROW(PageRank(G, 0.85, 1e-8, false, PageRank::SinkHandling::NO_SINK_HANDLING, tooSmall), + std::runtime_error); + + // Negative entry. + std::vector negative(n, 0.0); + negative[0] = -1.0; + EXPECT_THROW(PageRank(G, 0.85, 1e-8, false, PageRank::SinkHandling::NO_SINK_HANDLING, negative), + std::runtime_error); + + // All zeros -> sum is zero. + std::vector allZero(n, 0.0); + EXPECT_THROW(PageRank(G, 0.85, 1e-8, false, PageRank::SinkHandling::NO_SINK_HANDLING, allZero), + std::runtime_error); + + // Static factory: non-existent source. + EXPECT_THROW(PageRank::personalizationFromSource(G, /*source=*/42), std::runtime_error); + // Static factory: empty source set. + EXPECT_THROW(PageRank::personalizationFromSources(G, std::vector{}), std::runtime_error); + // Static factory: non-existent source in set. + EXPECT_THROW(PageRank::personalizationFromSources(G, std::vector{0, 99}), + std::runtime_error); + // Static factory: negative weight. + EXPECT_THROW( + PageRank::personalizationFromWeights(G, std::vector>{{0, -1.0}}), + std::runtime_error); +} + +TEST_F(CentralityGTest, testForSourceMatchesVectorPath) { + // The memory-efficient forSource / forSources / forWeights factories must produce + // exactly the same scores as the equivalent vector-based constructor calls. The + // triangle PPR reference values (from testPersonalizedPageRankDirectedTriangle) are + // reused here as the ground truth. + count n = 3; + GraphW G(n, false, true); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 0); + + // Vector path. + auto persVec = PageRank::personalizationFromSource(G, 0); + PageRank prVec(G, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, persVec); + prVec.run(); + auto scoresVec = prVec.scores(); + + // forSource path: should hit the sparse fast path. + PageRank prFast = + PageRank::forSource(G, 0, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING); + prFast.run(); + auto scoresFast = prFast.scores(); + + constexpr double tol = 1e-12; + EXPECT_NEAR(scoresVec[0], scoresFast[0], tol); + EXPECT_NEAR(scoresVec[1], scoresFast[1], tol); + EXPECT_NEAR(scoresVec[2], scoresFast[2], tol); + EXPECT_NEAR(scoresFast[0], 0.3887, 1e-4); + EXPECT_NEAR(scoresFast[1], 0.3304, 1e-4); + EXPECT_NEAR(scoresFast[2], 0.2809, 1e-4); +} + +TEST_F(CentralityGTest, testForSourcesMatchesVectorPath) { + // Multi-source PPR. Use the same 4-node "ring + chords" graph as + // testPersonalizedPageRankFromMultipleSources, but verify that the fast path + // (forSources) and the vector path agree. + count n = 4; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 3); + G.addEdge(3, 0); + G.addEdge(0, 2); + G.addEdge(1, 3); + + std::vector sources{0, 1}; + auto persVec = PageRank::personalizationFromSources(G, sources); + PageRank prVec(G, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, persVec); + prVec.run(); + auto scoresVec = prVec.scores(); + + PageRank prFast = PageRank::forSources(G, sources, 0.85, 1e-10, false, + PageRank::SinkHandling::NO_SINK_HANDLING); + prFast.run(); + auto scoresFast = prFast.scores(); + + constexpr double tol = 1e-12; + for (node u = 0; u < n; ++u) { + EXPECT_NEAR(scoresVec[u], scoresFast[u], tol) << "disagreement at node " << u; + } +} + +TEST_F(CentralityGTest, testForWeightsMatchesVectorPath) { + // Weighted-source PPR. Same triangle graph as testForSourceMatchesVectorPath. + count n = 3; + GraphW G(n, false, true); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 0); + + std::vector> weighted{{0, 10.0}, {1, 1.0}, {2, 1.0}}; + auto persVec = PageRank::personalizationFromWeights(G, weighted); + PageRank prVec(G, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, persVec); + prVec.run(); + auto scoresVec = prVec.scores(); + + PageRank prFast = PageRank::forWeights(G, weighted, 0.85, 1e-10, false, + PageRank::SinkHandling::NO_SINK_HANDLING); + prFast.run(); + auto scoresFast = prFast.scores(); + + constexpr double tol = 1e-12; + for (node u = 0; u < n; ++u) { + EXPECT_NEAR(scoresVec[u], scoresFast[u], tol) << "disagreement at node " << u; + } +} + +TEST_F(CentralityGTest, testConstructorAutoDetectsSparse) { + // When the user passes a vector with at most SPARSE_THRESHOLD non-zero entries, the + // constructor should silently switch to the sparse representation and produce + // bit-for-bit identical results to the dense path. We can't directly observe the + // internal storage, so we exercise it through: + // (a) identical output to the explicit-dense variant + // (b) equality with the forSource / forSources / forWeights fast paths. + count n = 3; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + G.addEdge(2, 0); + + // Single source via vector — exercises the k=1 auto-detection branch. + auto pers1 = PageRank::personalizationFromSource(G, 0); + PageRank prVec1(G, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, pers1); + prVec1.run(); + PageRank prFor1 = + PageRank::forSource(G, 0, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING); + prFor1.run(); + for (node u = 0; u < n; ++u) { + EXPECT_NEAR(prVec1.scores()[u], prFor1.scores()[u], 1e-12); + } + + // Three sources via vector — exercises the k=SPARSE_THRESHOLD auto-detection branch. + std::vector all{n - 1, 0, 1}; // 3 sources, k > 1 and <= threshold + auto pers2 = PageRank::personalizationFromSources(G, all); + PageRank prVec2(G, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, pers2); + prVec2.run(); + PageRank prFor2 = + PageRank::forSources(G, all, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING); + prFor2.run(); + for (node u = 0; u < n; ++u) { + EXPECT_NEAR(prVec2.scores()[u], prFor2.scores()[u], 1e-12); + } + + // SPARSE_THRESHOLD + 1 non-zero entries -> should stay dense. We pick a large enough + // graph so the dense path is the only feasible option, then verify the constructor + // still produces a result identical to the vector path (we don't observe the storage + // type directly, but the math must be correct). + constexpr count nBig = PageRank::SPARSE_THRESHOLD + 2; + GraphW Gbig(nBig, false, false); + for (count i = 0; i + 1 < nBig; ++i) { + Gbig.addEdge(i, i + 1); + } + Gbig.addEdge(nBig - 1, 0); // make it a cycle + std::vector manyWeights(nBig, 0.0); + for (count i = 0; i < PageRank::SPARSE_THRESHOLD + 1; ++i) { + manyWeights[i] = 1.0; + } + PageRank prMany(Gbig, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, + manyWeights); + prMany.run(); + PageRank prManyCheck(Gbig, 0.85, 1e-10, false, PageRank::SinkHandling::NO_SINK_HANDLING, + manyWeights); + prManyCheck.run(); + for (node u = 0; u < nBig; ++u) { + EXPECT_NEAR(prMany.scores()[u], prManyCheck.scores()[u], 1e-12); + } +} + +TEST_F(CentralityGTest, testForSourceValidation) { + count n = 3; + GraphW G(n, false, false); + G.addEdge(0, 1); + G.addEdge(1, 2); + // Non-existent source. + EXPECT_THROW(PageRank::forSource(G, /*source=*/42, 0.85, 1e-8, false, + PageRank::SinkHandling::NO_SINK_HANDLING), + std::runtime_error); + // Empty source set. + EXPECT_THROW(PageRank::forSources(G, std::vector{}, 0.85, 1e-8, false, + PageRank::SinkHandling::NO_SINK_HANDLING), + std::runtime_error); + // Non-existent source in set. + EXPECT_THROW(PageRank::forSources(G, std::vector{0, 99}, 0.85, 1e-8, false, + PageRank::SinkHandling::NO_SINK_HANDLING), + std::runtime_error); + // Negative weight. + EXPECT_THROW(PageRank::forWeights(G, std::vector>{{0, -1.0}}, 0.85, + 1e-8, false, PageRank::SinkHandling::NO_SINK_HANDLING), + std::runtime_error); +} + TEST_F(CentralityGTest, testEigenvectorCentrality) { /* Graph: 0 3 6 diff --git a/networkit/cpp/community/ParallelLeidenView.cpp b/networkit/cpp/community/ParallelLeidenView.cpp index 85ba5a727..89f0d9c76 100644 --- a/networkit/cpp/community/ParallelLeidenView.cpp +++ b/networkit/cpp/community/ParallelLeidenView.cpp @@ -177,8 +177,7 @@ void ParallelLeidenView::run() { changed = false; INFO("Using move gain epsilon=", std::setprecision(6), moveGainMarginEpsilon, " | max inner iterations=", maxInnerIterations, - " | max moves per node=", maxMovesPerNode, - " | vector oversize=", VECTOR_OVERSIZE, + " | max moves per node=", maxMovesPerNode, " | vector oversize=", VECTOR_OVERSIZE, " | min community reduction=", minCommunityReduction); // Start with the original graph @@ -490,7 +489,8 @@ ParallelLeidenView::MoveStats ParallelLeidenView::parallelMove(const GraphType & assert(expected == Reprocess); expected = Reprocess; - const bool markedQueued = nodeState[processedNode].compare_exchange_strong(expected, Queued); + const bool markedQueued = + nodeState[processedNode].compare_exchange_strong(expected, Queued); assert(markedQueued); tlx::unused(markedQueued); @@ -522,7 +522,8 @@ ParallelLeidenView::MoveStats ParallelLeidenView::parallelMove(const GraphType & } std::uint8_t expectedState = Queued; - const bool startedProcessing = nodeState[u].compare_exchange_strong(expectedState, Processing); + const bool startedProcessing = + nodeState[u].compare_exchange_strong(expectedState, Processing); assert(startedProcessing); tlx::unused(startedProcessing); @@ -703,14 +704,14 @@ ParallelLeidenView::MoveStats ParallelLeidenView::parallelMove(const GraphType & totalNodesPerThread.end(), static_cast(0)); const count totalNodePassesSkippedByMoveLimit = - std::accumulate(nodePassesSkippedByMoveLimit.begin(), - nodePassesSkippedByMoveLimit.end(), static_cast(0)); + std::accumulate(nodePassesSkippedByMoveLimit.begin(), nodePassesSkippedByMoveLimit.end(), + static_cast(0)); if (Aux::Log::isLogLevelEnabled(Aux::Log::LogLevel::DEBUG)) { tlx::unused(totalWorked); DEBUG("Total worked: ", totalWorked, " Total moved: ", totalMoved, - " Moved to singleton community: ", singleton, - " Node passes skipped by move limit: ", totalNodePassesSkippedByMoveLimit); + " Moved to singleton community: ", singleton, + " Node passes skipped by move limit: ", totalNodePassesSkippedByMoveLimit); } MoveStats stats; stats.moved = totalMoved; diff --git a/networkit/test/test_personalized_pagerank.py b/networkit/test/test_personalized_pagerank.py new file mode 100644 index 000000000..513c9bebb --- /dev/null +++ b/networkit/test/test_personalized_pagerank.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +""" +Tests for personalized PageRank (networkit.centrality.PageRank). + +Covers: + +* Reference values on hand-solvable graphs (directed and undirected triangle + with a single teleportation source). +* Behaviour with sink distribution and with multiple / weighted source sets. +* The static ``personalizationFrom*`` factories. +* Validation: invalid input vectors / source sets must raise ``RuntimeError``. +* Backward compatibility: passing no personalization must reproduce standard + (uniform) PageRank bit-for-bit. +""" + +import unittest + +import networkit as nk + + +PR_TOL = 1e-4 # tolerance for hand-derived reference scores + + +def _build_directed_triangle(): + """0 -> 1 -> 2 -> 0, three nodes, three edges.""" + g = nk.Graph(3, weighted=False, directed=True) + g.addEdge(0, 1) + g.addEdge(1, 2) + g.addEdge(2, 0) + return g + + +def _build_undirected_triangle(): + """0 - 1 - 2 - 0, three nodes, three edges.""" + g = nk.Graph(3, weighted=False, directed=False) + g.addEdge(0, 1) + g.addEdge(1, 2) + g.addEdge(2, 0) + return g + + +class TestPersonalizedPageRank(unittest.TestCase): + # --- Reference values on hand-solvable graphs ------------------------- + + def test_directed_triangle_single_source(self): + # Steady state: pr[0] = 0.15 / (1 - 0.85^3) ~= 0.3887, + # pr[1] = 0.85 * pr[0] ~= 0.3304, + # pr[2] = 0.85^2 * pr[0] ~= 0.2809. + g = _build_directed_triangle() + pers = nk.centrality.PageRank.personalizationFromSource(g, 0) + pr = nk.centrality.PageRank( + g, damp=0.85, tol=1e-10, distributeSinks=nk.centrality.SinkHandling.NoSinkHandling, + personalization=pers, + ) + pr.run() + scores = pr.scores() + self.assertAlmostEqual(scores[0], 0.3887, places=4) + self.assertAlmostEqual(scores[1], 0.3304, places=4) + self.assertAlmostEqual(scores[2], 0.2809, places=4) + self.assertAlmostEqual(sum(scores), 1.0, places=6) + + def test_undirected_triangle_single_source(self): + # Steady state: pr[0] = 0.15 * 23 / 8.55 = 3.45 / 8.55 ~= 0.40351, + # pr[1] = pr[2] = 17/23 * pr[0] ~= 0.29825. + g = _build_undirected_triangle() + pers = nk.centrality.PageRank.personalizationFromSource(g, 0) + pr = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers) + pr.run() + scores = pr.scores() + self.assertAlmostEqual(scores[0], 3.45 / 8.55, places=4) + self.assertAlmostEqual(scores[1], 17.0 / 23.0 * (3.45 / 8.55), places=4) + self.assertAlmostEqual(scores[2], 17.0 / 23.0 * (3.45 / 8.55), places=4) + self.assertAlmostEqual(sum(scores), 1.0, places=6) + + # --- Sanity: PPR actually biases towards the source ------------------- + + def test_source_is_emphasized(self): + g = nk.Graph(4, weighted=False, directed=False) + for u, v in [(0, 1), (1, 2), (2, 3), (3, 0)]: + g.addEdge(u, v) + + uniform = nk.centrality.PageRank(g, damp=0.85, tol=1e-10) + uniform.run() + + pers = nk.centrality.PageRank.personalizationFromSource(g, 0) + ppr = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers) + ppr.run() + + u_scores = uniform.scores() + p_scores = ppr.scores() + # The source node must receive strictly more mass in PPR. + self.assertGreater(p_scores[0], u_scores[0]) + # Both distributions must still sum to 1. + self.assertAlmostEqual(sum(u_scores), 1.0, places=6) + self.assertAlmostEqual(sum(p_scores), 1.0, places=6) + + # --- Multiple / weighted sources -------------------------------------- + + def test_multiple_sources_are_emphasized(self): + g = nk.Graph(4, weighted=False, directed=False) + for u, v in [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)]: + g.addEdge(u, v) + + pers = nk.centrality.PageRank.personalizationFromSources(g, [0, 1]) + self.assertEqual(len(pers), g.upperNodeIdBound()) + self.assertAlmostEqual(pers[0], 0.5, places=12) + self.assertAlmostEqual(pers[1], 0.5, places=12) + self.assertAlmostEqual(pers[2], 0.0, places=12) + self.assertAlmostEqual(pers[3], 0.0, places=12) + + pr = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers) + pr.run() + scores = pr.scores() + # The two source nodes should each have a higher score than the two + # non-source nodes. + self.assertGreater(scores[0], scores[2]) + self.assertGreater(scores[1], scores[2]) + self.assertGreater(scores[0], scores[3]) + self.assertGreater(scores[1], scores[3]) + self.assertAlmostEqual(sum(scores), 1.0, places=6) + + def test_weighted_sources_normalization(self): + g = _build_undirected_triangle() + pers = nk.centrality.PageRank.personalizationFromWeights( + g, [(0, 10.0), (1, 1.0), (2, 1.0)] + ) + self.assertAlmostEqual(pers[0], 10.0 / 12.0, places=12) + self.assertAlmostEqual(pers[1], 1.0 / 12.0, places=12) + self.assertAlmostEqual(pers[2], 1.0 / 12.0, places=12) + + pr = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers) + pr.run() + scores = pr.scores() + # The 10x-weighted source should have the largest score. + self.assertGreater(scores[0], scores[1]) + self.assertGreater(scores[0], scores[2]) + self.assertAlmostEqual(sum(scores), 1.0, places=6) + + # --- Sinks ------------------------------------------------------------- + + def test_personalized_pagerank_with_sinks(self): + g = nk.Graph(4, weighted=False, directed=True) + g.addEdge(0, 1) + g.addEdge(1, 2) + # Node 3 is a sink (no outgoing edges). + pers = nk.centrality.PageRank.personalizationFromSource(g, 0) + pr = nk.centrality.PageRank( + g, damp=0.85, tol=1e-10, + distributeSinks=nk.centrality.SinkHandling.DistributeSinks, + personalization=pers, + ) + pr.run() + scores = pr.scores() + self.assertAlmostEqual(sum(scores), 1.0, places=6) + # The source still has the highest score. + self.assertGreaterEqual(scores[0], max(scores[1], scores[2], scores[3])) + + # --- Backward compatibility ------------------------------------------- + + def test_default_constructor_matches_standard_pagerank(self): + # Passing personalization=None must be bit-for-bit equivalent to the + # original PageRank construction (uniform teleportation 1/n). + g = _build_directed_triangle() + a = nk.centrality.PageRank(g, damp=0.85, tol=1e-10) + a.run() + b = nk.centrality.PageRank( + g, damp=0.85, tol=1e-10, + distributeSinks=nk.centrality.SinkHandling.NoSinkHandling, + personalization=None, + ) + b.run() + for sa, sb in zip(a.scores(), b.scores()): + self.assertEqual(sa, sb) + + def test_uniform_personalization_matches_standard_pagerank(self): + # Passing an explicit uniform personalization vector must also reproduce + # standard PageRank. + g = _build_directed_triangle() + n = g.numberOfNodes() + uniform = [1.0 / n] * g.upperNodeIdBound() + a = nk.centrality.PageRank(g, damp=0.85, tol=1e-10) + a.run() + b = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=uniform) + b.run() + for sa, sb in zip(a.scores(), b.scores()): + self.assertAlmostEqual(sa, sb, places=10) + + # --- Input validation ------------------------------------------------- + + def test_too_small_personalization_raises(self): + g = _build_undirected_triangle() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank(g, damp=0.85, tol=1e-8, personalization=[1.0]) + + def test_negative_entry_raises(self): + g = _build_undirected_triangle() + z = g.upperNodeIdBound() + pers = [-1.0] + [0.0] * (z - 1) + with self.assertRaises(RuntimeError): + nk.centrality.PageRank(g, damp=0.85, tol=1e-8, personalization=pers) + + def test_all_zero_personalization_raises(self): + g = _build_undirected_triangle() + pers = [0.0] * g.upperNodeIdBound() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank(g, damp=0.85, tol=1e-8, personalization=pers) + + def test_factory_nonexistent_source_raises(self): + g = _build_undirected_triangle() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.personalizationFromSource(g, 42) + + def test_factory_empty_sources_raises(self): + g = _build_undirected_triangle() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.personalizationFromSources(g, []) + + def test_factory_sources_with_unknown_node_raises(self): + g = _build_undirected_triangle() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.personalizationFromSources(g, [0, 99]) + + def test_factory_negative_weight_raises(self): + g = _build_undirected_triangle() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.personalizationFromWeights(g, [(0, -1.0)]) + + # --- Memory-efficient fast-path static factories --------------------- + + def test_for_source_matches_vector_path(self): + # The fast-path `forSource` factory must produce bit-for-bit identical + # scores to the equivalent vector-based construction. + g = _build_directed_triangle() + pers = nk.centrality.PageRank.personalizationFromSource(g, 0) + pr_vec = nk.centrality.PageRank( + g, damp=0.85, tol=1e-10, + distributeSinks=nk.centrality.SinkHandling.NoSinkHandling, + personalization=pers, + ) + pr_vec.run() + pr_fast = nk.centrality.PageRank.forSource( + g, 0, damp=0.85, tol=1e-10, + distributeSinks=nk.centrality.SinkHandling.NoSinkHandling, + ) + pr_fast.run() + for s_vec, s_fast in zip(pr_vec.scores(), pr_fast.scores()): + self.assertAlmostEqual(s_vec, s_fast, places=12) + + def test_for_sources_matches_vector_path(self): + g = nk.Graph(4, weighted=False, directed=False) + for u, v in [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)]: + g.addEdge(u, v) + + sources = [0, 1] + pers = nk.centrality.PageRank.personalizationFromSources(g, sources) + pr_vec = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers) + pr_vec.run() + pr_fast = nk.centrality.PageRank.forSources(g, sources, damp=0.85, tol=1e-10) + pr_fast.run() + for s_vec, s_fast in zip(pr_vec.scores(), pr_fast.scores()): + self.assertAlmostEqual(s_vec, s_fast, places=12) + + def test_for_weights_matches_vector_path(self): + g = _build_directed_triangle() + weighted = [(0, 10.0), (1, 1.0), (2, 1.0)] + pers = nk.centrality.PageRank.personalizationFromWeights(g, weighted) + pr_vec = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers) + pr_vec.run() + pr_fast = nk.centrality.PageRank.forWeights(g, weighted, damp=0.85, tol=1e-10) + pr_fast.run() + # The fast path normalizes inside the sparse-representation builder using a + # slightly different summation order than the dense vector path, so a tolerance + # of 1e-10 (the same as the iteration tolerance) is the right level here. + for s_vec, s_fast in zip(pr_vec.scores(), pr_fast.scores()): + self.assertAlmostEqual(s_vec, s_fast, places=9) + + def test_for_source_validation(self): + g = _build_undirected_triangle() + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.forSource(g, 42) + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.forSources(g, []) + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.forSources(g, [0, 99]) + with self.assertRaises(RuntimeError): + nk.centrality.PageRank.forWeights(g, [(0, -1.0)]) + + def test_for_source_runs_on_large_graph(self): + # The optimization is most useful on large graphs: a 200k-node graph with + # a single teleportation source would otherwise require allocating a 1.6 MB + # personalization vector on the Python side (plus the C++ copy). Verify + # that forSource runs to completion and produces the same sum-of-scores + # invariant as the dense path. + import random + random.seed(0) + n = 200_000 + g = nk.Graph(n, weighted=False, directed=False) + for _ in range(n): + u = random.randrange(n) + v = random.randrange(n) + if u != v: + g.addEdge(u, v) + + pr_fast = nk.centrality.PageRank.forSource(g, 0, damp=0.85, tol=1e-6) + pr_fast.run() + scores = pr_fast.scores() + self.assertAlmostEqual(sum(scores), 1.0, places=6) + # The source should have the largest score (teleportation bias). + self.assertEqual(max(range(len(scores)), key=lambda u: scores[u]), 0) + + def test_constructor_auto_detects_sparse(self): + # A personalization vector with at most SPARSE_THRESHOLD non-zero entries + # is silently switched to the sparse representation by the constructor. + # We can only observe this indirectly: the result must match the explicit + # forSource / forSources fast paths bit-for-bit. + g = _build_directed_triangle() + + # Single source via vector — k=1 should switch to sparse. + pers1 = nk.centrality.PageRank.personalizationFromSource(g, 0) + pr_vec = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers1) + pr_vec.run() + pr_for = nk.centrality.PageRank.forSource(g, 0, damp=0.85, tol=1e-10) + pr_for.run() + for s_vec, s_for in zip(pr_vec.scores(), pr_for.scores()): + self.assertAlmostEqual(s_vec, s_for, places=12) + + # Three sources via vector — k=3 should switch to sparse. + sources = [0, 1, 2] + pers2 = nk.centrality.PageRank.personalizationFromSources(g, sources) + pr_vec2 = nk.centrality.PageRank(g, damp=0.85, tol=1e-10, personalization=pers2) + pr_vec2.run() + pr_for2 = nk.centrality.PageRank.forSources(g, sources, damp=0.85, tol=1e-10) + pr_for2.run() + for s_vec, s_for in zip(pr_vec2.scores(), pr_for2.scores()): + self.assertAlmostEqual(s_vec, s_for, places=12) + + +if __name__ == "__main__": + unittest.main()