# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
NetMF: Network Embedding as Matrix Factorization
Implementation based on:
Qiu, J., Dong, Y., Ma, H., Li, J., Wang, K., & Tang, J. (2018).
Network embedding as matrix factorization: Unifying deepwalk, line, pte, and node2vec.
In Proceedings of the Eleventh ACM International Conference on Web Search and Data Mining (pp. 459-467).
Reference: https://github.com/xptree/NetMF
"""
import numpy as np
import networkx as nx
from scipy import sparse
from scipy.sparse import linalg as sparse_linalg
from typing import List, Optional
[docs]
def run_netmf(
graph: nx.Graph,
nodes: List,
dimensions: int = 128,
window_size: int = 10,
negative: int = 1,
rank: Optional[int] = None,
use_svd: bool = True,
seed: Optional[int] = None
) -> np.ndarray:
"""
Run NetMF (Network Embedding as Matrix Factorization) on a graph.
NetMF provides a closed-form solution for network embedding by computing
the matrix factorization of a modified adjacency matrix that captures
higher-order proximity.
Parameters
----------
graph : nx.Graph
Input graph
nodes : list
List of nodes (for ordering)
dimensions : int, default=128
Embedding dimension
window_size : int, default=10
Context window size (similar to DeepWalk/Node2Vec)
negative : int, default=1
Number of negative samples (affects the matrix transformation)
rank : int, optional
Rank for SVD approximation. If None, uses dimensions.
use_svd : bool, default=True
Whether to use SVD (True) or eigendecomposition (False)
seed : int, optional
Random seed for reproducibility
Returns
-------
np.ndarray
Node embeddings matrix of shape (n_nodes, dimensions)
Notes
-----
NetMF computes embeddings by:
1. Computing the transition matrix P from the adjacency matrix
2. Computing the volume (sum of degrees)
3. Computing the DeepWalk matrix: log(vol(G) * (sum_{r=1}^T P^r) / T / b) - log(b)
where b is the number of negative samples
4. Performing SVD/eigendecomposition to get the embedding
References
----------
Qiu et al. (2018). Network embedding as matrix factorization: Unifying
deepwalk, line, pte, and node2vec. WSDM 2018.
"""
if seed is not None:
np.random.seed(seed)
# Create node to index mapping
node_to_idx = {node: idx for idx, node in enumerate(nodes)}
n_nodes = len(nodes)
# Build adjacency matrix
A = nx.to_scipy_sparse_array(graph, nodelist=nodes, format='csr', dtype=np.float64)
# Compute degree matrix
degrees = np.array(A.sum(axis=1)).flatten()
# Avoid division by zero
degrees[degrees == 0] = 1
# Compute transition matrix P = D^{-1} A
D_inv = sparse.diags(1.0 / degrees, format='csr')
P = D_inv @ A
# Compute volume (sum of all degrees)
vol = degrees.sum()
# Compute the sum of powers of P: sum_{r=1}^{window_size} P^r
# This captures multi-hop proximity
M = sparse.csr_matrix((n_nodes, n_nodes), dtype=np.float64)
P_power = P.copy()
for r in range(1, window_size + 1):
M = M + P_power
if r < window_size:
P_power = P_power @ P
# Average over window size
M = M / window_size
# Compute the DeepWalk matrix
# M_dw = log(vol * M / b) - log(b)
# where b is the number of negative samples
M = M.multiply(vol / negative)
# Convert to dense for log operation (only for non-zero elements)
M_dense = M.toarray()
# Apply log transformation (avoid log(0))
M_dense[M_dense > 0] = np.log(M_dense[M_dense > 0])
M_dense = M_dense - np.log(negative)
# Set negative values to 0 (as in the original NetMF paper)
M_dense[M_dense < 0] = 0
# Perform matrix factorization
if rank is None:
rank = min(dimensions, n_nodes - 1)
if use_svd:
# Use SVD for factorization
try:
# Use sparse SVD if matrix is large
if n_nodes > 500:
U, S, Vt = sparse_linalg.svds(sparse.csr_matrix(M_dense), k=rank)
# svds returns singular values in ascending order, reverse them
U = U[:, ::-1]
S = S[::-1]
else:
U, S, Vt = np.linalg.svd(M_dense, full_matrices=False)
U = U[:, :rank]
S = S[:rank]
# Embedding is U * sqrt(S)
embeddings = U @ np.diag(np.sqrt(S))
except (np.linalg.LinAlgError, sparse_linalg.ArpackError, ValueError) as exc:
# Do NOT fall back to random noise. This function previously returned
# np.random.randn(...) here, so a failed factorization was reported to
# the caller as a successful embedding and scored as if it were one.
# run_netmf is reached through baselines.registry, which catches this
# and records the method as failed with the reason attached -- which is
# the honest outcome for "NetMF could not factorize this graph".
raise RuntimeError(
f"NetMF truncated SVD failed on a {n_nodes}-node graph at rank "
f"{rank}: {exc}. Try a smaller `dimensions`, or a graph with more "
f"than {rank + 1} nodes."
) from exc
else:
# Use eigendecomposition (symmetric approximation)
M_sym = (M_dense + M_dense.T) / 2
try:
if n_nodes > 500:
eigenvalues, eigenvectors = sparse_linalg.eigsh(
sparse.csr_matrix(M_sym), k=rank, which='LA'
)
# eigsh returns eigenvalues in ascending order, reverse them
eigenvalues = eigenvalues[::-1]
eigenvectors = eigenvectors[:, ::-1]
else:
eigenvalues, eigenvectors = np.linalg.eigh(M_sym)
# Take top k eigenvalues
idx = eigenvalues.argsort()[::-1][:rank]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]
# Embedding is eigenvectors * sqrt(eigenvalues)
eigenvalues[eigenvalues < 0] = 0 # Ensure non-negative
embeddings = eigenvectors @ np.diag(np.sqrt(eigenvalues))
except (np.linalg.LinAlgError, sparse_linalg.ArpackError, ValueError) as exc:
# See the SVD branch above: a random-noise embedding is not a result.
raise RuntimeError(
f"NetMF eigendecomposition failed on a {n_nodes}-node graph at "
f"rank {rank}: {exc}. Try a smaller `dimensions`, or a graph with "
f"more than {rank + 1} nodes."
) from exc
# Ensure we have the right number of dimensions
if embeddings.shape[1] < dimensions:
# Pad with zeros if needed
padding = np.zeros((n_nodes, dimensions - embeddings.shape[1]))
embeddings = np.hstack([embeddings, padding])
elif embeddings.shape[1] > dimensions:
# Truncate if needed
embeddings = embeddings[:, :dimensions]
return embeddings