Anthony Couthures
  • Home
  • Publications
  • Code
    • COSA Simulator
    • SLCD Simulator (community detection)

    • COSA-Simulator (GitHub)
    • Social-Learning-Community-Detection (GitHub)
    • Private Transport Optimization (GitHub)
    • CO2-Game (GitHub)
    • GitHub Profile
  • Talks
    • Talks
    • Posters
  • Contact
  • CV
  • Scholar

On this page

  • 1. How It Works
  • 2. Interactive Demo
    • Things to try
  • 3. Use the Code
  • 4. How to Cite

SLCD Simulator: Social Learning Community Detection

Interactive companion simulator for decentralized community detection with nonlinear opinion dynamics: SER, RNP and RNP-DC running live in the browser.

Official Companion Tool: This simulator accompanies the paper “Social learning community detection with nonlinear interaction” by A. Couthures, A. V. Jayakumar, V. S. Varma, I.-C. Morărescu, S. Lasaulce and A. Girard (preprint, 2026).

GitHub Repository License: MIT arXiv

Can a network find its own communities, without anyone seeing the whole graph? In this model every node is an agent with a private opinion, and it only observes the saturated public actions of its neighbors. When the agents react strongly enough to each other, the network polarizes along its sparsest cuts, and the communities can be read from where the opinions settle. No agent needs a global view of the graph or has to reveal its private opinion.


1. How It Works

Agent \(i\) holds an opinion \(x_i \in [-1, 1]\) and broadcasts the action \(s(x_i) = \min(1, \max(-1, Kx_i))\), a saturated version of its opinion with gain \(K\). At each step, every agent moves towards the average action of its neighbors:

\[x(t+1) = x(t) + h\big(P\, s(x(t)) - x(t)\big), \qquad P = D^{-1}A.\]

  • Weak gain: consensus. If \(K\) is below the threshold \(K^\star = 1/\lambda_{N-1}(P)\), set by the second-largest eigenvalue of \(P\), all agents end up agreeing and nothing is learned about the graph.
  • Strong gain: polarization. Above \(K^\star\), agreement becomes unstable. Densely connected groups lock together on one side, so the network splits along its weakest links.
  • Reading the communities. An edge inside a community almost always connects agents that agree at the end. An edge between communities is often in tension. The three algorithms below turn this observation into a partition.
SER recommended
Score-based Edge Reliability

Run the dynamics \(M\) times from random opinions. In a run, the two ends of an edge agree if

\[|s(x_i) - s(x_j)| < \varepsilon.\]

The reliability \(P_{ij}\) of the edge is the share of runs in which they agree. Keep the edges with \(P_{ij} \geq \tau\): the communities are the connected groups that remain.

RNP
Recursive Neighbor Pruning

Let the network polarize, then cut every edge whose two ends took opposite sides in one of the runs:

\[s(x_i)\, s(x_j) < 0\]

Repeat on what is left until nothing more is cut.

RNP-DC
RNP with Decaying Confidence

Cut edges during the dynamics, as soon as their tension exceeds a confidence radius that shrinks over time:

\[|s(x_i(t)) - s(x_j(t))| > R\rho^{\,t}\]

Works with multi-plateau signals such as a staircase.

All three are fully decentralized: an agent only needs the actions of its own neighbors.


2. Interactive Demo

Pick a network and an algorithm. The detection runs live in your browser, using a JavaScript port of the slcd package that gives the same results as the paper’s code. Each network starts with the parameters used in the paper.

import {mulberry32, makeGraph, sbm, signal, trajectory, ser, rnp, rnpDc, spectralThreshold, nmi, modularity, matchCommunities, forceLayout, drawNetwork, opinionColor} from "./slcd/slcd.js"

Network

viewof graphChoice = Inputs.select(
  ["Stochastic block model", "Zachary karate club", "US college football"],
  {value: "Stochastic block model", label: "Network"}
)
viewof nBlocks = Inputs.range([2, 6], {value: 3, step: 1, label: "Blocks"})
viewof blockSize = Inputs.range([10, 50], {value: 30, step: 5, label: "Block size"})
viewof pIn = Inputs.range([0.05, 0.9], {value: 0.42, step: 0.01, label: "p inside"})
viewof pOut = Inputs.range([0, 0.2], {value: 0.02, step: 0.005, label: "p between"})
viewof newGraph = Inputs.button("🎲 New network")

Dynamics

viewof signalType = Inputs.select(
  new Map([["Saturation", "saturation"], ["tanh", "tanh"], ["Staircase (3 levels)", "staircase"]]),
  {value: "saturation", label: "Signal s(x)"}
)
viewof K = Inputs.range([0.5, 10], {value: preset.K, step: 0.1, label: "Gain K"})
kHint = html`<div class="slcd-hint ${K > spectral.Kstar ? "slcd-hint-ok" : "slcd-hint-warn"}">
  Threshold K* = ${spectral.Kstar.toFixed(2)}.
  ${K > spectral.Kstar ? "K is above it: the network can polarize." : "K is below it: agents reach consensus, so no community can be found."}
</div>`
viewof h = Inputs.range([0.05, 1], {value: preset.h, step: 0.05, label: "Step h"})
viewof T = Inputs.range([10, 400], {value: preset.T, step: 10, label: "Horizon T"})

Algorithm

viewof method = Inputs.radio(["SER", "RNP", "RNP-DC"], {value: "SER"})
viewof tau = Inputs.range([0.5, 1], {value: 1, step: 0.01, label: "Threshold τ"})
viewof epsilon = Inputs.range([0.05, 1], {value: preset.epsilon, step: 0.05, label: "Tolerance ε"})
viewof nRuns = Inputs.range([10, 300], {value: preset.nRuns, step: 10, label: "Runs M"})
viewof R = Inputs.range([0.5, 20], {value: 4, step: 0.5, label: "Radius R"})
viewof rho = Inputs.range([0.9, 0.999], {value: 0.98, step: 0.001, label: "Decay ρ"})
viewof nRetry = Inputs.range([5, 100], {value: 30, step: 5, label: "Runs per round"})
viewof rerun = Inputs.button("🔁 Run again")
viewof advanced = Inputs.toggle({label: "Show advanced settings", value: false})
// Show only the controls that matter for the current network, algorithm and level of detail.
controlVisibility = {
  const show = (v, on) => { (v.parentElement || v).style.display = on ? "" : "none"; };
  const isSbm = graphChoice === "Stochastic block model";
  for (const v of [viewof nBlocks, viewof blockSize, viewof pIn, viewof pOut, viewof newGraph]) show(v, isSbm);
  show(viewof h, advanced);
  show(viewof T, advanced);
  show(viewof tau, method === "SER");
  show(viewof epsilon, method === "SER" && advanced);
  show(viewof nRuns, method === "SER" && advanced);
  show(viewof R, method === "RNP-DC");
  show(viewof rho, method === "RNP-DC");
  show(viewof nRetry, method === "RNP" || (method === "RNP-DC" && advanced));
  return null;
}
KARATE_EDGES = [[0,1,4],[0,2,5],[0,3,3],[0,4,3],[0,5,3],[0,6,3],[0,7,2],[0,8,2],[0,10,2],[0,11,3],[0,12,1],[0,13,3],[0,17,2],[0,19,2],[0,21,2],[0,31,2],[1,2,6],[1,3,3],[1,7,4],[1,13,5],[1,17,1],[1,19,2],[1,21,2],[1,30,2],[2,3,3],[2,7,4],[2,8,5],[2,9,1],[2,13,3],[2,27,2],[2,28,2],[2,32,2],[3,7,3],[3,12,3],[3,13,3],[4,6,2],[4,10,3],[5,6,5],[5,10,3],[5,16,3],[6,16,3],[8,30,3],[8,32,3],[8,33,4],[9,33,2],[13,33,3],[14,32,3],[14,33,2],[15,32,3],[15,33,4],[18,32,1],[18,33,2],[19,33,1],[20,32,3],[20,33,1],[22,32,2],[22,33,3],[23,25,5],[23,27,4],[23,29,3],[23,32,5],[23,33,4],[24,25,2],[24,27,3],[24,31,2],[25,31,7],[26,29,4],[26,33,2],[27,33,4],[28,31,2],[28,33,2],[29,32,4],[29,33,2],[30,32,3],[30,33,3],[31,32,4],[31,33,4],[32,33,5]]

// Faction of each member after the split (0 = Mr. Hi, 1 = Officer), as in networkx.
KARATE_LABELS = [..."0000000001000011001010111111111111"].map(Number)

footballData = FileAttachment("slcd/football.json").json()

// Parameters of the paper for each network (karate: Fig. 2; football: Fig. 6; SBM: quickstart).
preset = ({
  "Stochastic block model": {K: 4, h: 0.1, T: 30, epsilon: 0.5, nRuns: 100},
  "Zachary karate club": {K: 5, h: 0.1, T: 50, epsilon: 0.2, nRuns: 200},
  "US college football": {K: 6, h: 0.1, T: 200, epsilon: 0.25, nRuns: 100}
})[graphChoice]

graph = {
  if (graphChoice === "Zachary karate club") {
    return makeGraph(34, KARATE_EDGES, KARATE_LABELS, {
      groups: ["Mr. Hi's faction", "Officer's faction"],
      names: Array.from({length: 34}, (_, i) => `Member ${i + 1}`),
      seed: 34
    });
  }
  if (graphChoice === "US college football") {
    const d = footballData;
    return makeGraph(d.names.length, d.edges, d.labels, {groups: d.groups, names: d.names, seed: 115});
  }
  newGraph;
  const seed = Math.floor(Math.random() * 2 ** 31);
  const g = sbm(nBlocks, blockSize, pIn, pOut, seed);
  g.names = Array.from({length: g.n}, (_, i) => `Node ${i + 1}`);
  g.seed = seed;
  return g;
}

spectral = spectralThreshold(graph)

sFun = signal(signalType, K)

// Static force-directed layout, computed once per graph.
layout = forceLayout(d3, graph)

// One realization of the dynamics, replayed on the network on demand.
trajRun = {
  rerun;
  const rng = mulberry32(graph.seed * 7919 + rerun + 1);
  const x0 = Float64Array.from({length: graph.n}, () => 2 * rng() - 1);
  return trajectory(graph, {s: sFun, h, T, x0, confidence: method === "RNP-DC" ? {R, rho} : null, maxFrames: 160, logTime: true});
}

// ---------------------------------------------------------------- detection
detection = {
  rerun;
  let cancelled = false;
  invalidation.then(() => (cancelled = true));
  const rng = mulberry32(Math.floor(Math.random() * 2 ** 31));
  const opts = {s: sFun, h, T, rng};
  const gen = method === "SER" ? ser(graph, {...opts, epsilon, tau, nRuns})
    : method === "RNP" ? rnp(graph, {...opts, nRetry})
    : rnpDc(graph, {...opts, R, rho, nRetry});
  const t0 = performance.now();
  let r = gen.next();
  while (!r.done) {
    // Work in slices of ~25 ms so the page stays responsive.
    const tc = performance.now();
    while (!r.done && performance.now() - tc < 25) r = gen.next();
    if (r.done) break;
    await new Promise((res) => setTimeout(res, 0));
    if (cancelled) return null;
  }
  const res = r.value;
  const labels = Array.from(res.labels);
  const sizes = d3.rollup(labels, (v) => v.length, (d) => d);
  res.ms = performance.now() - t0;
  res.nComm = [...sizes.values()].filter((s) => s > 1).length;
  res.nSingle = [...sizes.values()].filter((s) => s === 1).length;
  res.nmi = nmi(labels, graph.labels);
  res.Q = modularity(graph, labels);
  res.match = matchCommunities(labels, graph.labels);
  res.graph = graph;
  return res;
}

// ---------------------------------------------------------------- colours
groupColor = (k) => `var(--slcd-c${(k % 12) + 1})`
networkView = {
  const g = graph, pos = layout, run = trajRun;
  const det = detection && detection.graph === g ? detection : null;
  const W = Math.max(320, Math.min(width, 980)), H = Math.round(W * (W < 600 ? 0.9 : 0.6));
  // On wide screens, keep the right side free for the legend.
  const {svg, lines, circles} = drawNetwork(d3, g, pos, {width: W, height: H, padRight: W >= 576 ? 190 : 14});

  // Kept edges stay visible; removed edges are dashed and no heavier than the others.
  const kept = {stroke: "var(--slcd-edge)", width: 1, opacity: 0.7, dash: null};
  const removed = (opacity = 0.75) => ({stroke: "var(--slcd-cut)", width: 1, opacity, dash: "3,3"});

  // ---- result view: detected communities and the edges the algorithm removed
  const edgeResult = (k) => {
    const [u, v] = g.edges[k];
    const base = `${g.names[u]} – ${g.names[v]}`;
    if (!det || det.keep[k]) {
      const note = det && det.method === "SER" ? `\nreliability P = ${det.scores[k].toFixed(2)}` : "";
      return {...kept, title: base + note};
    }
    // SER: the less reliable the edge, the more opaque its dashes.
    if (det.method === "SER") return {...removed(0.3 + 0.6 * (1 - det.scores[k])), title: `${base}\nunreliable: P = ${det.scores[k].toFixed(2)} < τ`};
    return {...removed(), title: `${base}\ncut in round ${det.cutRound[k]}`};
  };
  const nodeResult = (i) => {
    const truth = g.groups[g.labels[i]];
    if (!det) return {fill: "var(--slcd-unmatched)", title: `${g.names[i]} (${truth})`};
    const c = det.labels[i], m = det.match.get(c);
    return {fill: m >= 0 ? groupColor(m) : "var(--slcd-unmatched)", title: `${g.names[i]}\ntrue group: ${truth}\ndetected community #${c + 1}`};
  };

  const edgeTitles = lines.append("title");
  const nodeTitles = circles.append("title");
  const styleEdge = (sel, e) => sel.attr("stroke", e.stroke).attr("stroke-width", e.width)
    .attr("stroke-opacity", e.opacity).attr("stroke-dasharray", e.dash);

  // ---- legend, top right of the network
  const legend = html`<div class="slcd-legend"></div>`;
  const row = (cls, label) => `<div class="slcd-legend-row"><span class="${cls}"></span>${label}</div>`;
  const resultLegend = () => {
    legend.innerHTML = !det ? `<div class="slcd-legend-row">Running the detection…</div>` : [
      row("slcd-lg-fill", "Fill: detected community"),
      row("slcd-lg-ring", "Ring: true group"),
      row("slcd-lg-edge", "Kept edge"),
      row("slcd-lg-cut", det.method === "SER" ? "Unreliable edge (P<sub>ij</sub> &lt; τ)" : `Edge cut by ${det.method}`)
    ].join("");
  };
  const tensionLabel = method === "SER" ? "Ends disagree (≥ ε)" : method === "RNP" ? "Ends on opposite sides" : "Cut by confidence radius";
  const replayLegend = (t) => {
    legend.innerHTML = `<div class="slcd-legend-title">One run, t = ${t.toFixed(1)}</div>`
      + `<div class="slcd-legend-row"><span class="slcd-ramp"></span>Opinion −1 … +1</div>`
      + row("slcd-lg-edge", "Edge")
      + row("slcd-lg-cut", tensionLabel);
  };

  const showResult = () => {
    lines.each(function (_, k) { styleEdge(d3.select(this), edgeResult(k)); });
    edgeTitles.text((_, k) => edgeResult(k).title);
    circles.attr("fill", (i) => nodeResult(i).fill).attr("stroke", (i) => groupColor(g.labels[i]));
    nodeTitles.text((i) => nodeResult(i).title);
    resultLegend();
  };

  // ---- replay of one run: node colour = opinion, dashed edges = in tension
  const tension = (f, k) => {
    const [u, v] = g.edges[k];
    const x = run.frames[f], t = run.times[f];
    if (method === "RNP-DC") return run.cutTime[k] <= t;
    const su = sFun(x[u]), sv = sFun(x[v]);
    return method === "SER" ? Math.abs(su - sv) >= epsilon : su * sv < 0;
  };
  const showFrame = (f) => {
    const x = run.frames[f];
    circles.attr("fill", (i) => opinionColor(x[i])).attr("stroke", "var(--slcd-surface)");
    lines.each(function (_, k) { styleEdge(d3.select(this), tension(f, k) ? removed() : kept); });
    replayLegend(run.times[f]);
  };

  // The play button sits below the results (cell `replayBar`).
  const button = replayBar.querySelector("button");
  let timer = null;
  const stop = () => { clearInterval(timer); timer = null; button.textContent = "▶ Watch one run"; showResult(); };
  button.onclick = () => {
    if (timer) return stop();
    let f = 0, hold = 0;
    button.textContent = "■ Stop";
    timer = setInterval(() => {
      showFrame(f);
      if (f < run.frames.length - 1) f++;
      else if (++hold > 30) stop();
    }, 60);
  };
  invalidation.then(() => { clearInterval(timer); button.textContent = "▶ Watch one run"; });

  showResult();
  return html`<div class="slcd-network-box">${svg.node()}${legend}</div>`;
}
results = {
  const d = detection && detection.graph === graph ? detection : null;
  if (!d) return html`<div class="slcd-results">Running the detection…</div>`;
  const nTrue = graph.groups.length;
  const found = `${d.nComm} communit${d.nComm === 1 ? "y" : "ies"}`
    + (d.nSingle ? ` and ${d.nSingle} isolated node${d.nSingle > 1 ? "s" : ""}` : "");
  const time = d.ms < 1000 ? `${Math.round(d.ms)} ms` : `${(d.ms / 1000).toFixed(1)} s`;
  return html`<div class="slcd-results">
    <div class="slcd-results-main"><strong>${d.method}</strong> found <strong>${found}</strong>; the network has ${nTrue} true groups.</div>
    <div class="slcd-results-stats">
      <span title="Normalized mutual information between the detected communities and the true groups: 1 means identical, 0 means unrelated.">Match with the true groups (NMI): <strong>${d.nmi.toFixed(2)}</strong> / 1</span>
      <span title="Modularity: how much denser the communities are than in a random graph with the same degrees.">Modularity: <strong>${d.Q.toFixed(2)}</strong></span>
      <span>${graph.n} nodes, ${graph.edges.length} edges · ${time}</span>
    </div>
  </div>`;
}
replayBar = html`<div class="slcd-replay">
  <button type="button" class="slcd-play">▶ Watch one run</button>
  <span>Replay one run of the dynamics on the network. Time runs on a logarithmic scale, because polarization is exponentially fast at the start.</span>
</div>`

Things to try

  • Cross the threshold. Lower \(K\) below \(K^\star\): agents reach consensus, no edge is ever in tension, and every algorithm returns a single community.
  • Blur the communities. On the block model, raise p between. Once the blocks are too mixed, the match with the true groups drops.
  • Karate club. SER finds the two famous factions, plus a few smaller groups. Hover the dashed edges: they are the fracture lines of the club.
  • Football. The 12 conferences are recovered almost exactly. The Independents teams have no conference, so they end up isolated or merged with others.
  • Watch it happen. Press ▶ Watch one run to see one realization of the dynamics polarize the network.

3. Use the Code

The Python package slcd has a NetworkX-style API and depends only on NumPy, SciPy and NetworkX. The repository also contains the scripts that reproduce every figure of the paper.

git clone https://github.com/AnthonyCouthures/Social-Learning-Community-Detection.git
cd Social-Learning-Community-Detection
pip install -e .
import networkx as nx
import slcd

G = nx.karate_club_graph()
print("K* =", slcd.spectral_threshold(G))
communities = slcd.ser_communities(G, K=5, epsilon=0.2, h=0.1, seed=0)  # list of sets of nodes
print(nx.community.modularity(G, communities))

rnp_communities and rnp_dc_communities work the same way, and ser_edge_reliability returns the score \(P_{ij}\) of every edge.


4. How to Cite

If you use this code, please cite the paper:

@article{couthures2026social,
  title  = {Social learning community detection with nonlinear interaction},
  author = {Couthures, Anthony and Jayakumar, Athira Varma and Varma, Vineeth Satheeskumar and
            Mor{\u{a}}rescu, Irinel-Constantin and Lasaulce, Samson and Girard, Antoine},
  year   = {2026},
  eprint = {2606.00268},
  archivePrefix = {arXiv},
  primaryClass  = {cs.SI},
  doi    = {10.48550/arXiv.2606.00268}
}

The nonlinear consensus model behind it is the one of the COSA Simulator, studied in Couthures et al., IEEE Control Systems Letters, 2025.

Data. Zachary’s karate club: W. W. Zachary, J. Anthropol. Res. 33, 452–473 (1977). US college football 2000: M. Girvan and M. E. J. Newman, PNAS 99, 7821–7826 (2002), data distributed by M. Newman. The code is released under the MIT License.

Anthony Couthures ©

Built with Quarto

Cookie Preferences