Overview

By default, PRISM optimizes three built-in multi-resolution modularity objectives in Rust: -Q_0.5, -Q_1.0, and -Q_2.0. These are evaluated in parallel across the swarm and cover the common “I want resolution-diverse community structure” use case.

Just like HP-MOCD, PRISM accepts an objectives= list of Python callables. When provided, the Rust objective evaluator is bypassed entirely — the swarm, the archive, the leader-selection tournament, and the Pareto front are all driven by your callbacks.


Writing an Objective Function

Each objective must follow this signature:

  def objective(graph, partition: dict[int, int]) -> float:
    ...
  
ArgumentTypeDescription
graphYour original graph objectThe same NetworkX or igraph graph you passed to Prism.
partitiondict[int, int]Maps each node ID to its community ID.
returnfloatThe objective value — minimized by the algorithm.

Simple Example

Replace the default -Q_γ triple with a single negated-modularity objective:

  import networkx as nx
from pymocd import Prism

def negative_modularity(G, partition):
    communities = {}
    for node, cid in partition.items():
        communities.setdefault(cid, []).append(node)
    return -nx.community.modularity(G, communities.values())

G = nx.karate_club_graph()
alg = Prism(
    G,
    objectives=[negative_modularity],
    swarm_size=50,
    num_gens=50,
)
solution = alg.run()
  

Any number of objectives is accepted — PRISM’s archive stores the non-dominated front across all of them:

  alg = Prism(G, objectives=[obj_1, obj_2, obj_3])
  

Factory Pattern for Performance

Each objective is called once per particle, per generationswarm_size × num_gens times. Recomputing graph-level constants inside that hot path is wasteful.

The factory pattern fixes this: a factory receives the graph once, precomputes anything that does not depend on the partition, and returns a closure that is what PRISM actually calls:

  import numpy as np
import scipy.sparse as sp

def make_conductance(G):
    nodes = list(G.nodes())
    idx = {v: i for i, v in enumerate(nodes)}
    n = len(nodes)
    src = [idx[u] for u, v in G.edges()] + [idx[v] for u, v in G.edges()]
    dst = [idx[v] for u, v in G.edges()] + [idx[u] for u, v in G.edges()]
    A = sp.csr_matrix((np.ones(len(src)), (src, dst)), shape=(n, n))
    degrees = np.asarray(A.sum(axis=1)).ravel()
    total_vol = degrees.sum()
    rows, cols = A.nonzero()

    def _obj(_G, partition):
        if total_vol == 0:
            return 0.0
        labels = np.array([partition[v] for v in nodes], dtype=np.int32)
        n_comms = labels.max() + 1
        is_cut = (labels[rows] != labels[cols]).astype(np.float64)
        cut_comm = np.bincount(labels[rows], weights=is_cut, minlength=n_comms)
        vol_comm = np.bincount(labels, weights=degrees, minlength=n_comms)
        vol_comp = total_vol - vol_comm
        denom = np.minimum(vol_comm, vol_comp)
        mask = denom > 0
        return float((cut_comm[mask] / denom[mask]).mean()) if mask.any() else 0.0

    return _obj
  

Usage — note the factory is invoked once with G at construction time, and the returned closure is what PRISM calls repeatedly:

  alg = Prism(
    G,
    objectives=[make_conductance(G)],
    swarm_size=50,
    num_gens=50,
)
solution = alg.run()
  

Changing Objectives After Construction

set_objectives() swaps the callable list on an existing instance. Pass an empty list to revert to the built-in Rust -Q_γ triple:

  alg = Prism(G)

# switch to custom objectives
alg.set_objectives([make_conductance(G)])

# revert to built-in Rust multi-resolution modularity
alg.set_objectives([])
  

Progress Tracking

Register a callback with set_on_generation() — invoked after every generation of the swarm:

  from tqdm import tqdm

alg = Prism(G, objectives=[make_conductance(G)], swarm_size=50, num_gens=50)

bar = tqdm(total=alg.num_gens, desc="PRISM", unit="gen")

def on_gen(generation, num_gens, archive_size):
    bar.set_postfix(archive=archive_size)
    bar.update(1)
    if generation == num_gens - 1:
        bar.close()

alg.set_on_generation(on_gen)
solution = alg.run()
  
ArgumentTypeDescription
generationintCurrent generation (0-indexed).
num_gensintTotal number of generations.
archive_sizeintNumber of members currently in the archive.

Performance Considerations

Recommendations:

  • Reduce the evolutionary budget: swarm_size=50, num_gens=50 instead of the default 100/100.
  • Use the factory pattern: precompute anything that does not depend on the partition.
  • Vectorize: numpy + scipy.sparse is 10–100× faster than pure-Python loops.
  • Profile first: the built-in Rust -Q_γ objectives are typically 10–30× faster per evaluation. Only reach for custom objectives when the metric you actually care about is not expressible in the defaults.

Last updated 18 Apr 2026, 22:19 -0300 . history