Back to Deep Learning
AI/ML · Deep Learning

DeepWalk & Node2Vec

Word2vec learned that a word is defined by the words it appears near. A graph has no sentences, so make some: start at a node and take a random walk, and treat the sequence of nodes you visit as a sentence. Then run skip-gram on that corpus unchanged. Nodes that keep showing up in each other’s walks get similar vectors, and because a walk can be biased to either circle a neighbourhood or head away from it, the same machinery produces either community structure or structural role depending on two knobs.

Self-supervisedFeedforwardDimensionality reductionRankingClusteringAnomaly detection

Self-supervised because the labels are manufactured from the graph itself — a node and one of its walk neighbours is a positive pair, a random node is a negative. Architecturally it is a single embedding lookup table trained by logistic regression, which makes "deep" a generous description; it is included here because it is the baseline every graph neural network is measured against and frequently fails to beat.

Also known as: DeepWalk, node2vec, Random-walk embeddings, Skip-gram on graphs, Shallow graph embedding

Intuition

What it actually does

Word2vec learned that a word is defined by the words it appears near. A graph has no sentences, so make some: start at a node and take a random walk, and treat the sequence of nodes you visit as a sentence. Then run skip-gram on that corpus unchanged. Nodes that keep showing up in each other’s walks get similar vectors, and because a walk can be biased to either circle a neighbourhood or head away from it, the same machinery produces either community structure or structural role depending on two knobs.

Assumptions it makes
  • Graph proximity implies similarity in whatever downstream sense matters — the load-bearing assumption, and false wherever an edge means opposition rather than affinity
  • The node set is fixed at training time; the model is transductive and has no function from node to embedding
  • The graph is connected enough that walks mix, since a disconnected component is embedded in its own arbitrary and incomparable region
  • Node attributes are either absent or irrelevant, because the method cannot use them at all
  • A single vector per node suffices, so a node with several distinct roles is averaged into one compromise position
Objective

Likelihood

Read it as binary logistic regression on pairs: pull a node and its observed walk neighbour together, push it away from m random nodes. The framing as a likelihood is the honest one — this is a tractable surrogate for maximizing the probability of a node’s graph neighbourhood under a softmax over all nodes, which nobody computes because the normalizer is a sum over every node in the graph. Negative sampling replaces that normalizer with a handful of draws, which changes the objective rather than approximating it: the optimum of this loss is not the optimum of the softmax it stands in for, and the three-quarters power on the noise distribution is an empirical correction nobody has derived from first principles.

the sampled corpus: every (centre, context) pair inside a window along every walk
the embedding of node u as a centre — the vector you actually keep
the separate context embedding of v; two tables, and discarding the wrong one is a common error
negative samples per positive pair; the estimator that makes the softmax tractable
the noise distribution, degree raised to the 3/4 power — a tuned constant, not a principled one
Optimization

How it is minimized

Asynchronous SGD over sampled walk pairs, with negative sampling and alias tables for O(1) draws

1 for an observed pair, 0 for a negative sample — the gradient is a single scalar times a vector
return parameter: large p discourages walking back where you came from
in-out parameter: q below 1 pushes the walk outward (structural roles), above 1 keeps it local (communities)
shortest-path distance from the previous node t to the candidate x — 0, 1 or 2, which is why the bias is second-order
learning rate, linearly decayed; there is no convergence criterion beyond exhausting the corpus

Three independent tricks stacked, and each is worth separating. The walk bias is second-order: the transition probability depends on where the walk came from, not just where it is, which is what lets two scalars interpolate between breadth-first and depth-first exploration. Negative sampling removes a softmax over every node, turning each update into a rank-one operation on a handful of rows. Alias tables make both the negative draw and the biased transition O(1) after an O(n) build, which matters because sampling dominates the runtime — this is a sampling problem wearing a learning problem’s clothes. Updates are applied asynchronously without locks: the races corrupt individual updates and are ignored, because the embedding table is enormous relative to the number of threads and the collision rate is negligible.

Hyperparameters
  • p (return)

    Discourages immediate backtracking; interacts with q and is rarely worth tuning alone (0.25 to 4)

  • q (in-out)

    The one knob that changes what the embedding means — below 1 for structural roles, above 1 for communities (0.25 to 4)

  • walk length

    How far a single sentence reaches; long walks blur community boundaries (40 to 100)

  • walks per node

    Corpus size, and therefore how well low-degree nodes are represented (10 to 20)

  • window size

    Context width within a walk; the effective neighbourhood the embedding encodes (5 to 10)

  • dimensions

    Embedding width. Gains flatten hard past 128 on most graphs (64 to 256)

  • negative samples

    Negatives per positive; more sharpens the embedding and costs linearly (5 to 20)

Convergence & cost

No convergence criterion at all — training stops when the sampled corpus is exhausted, and "more epochs" means "sample more walks". The loss is not comparable across runs because the corpus differs, so it is useless as a stopping signal and the only honest check is downstream task performance. Two characteristic failures. Low-degree nodes are under-sampled and end up with vectors that barely moved from their random initialization, which is invisible in an aggregate metric and obvious if you plot embedding norm against degree. And the whole method is transductive: there is no function from a node to its embedding, only a lookup table, so a node added after training has no vector and cannot be given one without retraining. That second property is the reason graph neural networks exist.

O(r · n · l) sampling for r walks of length l from each of n nodes, then O(|D| · m · d) for training. Sampling frequently dominates. Memory is O(n · d) for two embedding tables, which is the binding constraint on a large graph — a hundred million nodes at 128 dimensions is over a hundred gigabytes in two tables.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

It embeds a static graph with no notion of time; nodes have positions, not trajectories, and there is no mechanism to produce a value at a future instant. Where a graph and a series genuinely coexist the right entry is a spatio-temporal graph network, which keeps the graph and adds the time axis.

Anomaly Detection

Adapted
How

Embed the graph, then run an ordinary detector in the embedding space — distance to the k nearest neighbours, or density relative to the local community. A node whose connectivity pattern is unlike anything else in the graph lands in a sparse region of the embedding, which is exactly the signal for a fraud ring member or a bot account whose individual features look entirely normal.

Where
  • Fraud and money-laundering detection on transaction graphs, where the anomaly is the connectivity pattern rather than any single transaction
  • Bot and sockpuppet detection in social graphs, where accounts coordinate and therefore embed unusually close together
  • Insider-threat detection on access graphs, where the signal is an access pattern inconsistent with a role
  • Supply-chain and ownership-graph screening for shell-company structures that are structurally distinctive
Why

It works because the embedding turns a structural question into a geometric one, and every off-the-shelf outlier detector then applies. That is a genuine and widely deployed pattern, and on a fraud graph it catches rings that per-account features cannot. But it is adapted rather than native, and the caveats are serious: the embedding is unsupervised and optimizes nothing about anomalies, so it can compress away exactly the structure you needed; adversaries in fraud settings actively shape their connectivity, and a transductive embedding cannot score a newly created account at all. On tabular data with no graph, an isolation forest is both better and a thousand times cheaper.

Featurization
  • Normalize embeddings before any distance-based scoring, or the score is dominated by degree through the embedding norm
  • Set q above 1 so walks stay local — community structure is what a fraud ring violates, and outward walks blur it
  • Compare a node against its own community rather than against the global distribution, since normal varies enormously by region of the graph
  • Keep node attributes alongside the embedding rather than relying on structure alone; the combination substantially outperforms either
Evaluation

Precision at k on confirmed cases, since labels are scarce and recall is unmeasurable. Always compare against a degree-and-triangle-count baseline: simple structural statistics beat embeddings on a surprising fraction of fraud graphs, and skipping that comparison is how embedding pipelines get deployed without justification.

Pitfalls
  • The embedding norm tracking degree, so the outlier score ranks by popularity rather than by strangeness
  • A transductive model unable to score new accounts, which in fraud is precisely the population of interest
  • Retraining shifting the whole embedding space, so thresholds calibrated on the old space are meaningless on the new one
  • Coordinated fraud embedding tightly together and therefore reading as a dense, normal community rather than as an anomaly

Optimization

Adapted
How

The interesting content is almost entirely sampling. The walk is a Markov chain deliberately designed so that two scalars interpolate its stationary exploration between breadth-first and depth-first, which is a search-strategy design problem rather than a learning one. The alias method turns sampling from an arbitrary discrete distribution into two array lookups after an O(n) build, which is the trick that makes biased walks affordable at all. Negative sampling replaces an intractable normalizer with a Monte Carlo draw, changing the objective in exchange for tractability.

Where
  • The alias method as the canonical O(1) discrete sampler, worth knowing well beyond this application
  • Second-order random walks as tunable graph search, with p and q interpolating between BFS and DFS exploration
  • Negative sampling as a deliberate objective substitution rather than an approximation, with the noise distribution as a free parameter
  • Lock-free asynchronous SGD, where the race conditions are known, accepted and quantitatively negligible
Why

Worth studying as the clearest example in this reference of a method whose cost is sampling rather than arithmetic — profile it and the gradient updates are not the bottleneck, the draws are. The alias method generalizes everywhere discrete sampling appears, and the lock-free SGD is an honest case of a correctness compromise made deliberately with an argument, rather than by accident. What it is not is an optimizer: there is no objective being solved to optimality, no convergence criterion, and the loss value is not even comparable between runs.

Featurization
  • Build alias tables per edge for the second-order walk, and accept the O(|E| · average degree) memory that costs — it is the dominant footprint on a dense graph
  • Precompute the noise distribution once with degree raised to the 3/4 power; recomputing per draw destroys the whole advantage
  • Generate walks in parallel and shuffle before training, or the SGD sees the graph in a strongly correlated order
  • Sub-sample very high-degree nodes as word2vec sub-samples frequent words, or hubs dominate every window
Evaluation

Profile sampling against gradient time before optimizing anything — the intuition that the arithmetic dominates is wrong here and sends people to optimize the wrong loop. Verify the alias table against a rejection sampler on a small graph; a wrong alias table produces a valid-looking but differently distributed walk, which is undetectable downstream.

Pitfalls
  • Second-order alias tables exhausting memory on a dense graph, where storing per-edge distributions is quadratic in degree
  • Optimizing the gradient loop while sampling is the actual bottleneck
  • Treating the loss as a convergence signal when it is not comparable across runs because the corpus is resampled
Also applied in

Recommendation & Ranking

Primary fit
How

Build a graph whose edges are interactions — users to items, or items co-occurring in a session — and embed the nodes. Similarity in the embedding space is then a candidate generator: nearest neighbours of an item are related items, and nearest neighbours of a user’s recent items are recommendations. This is how large-scale item-to-item recommendation was actually built before two-tower models, and the graph formulation handles the long tail better than matrix factorization does.

Where
  • Item-to-item related-item recommendation on session co-occurrence graphs, deployed at scale in e-commerce
  • Candidate generation feeding a heavier ranking model, where recall at a few hundred is the only thing that matters
  • Cold-ish item handling, where an item with few interactions still borrows structure from the graph region it sits in
  • Social and content graphs where the edge is a follow or a share rather than a purchase
Why

The graph formulation captures transitive structure that matrix factorization misses: two items never co-purchased but both co-purchased with the same third item end up close, which is exactly the long-tail case where factorization has no signal. It is also cheap, shallow and trains on commodity hardware, which is why it remains a strong baseline. Against it: it is transductive, so a genuinely new item has no embedding and needs a separate cold-start path; it uses no node features at all, so a rich item catalogue is thrown away; and it optimizes graph proximity rather than any business objective, which means a heavier ranker downstream is doing the actual relevance work.

Featurization
  • Weight edges by interaction strength and use weighted walks, or a single accidental co-occurrence counts as much as a thousand
  • Sub-sample hub items aggressively — a bestseller appears in every window and dominates the corpus otherwise
  • Set q above 1 to keep walks within a product category, unless cross-category discovery is an explicit goal
  • Build the graph from sessions rather than from lifetime co-purchase, so the embedding reflects intent rather than catalogue structure
Evaluation

Recall at k of the held-out next interaction, split strictly by time, with a popularity-only baseline reported alongside — popularity is startlingly hard to beat on recall and an embedding that fails to is not worth deploying. Then measure the downstream ranker with and without these candidates, since candidate generation is only ever an input.

Pitfalls
  • Popularity bias, where high-degree items dominate every neighbourhood and the recommendations collapse to bestsellers
  • Random train/test splits leaking future interactions and producing an unreproducible offline win
  • No path for genuinely new items, which is a permanent structural gap rather than a tuning problem
  • Re-embedding shifting the whole space, so any cached neighbour list must be rebuilt wholesale

Risk & Fraud

Viable
How

Embed the entity graph — accounts, devices, payment instruments, addresses — and feed the embeddings as features into a supervised risk model alongside the usual tabular signals. The embedding contributes what tabular features cannot express: that this account sits in a region of the graph densely connected to previously confirmed fraud.

Where
  • Payment and account-takeover risk scoring, with graph embeddings as features in a gradient-boosted model
  • Collusion and ring detection in marketplaces and insurance claims
  • Identity resolution, where embedding proximity suggests two accounts are the same actor
Why

The gain is real and specific: connectivity to known-bad is a strong signal that no per-account feature carries, and adding embeddings to an existing boosted model is usually a straightforward improvement. The honest framing is that it is a feature source rather than a model — the supervised model does the deciding. The transductive limitation bites hardest here, because a new account is exactly what needs scoring, so most production systems maintain a separate feature path for unseen nodes and accept that the graph signal is unavailable for the first hours of an account’s life.

Featurization
  • Pair the embedding with explicit distance-to-known-fraud features, which are more interpretable and often stronger
  • Rebuild the graph on a rolling window so stale relationships expire rather than accumulating forever
  • Keep the embedding version pinned to the risk-model version; a re-embedded space silently invalidates every learned split
  • Handle new nodes explicitly with an attribute-only fallback rather than a zero vector, which reads as a real location in the space
Evaluation

Precision and recall against confirmed fraud at a fixed review budget, measured as a lift over the model without embedding features. Backtest strictly by time — a graph built with hindsight knows which accounts turned out to be connected and produces an offline win that does not survive deployment.

Pitfalls
  • Leakage from building the graph over the full period including the label window, which is the single most common error here
  • Adversarial adaptation, since fraudsters can restructure connectivity far faster than the embedding is retrained
  • A zero vector for unseen nodes being interpreted by the downstream model as a specific and meaningful position

Natural Language

Adapted
How

The relationship runs backwards: this method is word2vec, applied to a corpus manufactured from a graph. Used in language work on knowledge graphs and entity graphs — embedding entities by their relational structure rather than by their textual context.

Where
  • Knowledge-graph entity embeddings for linking and disambiguation
  • Taxonomy and ontology embedding, where the hierarchy is the only available signal
  • Document graphs built from citation or hyperlink structure rather than from text
Why

Worth knowing chiefly for the lineage: understanding that DeepWalk is skip-gram with a sampled corpus explains every design choice in it, including the ones that look arbitrary. As a practical choice for language work it has been superseded almost everywhere by contextual encoders, which use the text rather than only the structure, and the sensible modern use is combining a structural embedding with a text one rather than choosing between them.

Featurization
  • Treat relation types as distinct edge types or use a relation-aware method; a plain walk discards which relation it traversed
  • Combine with a text embedding rather than substituting for one — the two carry genuinely different information
  • Handle the extreme degree skew of a knowledge graph by sub-sampling hubs, or a handful of entities dominate every walk
Evaluation

Link prediction and entity classification on held-out edges, with a text-embedding baseline reported alongside. The comparison is the point: if structure alone does not add to text, the pipeline is not worth maintaining.

Pitfalls
  • Discarding relation types, which collapses semantically distinct edges into one undifferentiated adjacency
  • Extreme degree skew leaving most entities barely trained while hubs are over-represented
  • Comparing against no text baseline and therefore never establishing that structure contributed anything
Deployment

What it costs to run

Training

Minutes to hours on CPU for graphs up to tens of millions of nodes — genuinely cheap, and one of the strongest arguments for it. Sampling dominates rather than arithmetic, so it scales with cores rather than with an accelerator, and a GPU buys almost nothing.

Inference

There is no inference. The output is a lookup table, so serving is a key-value read, and downstream use is an approximate nearest-neighbour query over the vectors — which is where the real serving cost lives, not in this model.

Retraining

Weekly to monthly, driven entirely by how fast new nodes appear. A graph gaining nodes continuously needs retraining continuously, which is the practical expression of the transductive limitation.

Drift & monitoring
  • Track the fraction of queried nodes with no embedding; it measures exactly how badly the transductive assumption is being violated in production
  • Plot embedding norm against node degree — a strong relationship means the space is encoding popularity rather than structure
  • Monitor neighbour-list churn between retrains; a wholesale reshuffle means downstream thresholds are stale
  • Watch the low-degree tail separately, since those embeddings barely move from initialization and the aggregate metric hides it
Production gotchas
  • Embedding spaces are not comparable between runs — the model is rotation-invariant and a retrain produces an entirely different basis, so every cached neighbour list and every calibrated threshold must be rebuilt together
  • There are two embedding tables and the centre table is the one to keep; exporting the context table produces vectors that work just well enough to hide the mistake
  • A new node has no vector. Serving a zero vector is worse than serving nothing, because downstream models read it as a specific position rather than as missing
  • Alias tables for second-order walks are memory-quadratic in degree and are the usual cause of an out-of-memory failure on a dense graph
  • Walk generation must be seeded and the seed recorded, or a retrain is not reproducible even with identical data
Trade-offs

Pros and cons, in context

Strengths
  • Trivially cheap for what it delivers

    CPU-only, minutes on a large graph, no accelerator required. The reason it is still the first thing to try and still the baseline every graph neural network is measured against — and frequently the reason the fancier model is not worth its operational cost.

  • Needs nothing but the edge list

    No labels, no node features, no schema. Decisive when all you have is connectivity, which is common in fraud and interaction graphs; wasteful when you have a rich attribute table the method cannot touch.

  • Two knobs switch what the embedding means

    q below one gives structural roles, above one gives communities, from identical code. Genuinely useful and unusually interpretable as hyperparameters go — most graph models offer no comparable control.

  • Captures transitive structure that factorization misses

    Two items never co-observed but both linked to a common third end up close. Exactly the long-tail case where matrix factorization has no signal at all, and where most of the practical recommendation gain comes from.

Limitations
  • Transductive — no embedding for an unseen node

    The decisive limitation and the direct reason graph neural networks exist. Fatal in fraud and cold-start recommendation where new nodes are the population of interest; irrelevant on a fixed graph.

  • Ignores node features entirely

    A rich item catalogue or account profile contributes nothing. Where attributes carry real signal, a graph neural network that consumes both wins clearly and this method cannot be patched to close the gap.

  • Embedding spaces are incomparable between runs

    Rotation invariance means a retrain produces a different basis, so every cached neighbour list and calibrated threshold must be rebuilt in lockstep. A genuine and frequently underestimated operational burden.

  • Popularity leaks into the geometry

    High-degree nodes get larger norms and dominate neighbourhoods, so similarity search returns bestsellers. Fixable by normalizing and sub-sampling, but it is the default behaviour and it has to be noticed first.

  • No convergence criterion and no comparable loss

    Training ends when the corpus is exhausted, and the loss cannot be compared across runs because the corpus is resampled. Every decision has to be made on a downstream metric, which makes tuning slow and expensive.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""DeepWalk / node2vec, transcribed the way the papers read.

Three stages, and keeping them separate is the whole point:

  1. bias the transition probabilities with p and q  (second order: the
     probability depends on where the walk CAME FROM, not just where it is)
  2. sample walks, which are the "sentences"
  3. run skip-gram with negative sampling on those sentences, unchanged

Linear-scan sampling and per-pair Python updates. Both are replaced later -
and note now that this profiles as a SAMPLING problem, not an arithmetic one.
"""

import math
import random

SEED = 1234


def biased_transition(graph, previous, current, return_p, inout_q):
    """Unnormalized node2vec weights over the neighbours of `current`.

    d(previous, candidate) is 0, 1 or 2 and nothing else, because a candidate
    is a neighbour of current and current is a neighbour of previous:

      d = 0  the candidate IS previous    -> weight 1/p  (backtracking)
      d = 1  the candidate neighbours it  -> weight 1     (staying local)
      d = 2  neither                      -> weight 1/q  (heading outward)
    """
    previous_neighbours = set(graph[previous]) if previous is not None else set()
    weights = []
    for candidate in graph[current]:
        if previous is None:
            weights.append(1.0)
        elif candidate == previous:
            weights.append(1.0 / return_p)
        elif candidate in previous_neighbours:
            weights.append(1.0)
        else:
            weights.append(1.0 / inout_q)
    return weights


def sample_index(weights, rng):
    """Draw from an unnormalized discrete distribution by linear scan.

    O(degree) per step. On a graph with a few hub nodes this single function
    is most of the runtime, which is the observation the fast stage acts on.
    """
    total = sum(weights)
    if total <= 0.0:
        return None
    threshold = rng.random() * total
    cumulative = 0.0
    for index, weight in enumerate(weights):
        cumulative += weight
        if cumulative >= threshold:
            return index
    return len(weights) - 1


def generate_walk(graph, start, length, return_p, inout_q, rng):
    """One second-order random walk. The sentence the skip-gram will read."""
    walk = [start]
    while len(walk) < length:
        current = walk[-1]
        if not graph[current]:
            break
        previous = walk[-2] if len(walk) >= 2 else None
        weights = biased_transition(graph, previous, current, return_p, inout_q)
        choice = sample_index(weights, rng)
        if choice is None:
            break
        walk.append(graph[current][choice])
    return walk


def build_noise_distribution(graph):
    """Degree to the 3/4 power. An empirical constant from word2vec, not a
    derived one - it damps hub nodes without removing them."""
    return [len(neighbours) ** 0.75 for neighbours in graph]


def sigmoid(value):
    if value >= 0.0:
        return 1.0 / (1.0 + math.exp(-value))
    positive = math.exp(value)
    return positive / (1.0 + positive)


def train(graph, dimensions, walk_length, walks_per_node, window, negatives,
          return_p, inout_q, learning_rate, epochs):
    """Skip-gram with negative sampling over sampled walks."""
    rng = random.Random(SEED)
    num_nodes = len(graph)

    # TWO tables. `centre` is the one you keep; exporting `context` instead
    # produces vectors that work just well enough to hide the mistake.
    centre = [
        [(rng.random() - 0.5) / dimensions for _ in range(dimensions)]
        for _ in range(num_nodes)
    ]
    context = [[0.0] * dimensions for _ in range(num_nodes)]

    noise = build_noise_distribution(graph)

    for epoch in range(epochs):
        # The learning rate decays linearly and there is no other stopping
        # rule: training ends when the corpus is exhausted.
        rate = learning_rate * (1.0 - epoch / epochs)

        order = list(range(num_nodes))
        rng.shuffle(order)

        for _ in range(walks_per_node):
            for start in order:
                walk = generate_walk(graph, start, walk_length, return_p, inout_q, rng)

                for position, target in enumerate(walk):
                    low = max(0, position - window)
                    high = min(len(walk), position + window + 1)
                    for neighbour_position in range(low, high):
                        if neighbour_position == position:
                            continue
                        observed = walk[neighbour_position]

                        # One positive pair plus m negatives. Every update is
                        # a rank-one operation on two rows.
                        samples = [(observed, 1.0)]
                        for _ in range(negatives):
                            drawn = sample_index(noise, rng)
                            samples.append((drawn, 0.0))

                        gradient = [0.0] * dimensions
                        for node, label in samples:
                            score = sum(
                                centre[target][d] * context[node][d]
                                for d in range(dimensions)
                            )
                            error = (sigmoid(score) - label) * rate
                            for d in range(dimensions):
                                gradient[d] += error * context[node][d]
                                context[node][d] -= error * centre[target][d]
                        for d in range(dimensions):
                            centre[target][d] -= gradient[d]

    return centre


def cosine_neighbours(embeddings, query, top_k):
    """What the embedding is actually for: nearest neighbours in the space."""
    def norm(vector):
        return math.sqrt(sum(value * value for value in vector)) or 1.0

    query_vector = embeddings[query]
    query_norm = norm(query_vector)
    scored = []
    for node, vector in enumerate(embeddings):
        if node == query:
            continue
        dot = sum(a * b for a, b in zip(query_vector, vector))
        scored.append((dot / (query_norm * norm(vector)), node))
    scored.sort(reverse=True)
    return [node for _, node in scored[:top_k]]

O(r * n * l * degree) for sampling and O(|D| * m * d) for training, where the first term usually wins. Illustrative, not a measured benchmark: the linear-scan draw makes every walk step cost the degree of the current node, so a graph with a few million-degree hubs spends nearly all its time in sample_index.

2. Idiomatic

How this language actually wants it written.

"""The same method, with alias tables, typed boundaries, and one table kept.

What changes: sampling becomes O(1) via the alias method, the walk bias is
precomputed per (previous, current) edge rather than rebuilt on every step,
and the two embedding tables are returned in a structure that names which one
is which - because exporting the context table is a real and silent mistake.
"""

from __future__ import annotations

import math
import random
from dataclasses import dataclass
from typing import NamedTuple, Sequence

Graph = Sequence[Sequence[int]]


class DisconnectedGraph(ValueError):
    """Raised when the graph cannot support walks at all."""


class InvalidWalkParameters(ValueError):
    """Raised on p or q outside the positive reals, which is meaningless."""


class AliasTable:
    """O(1) draws from a fixed discrete distribution after an O(n) build.

    The construction pairs each over-full bucket with an under-full one until
    every bucket holds exactly 1/n of the mass, split between one or two
    outcomes. A draw is then: pick a bucket, flip a biased coin. Two array
    lookups, no scan, no matter how skewed the distribution.
    """

    __slots__ = ('_probability', '_alias')

    def __init__(self, weights: Sequence[float]) -> None:
        if not weights:
            raise InvalidWalkParameters('an alias table needs at least one outcome')
        total = math.fsum(weights)
        if total <= 0.0:
            raise InvalidWalkParameters('alias weights must sum to something positive')

        size = len(weights)
        scaled = [weight * size / total for weight in weights]
        self._probability = [0.0] * size
        self._alias = [0] * size

        small = [i for i, value in enumerate(scaled) if value < 1.0]
        large = [i for i, value in enumerate(scaled) if value >= 1.0]

        while small and large:
            lesser = small.pop()
            greater = large.pop()
            self._probability[lesser] = scaled[lesser]
            self._alias[lesser] = greater
            scaled[greater] -= 1.0 - scaled[lesser]
            if scaled[greater] < 1.0:
                small.append(greater)
            else:
                large.append(greater)

        # Whatever is left is exactly 1.0 up to floating-point drift.
        for remaining in small + large:
            self._probability[remaining] = 1.0

    def draw(self, rng: random.Random) -> int:
        bucket = rng.randrange(len(self._probability))
        if rng.random() < self._probability[bucket]:
            return bucket
        return self._alias[bucket]


@dataclass(frozen=True)
class WalkConfig:
    """Frozen so a sampler's behaviour cannot change after construction."""

    walk_length: int
    walks_per_node: int
    return_p: float
    inout_q: float

    def __post_init__(self) -> None:
        # Guard clauses in the config, so every downstream method can assume
        # the parameters are sane rather than re-checking them.
        if self.return_p <= 0.0 or self.inout_q <= 0.0:
            raise InvalidWalkParameters('p and q must be strictly positive')
        if self.walk_length < 2:
            raise InvalidWalkParameters('a walk shorter than two nodes has no pairs')
        if self.walks_per_node < 1:
            raise InvalidWalkParameters('at least one walk per node is required')


class Embeddings(NamedTuple):
    """Both tables, named. `centre` is the one to export."""

    centre: list[list[float]]
    context: list[list[float]]


class Node2VecSampler:
    """Precomputes every alias table the walk will need.

    Memory is the cost: a second-order walk needs one table per DIRECTED
    EDGE, so the footprint is the sum of neighbour degrees over edges - which
    is quadratic in degree and the usual cause of an out-of-memory failure on
    a dense graph. That trade is the reason first-order DeepWalk still exists.
    """

    def __init__(self, graph: Graph, config: WalkConfig) -> None:
        if not graph:
            raise DisconnectedGraph('an empty graph admits no walks')
        if all(not neighbours for neighbours in graph):
            raise DisconnectedGraph('every node is isolated; no walk can move')

        self._graph = graph
        self._config = config
        self._first_order = [
            AliasTable([1.0] * len(neighbours)) if neighbours else None
            for neighbours in graph
        ]
        self._second_order: dict[tuple[int, int], AliasTable] = {}
        self._build_second_order()

    def _build_second_order(self) -> None:
        for previous, neighbours in enumerate(self._graph):
            previous_set = frozenset(neighbours)
            for current in neighbours:
                candidates = self._graph[current]
                if not candidates:
                    continue
                self._second_order[(previous, current)] = AliasTable(
                    [self._weight(previous, previous_set, candidate) for candidate in candidates]
                )

    def _weight(self, previous: int, previous_set: frozenset[int], candidate: int) -> float:
        """d(previous, candidate) is 0, 1 or 2 and nothing else."""
        if candidate == previous:
            return 1.0 / self._config.return_p
        if candidate in previous_set:
            return 1.0
        return 1.0 / self._config.inout_q

    def walk(self, start: int, rng: random.Random) -> list[int]:
        """One second-order walk, every step an O(1) alias draw."""
        first = self._first_order[start]
        if first is None:
            return [start]

        walk = [start, self._graph[start][first.draw(rng)]]
        while len(walk) < self._config.walk_length:
            previous, current = walk[-2], walk[-1]
            table = self._second_order.get((previous, current))
            if table is None:
                break
            walk.append(self._graph[current][table.draw(rng)])
        return walk


def sigmoid(value: float) -> float:
    """Branch on sign so exp never overflows on a large negative score."""
    if value >= 0.0:
        return 1.0 / (1.0 + math.exp(-value))
    positive = math.exp(value)
    return positive / (1.0 + positive)


def train(
    graph: Graph,
    sampler: Node2VecSampler,
    config: WalkConfig,
    dimensions: int = 128,
    window: int = 5,
    negatives: int = 5,
    learning_rate: float = 0.025,
    epochs: int = 5,
    seed: int = 1234,
) -> Embeddings:
    """Skip-gram with negative sampling. Seeded, because a walk-based method
    is otherwise not reproducible even on identical data."""
    if dimensions < 1:
        raise InvalidWalkParameters('dimensions must be positive')

    rng = random.Random(seed)
    num_nodes = len(graph)

    centre = [
        [(rng.random() - 0.5) / dimensions for _ in range(dimensions)]
        for _ in range(num_nodes)
    ]
    context = [[0.0] * dimensions for _ in range(num_nodes)]

    # Degree to the 3/4 power, built once. Recomputing it per draw would
    # discard the entire benefit of the alias table.
    noise = AliasTable([max(len(neighbours), 1) ** 0.75 for neighbours in graph])

    for epoch in range(epochs):
        rate = learning_rate * max(1.0 - epoch / epochs, 1e-4)
        order = list(range(num_nodes))
        rng.shuffle(order)

        for _ in range(config.walks_per_node):
            for start in order:
                walk = sampler.walk(start, rng)
                _train_on_walk(walk, centre, context, noise, rng,
                               window, negatives, dimensions, rate)

    return Embeddings(centre=centre, context=context)


def _train_on_walk(
    walk: Sequence[int],
    centre: list[list[float]],
    context: list[list[float]],
    noise: AliasTable,
    rng: random.Random,
    window: int,
    negatives: int,
    dimensions: int,
    rate: float,
) -> None:
    """One walk's worth of updates. Extracted so the epoch loop stays flat."""
    for position, target in enumerate(walk):
        low = max(0, position - window)
        high = min(len(walk), position + window + 1)
        target_vector = centre[target]

        for neighbour_position in range(low, high):
            if neighbour_position == position:
                continue

            gradient = [0.0] * dimensions
            samples = [(walk[neighbour_position], 1.0)]
            samples.extend((noise.draw(rng), 0.0) for _ in range(negatives))

            for node, label in samples:
                context_vector = context[node]
                score = sum(a * b for a, b in zip(target_vector, context_vector))
                error = (sigmoid(score) - label) * rate
                for d in range(dimensions):
                    gradient[d] += error * context_vector[d]
                    context_vector[d] -= error * target_vector[d]

            for d in range(dimensions):
                target_vector[d] -= gradient[d]

What changed: Sampling was the bottleneck, so sampling is what changes: the alias method replaces an O(degree) linear scan with two array lookups after an O(degree) build, and the second-order bias is precomputed once per directed edge instead of being rebuilt on every walk step. That trade is stated explicitly in the sampler docstring rather than hidden, because the memory cost is quadratic in degree and is the usual reason a node2vec job dies on a dense graph — a reader needs to know that before choosing it over first-order DeepWalk. The two embedding tables come back in a NamedTuple that names which is which, since exporting the context table produces vectors that work just well enough to conceal the error. Parameter validation moves into a frozen config so every method downstream can assume p, q and the walk length are sane, the seed becomes an explicit argument because a walk-based method is otherwise irreproducible on identical data, and the sigmoid branches on sign so a large negative score cannot overflow.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionalsNo mutable default arguments

Sampling drops from O(degree) to O(1) per step, at a one-time O(sum of neighbour degrees) build and the matching memory. Illustrative, not a measured benchmark: on a hub-heavy graph this is the difference between sampling dominating the runtime and the gradient updates dominating it.

3. OptimizedNumPy

Faster, and the justification for each change.

"""Walks and updates in bulk. The alias draw vectorizes; the SGD batches.

Three changes:

  1. Alias sampling becomes array arithmetic. A draw is a uniform bucket
     index and a uniform coin, so N draws are two random arrays and one
     np.where - the whole walk frontier advances in a single step.
  2. Walks advance a FRONTIER rather than one node at a time: all N walks
     take their step t together, so the Python loop is over walk length
     (tens) rather than over walks (millions).
  3. Skip-gram updates batch. A block of pairs becomes two gathers, one
     row-wise dot, and a scatter-add - and the scatter must accumulate,
     because a hub node appears many times in one batch.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import NDArray

FLOAT = np.float32
INDEX = np.int32


class VectorizedAlias:
    """Alias tables for every row of a ragged distribution, in flat arrays.

    Ragged rows are stored contiguously with an offset array - the same CSR
    idea as a sparse matrix - so a draw for any row is pure index arithmetic
    and no Python-level dispatch is needed to find the right table.
    """

    def __init__(self, offsets: NDArray[np.int32], weights: NDArray[np.float32]) -> None:
        self._offsets = offsets
        self._probability = np.empty_like(weights)
        self._alias = np.zeros(weights.shape, dtype=INDEX)

        for row in range(len(offsets) - 1):
            begin, end = int(offsets[row]), int(offsets[row + 1])
            if end <= begin:
                continue
            self._build_row(weights[begin:end], begin, end)

    def _build_row(self, weights: NDArray[np.float32], begin: int, end: int) -> None:
        size = end - begin
        scaled = weights * (size / weights.sum())
        probability = self._probability[begin:end]
        alias = self._alias[begin:end]

        small = [i for i in range(size) if scaled[i] < 1.0]
        large = [i for i in range(size) if scaled[i] >= 1.0]
        while small and large:
            lesser, greater = small.pop(), large.pop()
            probability[lesser] = scaled[lesser]
            alias[lesser] = greater
            scaled[greater] -= 1.0 - scaled[lesser]
            (small if scaled[greater] < 1.0 else large).append(greater)
        for remaining in small + large:
            probability[remaining] = 1.0

    def draw(self, rows: NDArray[np.int32], rng: np.random.Generator) -> NDArray[np.int32]:
        """One draw per entry of `rows`, all at once.

        A draw is: uniform bucket, uniform coin, take the bucket or its alias.
        Both are array operations, so there is no per-draw Python work.
        """
        begin = self._offsets[rows]
        sizes = self._offsets[rows + 1] - begin
        bucket = (rng.random(len(rows)) * sizes).astype(INDEX)
        flat = begin + bucket
        coin = rng.random(len(rows), dtype=FLOAT)
        # np.where, not a branch: taking the alias is a select over the whole
        # batch rather than a decision made one draw at a time.
        return np.where(coin < self._probability[flat], bucket, self._alias[flat]).astype(INDEX)


def generate_walks(
    indptr: NDArray[np.int32],
    indices: NDArray[np.int32],
    alias: VectorizedAlias,
    starts: NDArray[np.int32],
    length: int,
    rng: np.random.Generator,
) -> NDArray[np.int32]:
    """Advance every walk's frontier together. Returns (num_walks, length).

    The Python loop is over `length` - tens of iterations - rather than over
    the millions of walks, which is the whole structural change.
    """
    num_walks = len(starts)
    walks = np.empty((num_walks, length), dtype=INDEX)
    walks[:, 0] = starts
    frontier = starts

    for step in range(1, length):
        # One batched alias draw for every walk, then one gather to turn the
        # local neighbour offset into a global node id.
        local = alias.draw(frontier, rng)
        frontier = indices[indptr[frontier] + local]
        walks[:, step] = frontier

    return walks


def build_pairs(walks: NDArray[np.int32], window: int) -> tuple[NDArray[np.int32], NDArray[np.int32]]:
    """Every (centre, context) pair inside the window, as two flat arrays.

    Built by shifting the walk matrix rather than by iterating positions: an
    offset of k pairs column j with column j + k for every walk at once.
    """
    centres: list[NDArray[np.int32]] = []
    contexts: list[NDArray[np.int32]] = []
    length = walks.shape[1]

    for offset in range(1, window + 1):
        if offset >= length:
            break
        left, right = walks[:, :-offset], walks[:, offset:]
        centres.append(left.ravel())
        contexts.append(right.ravel())
        centres.append(right.ravel())
        contexts.append(left.ravel())

    return np.concatenate(centres), np.concatenate(contexts)


def train_batch(
    centre: NDArray[np.float32],
    context: NDArray[np.float32],
    centre_ids: NDArray[np.int32],
    context_ids: NDArray[np.int32],
    noise_table: NDArray[np.int32],
    negatives: int,
    rate: float,
    rng: np.random.Generator,
) -> None:
    """One batched SGNS step, updating both tables in place.

    Positives and negatives are concatenated into one block so the whole step
    is a single gather, a single row-wise dot, and a single scatter-add.
    """
    batch = len(centre_ids)
    drawn = noise_table[rng.integers(0, len(noise_table), size=batch * negatives)]

    rows = np.repeat(centre_ids, negatives + 1)
    columns = np.empty(batch * (negatives + 1), dtype=INDEX)
    columns[0::negatives + 1] = context_ids
    mask = np.ones(len(columns), dtype=bool)
    mask[0::negatives + 1] = False
    columns[mask] = drawn

    labels = np.zeros(len(columns), dtype=FLOAT)
    labels[0::negatives + 1] = 1.0

    # Two gathers, then one row-wise dot: einsum over matching rows avoids
    # materializing the full outer product a plain matmul would build.
    target = centre[rows]
    paired = context[columns]
    scores = np.einsum('ij,ij->i', target, paired)

    # Stable logistic in place; the gradient is (sigmoid(score) - label).
    np.negative(scores, out=scores)
    np.exp(scores, out=scores)
    scores += 1.0
    np.reciprocal(scores, out=scores)
    scores -= labels
    scores *= rate

    # Scatter-add, never a plain assignment: a hub node appears many times in
    # one batch and its updates must accumulate rather than overwrite.
    np.add.at(centre, rows, -scores[:, None] * paired)
    np.add.at(context, columns, -scores[:, None] * target)


def build_noise_table(degrees: NDArray[np.int32], resolution: int = 10_000_000) -> NDArray[np.int32]:
    """Degree^0.75 flattened into a uniform lookup table.

    Pre-expanding the distribution into a table makes a negative draw one
    uniform integer and one gather, with no per-draw search at all.
    """
    weights = np.maximum(degrees, 1).astype(np.float64) ** 0.75
    cumulative = np.cumsum(weights / weights.sum())
    positions = np.linspace(0.0, 1.0, resolution, endpoint=False)
    return np.searchsorted(cumulative, positions).astype(INDEX)

What changed: The change follows the profile rather than the intuition: sampling dominates, so sampling is what gets vectorized. An alias draw decomposes into a uniform bucket index and a uniform coin, both of which are array operations, so N draws become two random arrays and one select — and the ragged per-node tables are stored CSR-style in flat arrays so finding the right table is index arithmetic rather than a Python dispatch. Walks then advance as a frontier: every walk takes step t together, which turns the outer Python loop from millions of walks into tens of steps. Pair construction shifts the walk matrix instead of iterating positions, since an offset of k pairs column j with column j plus k for every walk simultaneously. The SGD batches: positives and negatives are concatenated into one block, the scoring is an einsum over matching rows rather than a matmul that would materialize the full outer product, and the writes are scatter-adds because a hub node appears many times in a single batch and its updates must accumulate. The noise distribution is pre-expanded into a uniform lookup table, so a negative draw is one integer and one gather.

  • Eliminate Python-level loops over samples

    The walk frontier, the pair construction and the SGNS step all become whole-array operations, leaving Python loops only over walk length and epochs.

    Trade-off: All walks must be held simultaneously to advance them together, so peak memory is the number of walks times the walk length in int32 — on a large graph that is the largest allocation in the pipeline and forces chunking.

  • Batch work to amortize interpreter overhead

    One batched step covers thousands of pairs and their negatives, so the per-pair interpreter cost that dominated the scalar version disappears entirely.

    Trade-off: Batching changes the algorithm: the scalar version sees each update applied before the next is computed, while a batch computes every gradient against the same stale weights — larger batches degrade convergence per pair and need a compensating learning-rate change.

  • Pre-allocate output arrays and use in-place operations

    The walk matrix, the column array and the logistic transform are all written into pre-sized buffers, so the inner sequence of exp, add, reciprocal allocates nothing.

    Trade-off: The score buffer is destroyed by the in-place logistic, so nothing downstream can inspect the raw scores — which is exactly what you want when diagnosing a collapsed embedding.

  • Ensure contiguous memory layout and a single dtype

    int32 ids and float32 embeddings halve the bandwidth of the gathers, which are the memory-bound part of the step, and the CSR-style flat alias arrays keep the draws contiguous.

    Trade-off: int32 caps the graph at roughly two billion nodes, and float32 accumulation across many small updates to the same hub row drifts measurably — visible as hub embeddings that are noisier than their sample count suggests.

Walk generation becomes O(length) array steps instead of O(walks * length) interpreter steps; the SGD becomes one gather-dot-scatter per batch. Illustrative, not a measured benchmark: the remaining hot spot is np.add.at, which is unbuffered and slower than the arithmetic it follows — the reason production implementations use a lock-free compiled inner loop rather than array code.