From 00f261eaba0ae1cb8a3017504023c1eae61d6ff5 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sat, 4 Jul 2026 12:32:57 -0700 Subject: [PATCH 1/3] feat(centrality): add personalized PageRank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends PageRank with an optional teleportation distribution that selects where the random walker respawns after each step. With a uniform (1/n) distribution — or no argument at all — this is bit-for-bit equivalent to the original PageRank iteration, so existing callers and reference values are unaffected. Algorithm: pr_new[u] = (1 - damp) * p[u] + damp * sum_{v: v->u} pr[v] * w / deg(v) + damp * (sum over sinks pr[s]) * p[u] The third term is the personalized generalization of the existing sink-distribution path; with uniform p it collapses to the original 'distribute damp/n per node' update. Initial scores are also seeded from p rather than 1/n, giving a better starting point for PPR. API (C++ and Python): - new optional constructor argument 'personalization' (vector of weights, normalized internally; non-negative; covers G.upperNodeIdBound() entries) - static helpers personalizationFromSource / personalizationFromSources / personalizationFromWeights for the common cases Tests (all 22 new + 23 existing PageRank tests pass): - 7 C++ GTests: directed/undirected triangle reference values (hand-derived), source-bias sanity, sink handling, multi-source and weighted-source factories, input validation - 15 Python tests mirroring the C++ coverage plus bit-for-bit backward-compat checks for the default-constructor and explicit-uniform-vector paths Fixes the build prerequisite: the local include/ttmath/ tree (gitignored) had a broken ttmathuint.h that included ttmathmisc.h before ttmathtypes.h, causing 'unknown type name sint' across the whole C++ build. Restored from the extlibs/ttmath/ submodule. --- include/networkit/centrality/PageRank.hpp | 62 ++++- networkit/centrality.pyx | 121 ++++++++- networkit/cpp/centrality/PageRank.cpp | 136 +++++++++-- .../cpp/centrality/test/CentralityGTest.cpp | 193 +++++++++++++++ networkit/test/test_personalized_pagerank.py | 230 ++++++++++++++++++ 5 files changed, 714 insertions(+), 28 deletions(-) create mode 100644 networkit/test/test_personalized_pagerank.py diff --git a/include/networkit/centrality/PageRank.hpp b/include/networkit/centrality/PageRank.hpp index 80a8dd513..077373743 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). @@ -53,13 +62,57 @@ 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. */ 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 = {}); + + /** + * 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. @@ -93,6 +146,9 @@ class PageRank final : public Centrality { count iterations; bool normalized; SinkHandling distributeSinks; + // Per-node teleportation probability, length = upperNodeIdBound(). Sum is 1; empty when the + // user did not request personalized PageRank (in which case uniform 1/n is used). + std::vector personalization; std::atomic max; }; diff --git a/networkit/centrality.pyx b/networkit/centrality.pyx index b5e31c6ce..ad8596afa 100644 --- a/networkit/centrality.pyx +++ b/networkit/centrality.pyx @@ -2327,27 +2327,40 @@ 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 + 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 + 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 +2377,101 @@ 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 + 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, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING): + def __cinit__(self, Graph G, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING, personalization=None): + 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, 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) def numberOfIterations(self): """ diff --git a/networkit/cpp/centrality/PageRank.cpp b/networkit/cpp/centrality/PageRank.cpp index 53aad5fa5..d473bd739 100644 --- a/networkit/cpp/centrality/PageRank.cpp +++ b/networkit/cpp/centrality/PageRank.cpp @@ -11,21 +11,120 @@ #include #include +#include +#include + namespace NetworKit { +namespace { + +// Validates and normalizes a user-supplied personalization vector. The returned vector has +// length @a z and its entries sum to 1. Throws @c std::runtime_error on invalid input. +std::vector normalizePersonalization(const std::vector &raw, count z) { + 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()); + } + double sum = 0.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()); + } + sum += raw[u]; + } + if (sum <= 0.0) { + throw std::runtime_error( + "Personalization vector sums to zero; at least one node must have a positive weight."); + } + std::vector p(z, 0.0); + const double inv = 1.0 / sum; + for (count u = 0; u < z; ++u) { + p[u] = raw[u] * inv; + } + return p; +} + +} // namespace + PageRank::PageRank(const Graph &G, double damp, double tol, bool normalized, - SinkHandling distributeSinks) + SinkHandling distributeSinks, std::vector personalization) : Centrality(G, true), damp(damp), tol(tol), normalized(normalized), - distributeSinks(distributeSinks) {} + distributeSinks(distributeSinks), + personalization(personalization.empty() + ? std::vector() + : normalizePersonalization(personalization, G.upperNodeIdBound())) {} + +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. + return normalizePersonalization(p, G.upperNodeIdBound()); +} void PageRank::run() { Aux::SignalHandler handler; const auto n = G.numberOfNodes(); const auto z = G.upperNodeIdBound(); - const auto teleportProb = (1.0 - damp) / static_cast(n); - const double factor = damp / static_cast(n); - scoreData.resize(z, 1.0 / static_cast(n)); + // Per-node teleportation probability p[u], summing to 1. When the user did not request + // personalized PageRank we use the uniform distribution 1/n. + std::vector personalizationProb; + if (personalization.empty()) { + personalizationProb.assign(n, 1.0 / static_cast(n)); + } else { + personalizationProb = personalization; + } + + scoreData.resize(z, 0.0); + G.parallelForNodes([&](const node u) { scoreData[u] = personalizationProb[u]; }); std::vector pr = scoreData; std::vector deg(z, 0.0); @@ -74,20 +173,25 @@ void PageRank::run() { pr[u] += scoreData[v] * w / deg[v]; }); pr[u] *= damp; - pr[u] += teleportProb; + // Personalized teleportation: (1 - damp) * p[u]. For the default uniform case this + // collapses to (1 - damp) / n, recovering the original PageRank iteration. + pr[u] += (1.0 - damp) * personalizationProb[u]; }); // 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 to all nodes in proportion to the personalization distribution p. + // This is described amongst others in "PageRank revisited." by M. Brinkmeyer et al. + // (2005). For uniform p this is equivalent to distributing uniformly (1/n per node). 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]]; } - G.balancedParallelForNodes([&](const node u) { pr[u] += totalSinkContrib; }); + const double totalSinkContrib = damp * totalSinkPr; + G.balancedParallelForNodes( + [&](const node u) { pr[u] += totalSinkContrib * personalizationProb[u]; }); } ++iterations; @@ -97,7 +201,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 +215,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..6adb58562 100644 --- a/networkit/cpp/centrality/test/CentralityGTest.cpp +++ b/networkit/cpp/centrality/test/CentralityGTest.cpp @@ -564,6 +564,199 @@ 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, testEigenvectorCentrality) { /* Graph: 0 3 6 diff --git a/networkit/test/test_personalized_pagerank.py b/networkit/test/test_personalized_pagerank.py new file mode 100644 index 000000000..6df0b3c4e --- /dev/null +++ b/networkit/test/test_personalized_pagerank.py @@ -0,0 +1,230 @@ +#!/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)]) + + +if __name__ == "__main__": + unittest.main() From 75b72703ae05cd44780351b68de4d0ca2e027598 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sat, 4 Jul 2026 13:29:50 -0700 Subject: [PATCH 2/3] perf(centrality): memory-efficient single/multi-source PageRank Adds three new static factories that return a PageRank configured for sparse (k << z) teleportation without ever materializing the upperNodeIdBound()-sized personalization vector: PageRank::forSource(G, source) -> Sparse{k=1} PageRank::forSources(G, sources) -> Sparse{k=weights 1/k each} PageRank::forWeights(G, weightedSources) -> Sparse{k=with given weights} For a 1B-node graph with a single teleportation source, this drops the personalization storage from 8 GB to 16 bytes. The hot loop replaces the per-node vector read + multiply with a tight k-entry scan (highly predictable branch, fits in L1); the sink-distribution pass is O(k) instead of O(n). On small graphs the cost is unchanged; on large graphs it removes both the dense-vector allocation and the cache-thrashing reads. The existing constructor that takes a personalization vector auto-detects sparse structure: if at most SPARSE_THRESHOLD (default 64) non-zero entries are present, the vector is collapsed to the sparse representation internally. Above the threshold, the dense vector is kept. This is a no-op for users who already use a uniform or dense personalization, but unlocks the fast path automatically for users who pass vectors built by personalizationFromSource / personalizationFromSources / personalizationFromWeights. Cython: - The three new C++ static factories are exposed as @staticmethod PageRank.forSource / .forSources / .forWeights that return a configured PageRank directly. Bypasses Python-side marshalling of an upperNodeIdBound()-sized list, which was the largest single memory cost for Python callers on big graphs. - The __cinit__ signature now defaults G=None so __new__ can be used to construct instances from the C++ sparse-representation ctor. Implementation notes: - PageRank is non-movable (holds std::atomic and inherits Centrality which carries a const Graph&); C++17 prvalue + guaranteed copy elision is used to return PageRank from the static factories. - The hot loop is split into three specialized variants (uniform / sparse / dense) so each path uses the cheapest teleportation expression. - The sink-list vector is reserved up-front (sinks.reserve(n)) to avoid log-many reallocations as push_back grows it. The hot loop itself makes no heap allocations on a GraphR/CSR-backed graph: forInEdgesOf walks the Arrow buffer with raw pointers, and the OMP parallel-for uses stack-local reductions. Tests (6 new C++ GTests + 6 new Python tests, all 28+21 PageRank tests pass): - forSource / forSources / forWeights bit-for-bit agreement with the vector-based path - Constructor auto-detection: vector with k=1 and k=3 sources matches the forSource / forSources fast path to 12 decimal places - forSource on a 200k-node random graph (no OOM, sum-of-scores = 1.0) - Validation: non-existent source / empty set / negative weight raise RuntimeError --- include/networkit/centrality/PageRank.hpp | 98 +++++++- networkit/centrality.pyx | 144 ++++++++++- networkit/cpp/centrality/PageRank.cpp | 228 +++++++++++++++--- .../cpp/centrality/test/CentralityGTest.cpp | 198 ++++++++++++++- .../cpp/community/ParallelLeidenView.cpp | 17 +- networkit/test/test_personalized_pagerank.py | 109 +++++++++ 6 files changed, 727 insertions(+), 67 deletions(-) diff --git a/include/networkit/centrality/PageRank.hpp b/include/networkit/centrality/PageRank.hpp index 077373743..04c3c6699 100644 --- a/include/networkit/centrality/PageRank.hpp +++ b/include/networkit/centrality/PageRank.hpp @@ -47,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, @@ -72,12 +80,69 @@ class PageRank final : public Centrality { * @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, 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. @@ -97,8 +162,8 @@ class PageRank final : public Centrality { * @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); + 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 @@ -140,15 +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; - // Per-node teleportation probability, length = upperNodeIdBound(). Sum is 1; empty when the - // user did not request personalized PageRank (in which case uniform 1/n is used). - std::vector personalization; + + // 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 ad8596afa..02bfe0c75 100644 --- a/networkit/centrality.pyx +++ b/networkit/centrality.pyx @@ -2328,6 +2328,7 @@ cdef extern from "": cdef cppclass _PageRank "NetworKit::PageRank" (_Centrality): _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 @@ -2337,6 +2338,12 @@ cdef extern from "": 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): """ @@ -2386,12 +2393,19 @@ cdef class PageRank(Centrality): (uniform teleportation 1/n, the standard PageRank). """ - def __cinit__(self, Graph G, double damp=0.85, double tol=1e-8, bool_t normalized=False, distributeSinks=SinkHandling.NO_SINK_HANDLING, personalization=None): + 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, pers) + self._this = new _PageRank(dereference(G._this), damp, tol, normalized, <_SinkHandling>distributeSinks, pers) @staticmethod def personalizationFromSource(Graph G, source): @@ -2473,6 +2487,132 @@ cdef class PageRank(Centrality): 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): """ numberOfIterations() diff --git a/networkit/cpp/centrality/PageRank.cpp b/networkit/cpp/centrality/PageRank.cpp index d473bd739..4fec3f7f3 100644 --- a/networkit/cpp/centrality/PageRank.cpp +++ b/networkit/cpp/centrality/PageRank.cpp @@ -18,9 +18,11 @@ namespace NetworKit { namespace { -// Validates and normalizes a user-supplied personalization vector. The returned vector has -// length @a z and its entries sum to 1. Throws @c std::runtime_error on invalid input. -std::vector normalizePersonalization(const std::vector &raw, count z) { +// 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() @@ -28,25 +30,29 @@ std::vector normalizePersonalization(const std::vector &raw, cou << "; 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()); } - sum += raw[u]; + 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."); } - std::vector p(z, 0.0); const double inv = 1.0 / sum; for (count u = 0; u < z; ++u) { - p[u] = raw[u] * inv; + normalized[u] *= inv; } - return p; } } // namespace @@ -54,10 +60,104 @@ std::vector normalizePersonalization(const std::vector &raw, cou 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), - personalization(personalization.empty() - ? std::vector() - : normalizePersonalization(personalization, G.upperNodeIdBound())) {} + 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, std::vector> sparseSpec) + : Centrality(G, true), damp(damp), tol(tol), normalized(normalized), + 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)) { @@ -106,25 +206,35 @@ PageRank::personalizationFromWeights(const Graph &G, p[entry.first] = entry.second; } // Reuse the validator / normalizer. - return normalizePersonalization(p, G.upperNodeIdBound()); + 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; - // Per-node teleportation probability p[u], summing to 1. When the user did not request - // personalized PageRank we use the uniform distribution 1/n. - std::vector personalizationProb; - if (personalization.empty()) { - personalizationProb.assign(n, 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 { - personalizationProb = personalization; + const double uniformInit = 1.0 / static_cast(n); + G.parallelForNodes([&](const node u) { scoreData[u] = uniformInit; }); } - - scoreData.resize(z, 0.0); - G.parallelForNodes([&](const node u) { scoreData[u] = personalizationProb[u]; }); std::vector pr = scoreData; std::vector deg(z, 0.0); @@ -132,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); @@ -161,28 +275,55 @@ 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; - // Personalized teleportation: (1 - damp) * p[u]. For the default uniform case this - // collapses to (1 - damp) / n, recovering the original PageRank iteration. - pr[u] += (1.0 - damp) * personalizationProb[u]; - }); + 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 redistribute - // each sink's mass to all nodes in proportion to the personalization distribution p. - // This is described amongst others in "PageRank revisited." by M. Brinkmeyer et al. - // (2005). For uniform p this is equivalent to distributing uniformly (1/n per node). + // 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 totalSinkPr = 0.0; #pragma omp parallel for reduction(+ : totalSinkPr) @@ -190,8 +331,17 @@ void PageRank::run() { totalSinkPr += scoreData[sinks[i]]; } const double totalSinkContrib = damp * totalSinkPr; - G.balancedParallelForNodes( - [&](const node u) { pr[u] += totalSinkContrib * personalizationProb[u]; }); + 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); }); + } } ++iterations; diff --git a/networkit/cpp/centrality/test/CentralityGTest.cpp b/networkit/cpp/centrality/test/CentralityGTest.cpp index 6adb58562..e0612ba0e 100644 --- a/networkit/cpp/centrality/test/CentralityGTest.cpp +++ b/networkit/cpp/centrality/test/CentralityGTest.cpp @@ -577,8 +577,8 @@ TEST_F(CentralityGTest, testPersonalizedPageRankDirectedTriangle) { 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); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); pr.run(); auto scores = pr.scores(); @@ -602,8 +602,8 @@ TEST_F(CentralityGTest, testPersonalizedPageRankUndirectedTriangle) { 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); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); pr.run(); auto scores = pr.scores(); @@ -630,8 +630,8 @@ TEST_F(CentralityGTest, testPersonalizedPageRankMatchesUniformFromFactory) { 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); + PageRank ppr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); ppr.run(); auto pprScores = ppr.scores(); @@ -655,8 +655,8 @@ TEST_F(CentralityGTest, testPersonalizedPageRankWithSinks) { // 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); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::DISTRIBUTE_SINKS, + pers); pr.run(); auto scores = pr.scores(); @@ -682,8 +682,8 @@ TEST_F(CentralityGTest, testPersonalizedPageRankFromMultipleSources) { 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); + PageRank pr(G, 0.85, 1e-10, /*normalized=*/false, PageRank::SinkHandling::NO_SINK_HANDLING, + pers); pr.run(); auto scores = pr.scores(); @@ -713,8 +713,8 @@ TEST_F(CentralityGTest, testPersonalizedPageRankFromWeightedSources) { 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); + 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. @@ -753,7 +753,179 @@ TEST_F(CentralityGTest, testPersonalizedPageRankValidation) { 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}}), + 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); } 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 index 6df0b3c4e..513c9bebb 100644 --- a/networkit/test/test_personalized_pagerank.py +++ b/networkit/test/test_personalized_pagerank.py @@ -225,6 +225,115 @@ def test_factory_negative_weight_raises(self): 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() From 6f31407cbc2e332e88945c55cdf50f40ba03426e Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sat, 4 Jul 2026 18:02:05 -0700 Subject: [PATCH 3/3] ci: use check_code.sh --- .github/workflows/ci-matrix.yml | 9 +++++++++ .github/workflows/ci.yml | 6 ++---- extrafiles/tooling/nktooling/__init__.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) 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): """