6b7e6b44f1
gh-pages / build (push) Waiting to run
Python Publish (pypi) / Upload release to PyPI (push) Waiting to run
Spellcheck / spellcheck (push) Waiting to run
Python Build and Type Check / python-ci (ubuntu-latest, 3.11) (push) Has been cancelled
Python Build and Type Check / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Build and Type Check / python-ci (windows-latest, 3.11) (push) Has been cancelled
Python Build and Type Check / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Integration Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Integration Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Notebook Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Notebook Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Smoke Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Smoke Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
Python Unit Tests / python-ci (ubuntu-latest, 3.13) (push) Has been cancelled
Python Unit Tests / python-ci (windows-latest, 3.13) (push) Has been cancelled
263 lines
8.2 KiB
Python
263 lines
8.2 KiB
Python
# Copyright (c) 2024 Microsoft Corporation.
|
|
# Licensed under the MIT License
|
|
|
|
"""Side-by-side tests for the DataFrame-based modularity utility."""
|
|
|
|
import json
|
|
import math
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import networkx as nx
|
|
import pandas as pd
|
|
from graphrag.graphs.modularity import modularity
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NX reference implementation (copied from graphrag.index.utils.graphs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _nx_modularity_component(
|
|
intra_community_degree: float,
|
|
total_community_degree: float,
|
|
network_degree_sum: float,
|
|
resolution: float,
|
|
) -> float:
|
|
community_degree_ratio = math.pow(total_community_degree, 2.0) / (
|
|
2.0 * network_degree_sum
|
|
)
|
|
return (intra_community_degree - resolution * community_degree_ratio) / (
|
|
2.0 * network_degree_sum
|
|
)
|
|
|
|
|
|
def _nx_modularity_components(
|
|
graph: nx.Graph,
|
|
partitions: dict[Any, int],
|
|
weight_attribute: str = "weight",
|
|
resolution: float = 1.0,
|
|
) -> dict[int, float]:
|
|
total_edge_weight = 0.0
|
|
communities = set(partitions.values())
|
|
|
|
degree_sums_within_community: dict[int, float] = defaultdict(float)
|
|
degree_sums_for_community: dict[int, float] = defaultdict(float)
|
|
for vertex, neighbor_vertex, weight in graph.edges(data=weight_attribute):
|
|
vertex_community = partitions[vertex]
|
|
neighbor_community = partitions[neighbor_vertex]
|
|
if vertex_community == neighbor_community:
|
|
if vertex == neighbor_vertex:
|
|
degree_sums_within_community[vertex_community] += weight
|
|
else:
|
|
degree_sums_within_community[vertex_community] += weight * 2.0
|
|
degree_sums_for_community[vertex_community] += weight
|
|
degree_sums_for_community[neighbor_community] += weight
|
|
total_edge_weight += weight
|
|
|
|
return {
|
|
comm: _nx_modularity_component(
|
|
degree_sums_within_community[comm],
|
|
degree_sums_for_community[comm],
|
|
total_edge_weight,
|
|
resolution,
|
|
)
|
|
for comm in communities
|
|
}
|
|
|
|
|
|
def nx_modularity(
|
|
graph: nx.Graph,
|
|
partitions: dict[Any, int],
|
|
weight_attribute: str = "weight",
|
|
resolution: float = 1.0,
|
|
) -> float:
|
|
"""NX reference: compute modularity from a networkx graph."""
|
|
components = _nx_modularity_components(
|
|
graph, partitions, weight_attribute, resolution
|
|
)
|
|
return sum(components.values())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_fixture() -> pd.DataFrame:
|
|
"""Load the realistic graph fixture as a relationships DataFrame."""
|
|
with open(FIXTURES_DIR / "graph.json") as f:
|
|
data = json.load(f)
|
|
return pd.DataFrame(data["edges"])
|
|
|
|
|
|
def _make_edges(*edges: tuple[str, str, float]) -> pd.DataFrame:
|
|
"""Build a relationships DataFrame from (source, target, weight) tuples."""
|
|
return pd.DataFrame([{"source": s, "target": t, "weight": w} for s, t, w in edges])
|
|
|
|
|
|
def _edges_to_nx(edges: pd.DataFrame) -> nx.Graph:
|
|
"""Build an NX graph from an edges DataFrame."""
|
|
return nx.from_pandas_edgelist(edges, edge_attr=["weight"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Side-by-side tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_two_clear_communities():
|
|
"""Two densely-connected communities with a weak bridge."""
|
|
edges = _make_edges(
|
|
("A", "B", 1.0),
|
|
("B", "C", 1.0),
|
|
("A", "C", 1.0),
|
|
("D", "E", 1.0),
|
|
("E", "F", 1.0),
|
|
("D", "F", 1.0),
|
|
("C", "D", 0.1),
|
|
)
|
|
partitions = {"A": 0, "B": 0, "C": 0, "D": 1, "E": 1, "F": 1}
|
|
|
|
nx_result = nx_modularity(_edges_to_nx(edges), partitions)
|
|
df_result = modularity(edges, partitions)
|
|
|
|
assert abs(nx_result - df_result) < 1e-10
|
|
assert df_result > 0 # good partition should be positive
|
|
|
|
|
|
def test_single_community():
|
|
"""All nodes in one community — modularity should be zero."""
|
|
edges = _make_edges(
|
|
("A", "B", 1.0),
|
|
("B", "C", 1.0),
|
|
("A", "C", 1.0),
|
|
)
|
|
partitions = {"A": 0, "B": 0, "C": 0}
|
|
|
|
nx_result = nx_modularity(_edges_to_nx(edges), partitions)
|
|
df_result = modularity(edges, partitions)
|
|
|
|
assert abs(nx_result - df_result) < 1e-10
|
|
assert abs(df_result) < 1e-10
|
|
|
|
|
|
def test_every_node_own_community():
|
|
"""Each node in its own community — modularity should be negative."""
|
|
edges = _make_edges(
|
|
("A", "B", 1.0),
|
|
("B", "C", 1.0),
|
|
("A", "C", 1.0),
|
|
)
|
|
partitions = {"A": 0, "B": 1, "C": 2}
|
|
|
|
nx_result = nx_modularity(_edges_to_nx(edges), partitions)
|
|
df_result = modularity(edges, partitions)
|
|
|
|
assert abs(nx_result - df_result) < 1e-10
|
|
assert df_result < 0
|
|
|
|
|
|
def test_reversed_edges():
|
|
"""Reversed edge direction should not affect modularity (undirected)."""
|
|
edges_fwd = _make_edges(("A", "B", 1.0), ("B", "C", 1.0), ("C", "D", 1.0))
|
|
edges_rev = _make_edges(("B", "A", 1.0), ("C", "B", 1.0), ("D", "C", 1.0))
|
|
partitions = {"A": 0, "B": 0, "C": 1, "D": 1}
|
|
|
|
fwd = modularity(edges_fwd, partitions)
|
|
rev = modularity(edges_rev, partitions)
|
|
|
|
assert abs(fwd - rev) < 1e-10
|
|
|
|
|
|
def test_weighted_edges():
|
|
"""Different weights should affect modularity."""
|
|
edges_uniform = _make_edges(
|
|
("A", "B", 1.0),
|
|
("B", "C", 1.0),
|
|
("C", "D", 1.0),
|
|
)
|
|
edges_weighted = _make_edges(
|
|
("A", "B", 5.0),
|
|
("B", "C", 0.1),
|
|
("C", "D", 5.0),
|
|
)
|
|
partitions = {"A": 0, "B": 0, "C": 1, "D": 1}
|
|
|
|
u_nx = nx_modularity(_edges_to_nx(edges_uniform), partitions)
|
|
u_df = modularity(edges_uniform, partitions)
|
|
w_nx = nx_modularity(_edges_to_nx(edges_weighted), partitions)
|
|
w_df = modularity(edges_weighted, partitions)
|
|
|
|
assert abs(u_nx - u_df) < 1e-10
|
|
assert abs(w_nx - w_df) < 1e-10
|
|
# weighted version should have higher modularity (strong intra, weak inter)
|
|
assert w_df > u_df
|
|
|
|
|
|
def test_custom_resolution():
|
|
"""Resolution parameter should affect result and match NX."""
|
|
edges = _make_edges(
|
|
("A", "B", 1.0),
|
|
("B", "C", 1.0),
|
|
("A", "C", 1.0),
|
|
("D", "E", 1.0),
|
|
("C", "D", 0.5),
|
|
)
|
|
partitions = {"A": 0, "B": 0, "C": 0, "D": 1, "E": 1}
|
|
graph = _edges_to_nx(edges)
|
|
|
|
for res in (0.5, 1.0, 2.0):
|
|
nx_r = nx_modularity(graph, partitions, resolution=res)
|
|
df_r = modularity(edges, partitions, resolution=res)
|
|
assert abs(nx_r - df_r) < 1e-10
|
|
|
|
|
|
def test_duplicate_edges():
|
|
"""Duplicate edges (same pair, different weights) should match NX dedup."""
|
|
edges = _make_edges(
|
|
("A", "B", 1.0),
|
|
("A", "B", 3.0), # duplicate — NX keeps last
|
|
("B", "C", 2.0),
|
|
)
|
|
partitions = {"A": 0, "B": 0, "C": 1}
|
|
|
|
nx_result = nx_modularity(_edges_to_nx(edges), partitions)
|
|
df_result = modularity(edges, partitions)
|
|
|
|
assert abs(nx_result - df_result) < 1e-10
|
|
|
|
|
|
def test_reversed_duplicate_edges():
|
|
"""Edge (A,B) and (B,A) should be treated as the same, keeping last weight."""
|
|
edges = _make_edges(
|
|
("A", "B", 1.0),
|
|
("B", "A", 5.0), # reversed duplicate — NX keeps 5.0
|
|
("B", "C", 2.0),
|
|
)
|
|
partitions = {"A": 0, "B": 0, "C": 1}
|
|
|
|
nx_result = nx_modularity(_edges_to_nx(edges), partitions)
|
|
df_result = modularity(edges, partitions)
|
|
|
|
assert abs(nx_result - df_result) < 1e-10
|
|
|
|
|
|
def test_fixture_matches_nx():
|
|
"""Modularity on the fixture graph should match NX for several partitions."""
|
|
edges = _load_fixture()
|
|
graph = _edges_to_nx(edges)
|
|
nodes = sorted(graph.nodes())
|
|
|
|
# Test with a few different partition schemes
|
|
for n_communities in (2, 3, 5):
|
|
partitions = {node: i % n_communities for i, node in enumerate(nodes)}
|
|
nx_result = nx_modularity(graph, partitions)
|
|
df_result = modularity(edges, partitions)
|
|
assert abs(nx_result - df_result) < 1e-10, (
|
|
f"Mismatch for {n_communities} communities: NX={nx_result}, DF={df_result}"
|
|
)
|