blob: 559f4f5a2f7be73b423c0b464d2bd73c1d8da334 [file] [log] [blame] [edit]
// Copyright ©2015 The Gonum Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package community
import (
"math"
"reflect"
"sort"
"testing"
"golang.org/x/exp/rand"
"gonum.org/v1/gonum/floats"
"gonum.org/v1/gonum/floats/scalar"
"gonum.org/v1/gonum/graph"
"gonum.org/v1/gonum/graph/internal/ordered"
"gonum.org/v1/gonum/graph/simple"
)
var communityDirectedMultiplexQTests = []struct {
name string
layers []layer
structures []structure
wantLevels []level
}{
{
name: "unconnected",
layers: []layer{{g: unconnected, weight: 1}},
structures: []structure{
{
resolution: 1,
memberships: []intset{
0: linksTo(0),
1: linksTo(1),
2: linksTo(2),
3: linksTo(3),
4: linksTo(4),
5: linksTo(5),
},
want: math.NaN(),
},
},
wantLevels: []level{
{
q: math.Inf(-1), // Here math.Inf(-1) is used as a place holder for NaN to allow use of reflect.DeepEqual.
communities: [][]graph.Node{
{simple.Node(0)},
{simple.Node(1)},
{simple.Node(2)},
{simple.Node(3)},
{simple.Node(4)},
{simple.Node(5)},
},
},
},
},
{
name: "simple_directed",
layers: []layer{{g: simpleDirected, weight: 1}},
// community structure and modularity calculated by C++ implementation: louvain igraph.
// Note that louvain igraph returns Q as an unscaled value.
structures: []structure{
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 1),
1: linksTo(2, 3, 4),
},
want: 0.5714285714285716,
tol: 1e-10,
},
},
wantLevels: []level{
{
communities: [][]graph.Node{
{simple.Node(0), simple.Node(1)},
{simple.Node(2), simple.Node(3), simple.Node(4)},
},
q: 0.5714285714285716,
},
{
communities: [][]graph.Node{
{simple.Node(0)},
{simple.Node(1)},
{simple.Node(2)},
{simple.Node(3)},
{simple.Node(4)},
},
q: -1.2857142857142856,
},
},
},
{
name: "simple_directed_twice",
layers: []layer{
{g: simpleDirected, weight: 0.5},
{g: simpleDirected, weight: 0.5},
},
// community structure and modularity calculated by C++ implementation: louvain igraph.
// Note that louvain igraph returns Q as an unscaled value.
structures: []structure{
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 1),
1: linksTo(2, 3, 4),
},
want: 0.5714285714285716,
tol: 1e-10,
},
},
wantLevels: []level{
{
q: 0.5714285714285716,
communities: [][]graph.Node{
{simple.Node(0), simple.Node(1)},
{simple.Node(2), simple.Node(3), simple.Node(4)},
},
},
{
q: -1.2857142857142856,
communities: [][]graph.Node{
{simple.Node(0)},
{simple.Node(1)},
{simple.Node(2)},
{simple.Node(3)},
{simple.Node(4)},
},
},
},
},
{
name: "small_dumbell",
layers: []layer{
{g: smallDumbell, edgeWeight: 1, weight: 1},
{g: dumbellRepulsion, edgeWeight: -1, weight: -1},
},
structures: []structure{
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 1, 2),
1: linksTo(3, 4, 5),
},
want: 2.5714285714285716, tol: 1e-10,
},
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 1, 2, 3, 4, 5),
},
want: 0, tol: 1e-14,
},
},
wantLevels: []level{
{
q: 2.5714285714285716,
communities: [][]graph.Node{
{simple.Node(0), simple.Node(1), simple.Node(2)},
{simple.Node(3), simple.Node(4), simple.Node(5)},
},
},
{
q: -0.857142857142857,
communities: [][]graph.Node{
{simple.Node(0)},
{simple.Node(1)},
{simple.Node(2)},
{simple.Node(3)},
{simple.Node(4)},
{simple.Node(5)},
},
},
},
},
{
name: "repulsion",
layers: []layer{{g: repulsion, edgeWeight: -1, weight: -1}},
structures: []structure{
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 1, 2),
1: linksTo(3, 4, 5),
},
want: 9.0, tol: 1e-10,
},
{
resolution: 1,
memberships: []intset{
0: linksTo(0),
1: linksTo(1),
2: linksTo(2),
3: linksTo(3),
4: linksTo(4),
5: linksTo(5),
},
want: 3, tol: 1e-14,
},
},
wantLevels: []level{
{
q: 9.0,
communities: [][]graph.Node{
{simple.Node(0), simple.Node(1), simple.Node(2)},
{simple.Node(3), simple.Node(4), simple.Node(5)},
},
},
{
q: 3.0,
communities: [][]graph.Node{
{simple.Node(0)},
{simple.Node(1)},
{simple.Node(2)},
{simple.Node(3)},
{simple.Node(4)},
{simple.Node(5)},
},
},
},
},
{
name: "middle_east",
layers: []layer{
{g: middleEast.friends, edgeWeight: 1, weight: 1},
{g: middleEast.enemies, edgeWeight: -1, weight: -1},
},
structures: []structure{
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 6),
1: linksTo(1, 7, 9, 12),
2: linksTo(2, 8, 11),
3: linksTo(3, 4, 5, 10),
},
want: 33.818057455540355, tol: 1e-9,
},
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 2, 3, 4, 5, 10),
1: linksTo(1, 7, 9, 12),
2: linksTo(6),
3: linksTo(8, 11),
},
want: 30.92749658, tol: 1e-7,
},
{
resolution: 1,
memberships: []intset{
0: linksTo(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
},
want: 0, tol: 1e-14,
},
},
wantLevels: []level{
{
q: 33.818057455540355,
communities: [][]graph.Node{
{simple.Node(0), simple.Node(6)},
{simple.Node(1), simple.Node(7), simple.Node(9), simple.Node(12)},
{simple.Node(2), simple.Node(8), simple.Node(11)},
{simple.Node(3), simple.Node(4), simple.Node(5), simple.Node(10)},
},
},
{
q: 3.8071135430916545,
communities: [][]graph.Node{
{simple.Node(0)},
{simple.Node(1)},
{simple.Node(2)},
{simple.Node(3)},
{simple.Node(4)},
{simple.Node(5)},
{simple.Node(6)},
{simple.Node(7)},
{simple.Node(8)},
{simple.Node(9)},
{simple.Node(10)},
{simple.Node(11)},
{simple.Node(12)},
},
},
},
},
}
func TestCommunityQDirectedMultiplex(t *testing.T) {
for _, test := range communityDirectedMultiplexQTests {
g, weights, err := directedMultiplexFrom(test.layers)
if err != nil {
t.Errorf("unexpected error creating multiplex: %v", err)
continue
}
for _, structure := range test.structures {
communities := make([][]graph.Node, len(structure.memberships))
for i, c := range structure.memberships {
for n := range c {
communities[i] = append(communities[i], simple.Node(n))
}
}
q := QMultiplex(g, communities, weights, []float64{structure.resolution})
got := floats.Sum(q)
if !scalar.EqualWithinAbsOrRel(got, structure.want, structure.tol, structure.tol) && !math.IsNaN(structure.want) {
for _, c := range communities {
sort.Sort(ordered.ByID(c))
}
t.Errorf("unexpected Q value for %q %v: got: %v %.3v want: %v",
test.name, communities, got, q, structure.want)
}
}
}
}
func TestCommunityDeltaQDirectedMultiplex(t *testing.T) {
tests:
for _, test := range communityDirectedMultiplexQTests {
g, weights, err := directedMultiplexFrom(test.layers)
if err != nil {
t.Errorf("unexpected error creating multiplex: %v", err)
continue
}
rnd := rand.New(rand.NewSource(1)).Intn
for _, structure := range test.structures {
communityOf := make(map[int64]int)
communities := make([][]graph.Node, len(structure.memberships))
for i, c := range structure.memberships {
for n := range c {
n := int64(n)
communityOf[n] = i
communities[i] = append(communities[i], simple.Node(n))
}
sort.Sort(ordered.ByID(communities[i]))
}
resolution := []float64{structure.resolution}
before := QMultiplex(g, communities, weights, resolution)
// We test exhaustively.
const all = true
l := newDirectedMultiplexLocalMover(
reduceDirectedMultiplex(g, nil, weights),
communities, weights, resolution, all)
if l == nil {
if !math.IsNaN(floats.Sum(before)) {
t.Errorf("unexpected nil localMover with non-NaN Q graph: Q=%.4v", before)
}
continue tests
}
// This is done to avoid run-to-run
// variation due to map iteration order.
sort.Sort(ordered.ByID(l.nodes))
l.shuffle(rnd)
for _, target := range l.nodes {
got, gotDst, gotSrc := l.deltaQ(target)
want, wantDst := math.Inf(-1), -1
migrated := make([][]graph.Node, len(structure.memberships))
for i, c := range structure.memberships {
for n := range c {
n := int64(n)
if n == target.ID() {
continue
}
migrated[i] = append(migrated[i], simple.Node(n))
}
sort.Sort(ordered.ByID(migrated[i]))
}
for i, c := range structure.memberships {
if i == communityOf[target.ID()] {
continue
}
if !(all && hasNegative(weights)) {
connected := false
search:
for l := 0; l < g.Depth(); l++ {
if weights[l] < 0 {
connected = true
break search
}
layer := g.Layer(l)
for n := range c {
if layer.HasEdgeBetween(int64(n), target.ID()) {
connected = true
break search
}
}
}
if !connected {
continue
}
}
migrated[i] = append(migrated[i], target)
after := QMultiplex(g, migrated, weights, resolution)
migrated[i] = migrated[i][:len(migrated[i])-1]
if delta := floats.Sum(after) - floats.Sum(before); delta > want {
want = delta
wantDst = i
}
}
if !scalar.EqualWithinAbsOrRel(got, want, structure.tol, structure.tol) || gotDst != wantDst {
t.Errorf("unexpected result moving n=%d in c=%d of %s/%.4v: got: %.4v,%d want: %.4v,%d"+
"\n\t%v\n\t%v",
target.ID(), communityOf[target.ID()], test.name, structure.resolution, got, gotDst, want, wantDst,
communities, migrated)
}
if gotSrc.community != communityOf[target.ID()] {
t.Errorf("unexpected source community index: got: %d want: %d", gotSrc, communityOf[target.ID()])
} else if communities[gotSrc.community][gotSrc.node].ID() != target.ID() {
wantNodeIdx := -1
for i, n := range communities[gotSrc.community] {
if n.ID() == target.ID() {
wantNodeIdx = i
break
}
}
t.Errorf("unexpected source node index: got: %d want: %d", gotSrc.node, wantNodeIdx)
}
}
}
}
}
func TestReduceQConsistencyDirectedMultiplex(t *testing.T) {
tests:
for _, test := range communityDirectedMultiplexQTests {
g, weights, err := directedMultiplexFrom(test.layers)
if err != nil {
t.Errorf("unexpected error creating multiplex: %v", err)
continue
}
for _, structure := range test.structures {
if math.IsNaN(structure.want) {
continue tests
}
communities := make([][]graph.Node, len(structure.memberships))
for i, c := range structure.memberships {
for n := range c {
communities[i] = append(communities[i], simple.Node(n))
}
sort.Sort(ordered.ByID(communities[i]))
}
gQ := QMultiplex(g, communities, weights, []float64{structure.resolution})
gQnull := QMultiplex(g, nil, weights, nil)
cg0 := reduceDirectedMultiplex(g, nil, weights)
cg0Qnull := QMultiplex(cg0, cg0.Structure(), weights, nil)
if !scalar.EqualWithinAbsOrRel(floats.Sum(gQnull), floats.Sum(cg0Qnull), structure.tol, structure.tol) {
t.Errorf("disagreement between null Q from method: %v and function: %v", cg0Qnull, gQnull)
}
cg0Q := QMultiplex(cg0, communities, weights, []float64{structure.resolution})
if !scalar.EqualWithinAbsOrRel(floats.Sum(gQ), floats.Sum(cg0Q), structure.tol, structure.tol) {
t.Errorf("unexpected Q result after initial reduction: got: %v want :%v", cg0Q, gQ)
}
cg1 := reduceDirectedMultiplex(cg0, communities, weights)
cg1Q := QMultiplex(cg1, cg1.Structure(), weights, []float64{structure.resolution})
if !scalar.EqualWithinAbsOrRel(floats.Sum(gQ), floats.Sum(cg1Q), structure.tol, structure.tol) {
t.Errorf("unexpected Q result after second reduction: got: %v want :%v", cg1Q, gQ)
}
}
}
}
var localDirectedMultiplexMoveTests = []struct {
name string
layers []layer
structures []moveStructures
}{
{
name: "blondel",
layers: []layer{{g: blondel, weight: 1}, {g: blondel, weight: 0.5}},
structures: []moveStructures{
{
memberships: []intset{
0: linksTo(0, 1, 2, 4, 5),
1: linksTo(3, 6, 7),
2: linksTo(8, 9, 10, 12, 14, 15),
3: linksTo(11, 13),
},
targetNodes: []graph.Node{simple.Node(0)},
resolution: 1,
tol: 1e-14,
},
{
memberships: []intset{
0: linksTo(0, 1, 2, 4, 5),
1: linksTo(3, 6, 7),
2: linksTo(8, 9, 10, 12, 14, 15),
3: linksTo(11, 13),
},
targetNodes: []graph.Node{simple.Node(3)},
resolution: 1,
tol: 1e-14,
},
{
memberships: []intset{
0: linksTo(0, 1, 2, 4, 5),
1: linksTo(3, 6, 7),
2: linksTo(8, 9, 10, 12, 14, 15),
3: linksTo(11, 13),
},
// Case to demonstrate when A_aa != k_a^𝛼.
targetNodes: []graph.Node{simple.Node(3), simple.Node(2)},
resolution: 1,
tol: 1e-14,
},
},
},
}
func TestMoveLocalDirectedMultiplex(t *testing.T) {
for _, test := range localDirectedMultiplexMoveTests {
g, weights, err := directedMultiplexFrom(test.layers)
if err != nil {
t.Errorf("unexpected error creating multiplex: %v", err)
continue
}
for _, structure := range test.structures {
communities := make([][]graph.Node, len(structure.memberships))
for i, c := range structure.memberships {
for n := range c {
communities[i] = append(communities[i], simple.Node(n))
}
sort.Sort(ordered.ByID(communities[i]))
}
r := reduceDirectedMultiplex(reduceDirectedMultiplex(g, nil, weights), communities, weights)
l := newDirectedMultiplexLocalMover(r, r.communities, weights, []float64{structure.resolution}, true)
for _, n := range structure.targetNodes {
dQ, dst, src := l.deltaQ(n)
if dQ > 0 {
before := floats.Sum(QMultiplex(r, l.communities, weights, []float64{structure.resolution}))
l.move(dst, src)
after := floats.Sum(QMultiplex(r, l.communities, weights, []float64{structure.resolution}))
want := after - before
if !scalar.EqualWithinAbsOrRel(dQ, want, structure.tol, structure.tol) {
t.Errorf("unexpected deltaQ: got: %v want: %v", dQ, want)
}
}
}
}
}
}
func TestLouvainDirectedMultiplex(t *testing.T) {
const louvainIterations = 20
for _, test := range communityDirectedMultiplexQTests {
g, weights, err := directedMultiplexFrom(test.layers)
if err != nil {
t.Errorf("unexpected error creating multiplex: %v", err)
continue
}
if test.structures[0].resolution != 1 {
panic("bad test: expect resolution=1")
}
want := make([][]graph.Node, len(test.structures[0].memberships))
for i, c := range test.structures[0].memberships {
for n := range c {
want[i] = append(want[i], simple.Node(n))
}
sort.Sort(ordered.ByID(want[i]))
}
sort.Sort(ordered.BySliceIDs(want))
var (
got *ReducedDirectedMultiplex
bestQ = math.Inf(-1)
)
// Modularize is randomised so we do this to
// ensure the level tests are consistent.
src := rand.New(rand.NewSource(1))
for i := 0; i < louvainIterations; i++ {
r := ModularizeMultiplex(g, weights, nil, true, src).(*ReducedDirectedMultiplex)
if q := floats.Sum(QMultiplex(r, nil, weights, nil)); q > bestQ || math.IsNaN(q) {
bestQ = q
got = r
if math.IsNaN(q) {
// Don't try again for non-connected case.
break
}
}
var qs []float64
for p := r; p != nil; p = p.Expanded().(*ReducedDirectedMultiplex) {
qs = append(qs, floats.Sum(QMultiplex(p, nil, weights, nil)))
}
// Recovery of Q values is reversed.
if reverse(qs); !sort.Float64sAreSorted(qs) {
t.Errorf("Q values not monotonically increasing: %.5v", qs)
}
}
gotCommunities := got.Communities()
for _, c := range gotCommunities {
sort.Sort(ordered.ByID(c))
}
sort.Sort(ordered.BySliceIDs(gotCommunities))
if !reflect.DeepEqual(gotCommunities, want) {
t.Errorf("unexpected community membership for %s Q=%.4v:\n\tgot: %v\n\twant:%v",
test.name, bestQ, gotCommunities, want)
continue
}
var levels []level
for p := got; p != nil; p = p.Expanded().(*ReducedDirectedMultiplex) {
var communities [][]graph.Node
if p.parent != nil {
communities = p.parent.Communities()
for _, c := range communities {
sort.Sort(ordered.ByID(c))
}
sort.Sort(ordered.BySliceIDs(communities))
} else {
communities = reduceDirectedMultiplex(g, nil, weights).Communities()
}
q := floats.Sum(QMultiplex(p, nil, weights, nil))
if math.IsNaN(q) {
// Use an equalable flag value in place of NaN.
q = math.Inf(-1)
}
levels = append(levels, level{q: q, communities: communities})
}
if !reflect.DeepEqual(levels, test.wantLevels) {
t.Errorf("unexpected level structure:\n\tgot: %v\n\twant:%v", levels, test.wantLevels)
}
}
}
func TestNonContiguousDirectedMultiplex(t *testing.T) {
g := simple.NewDirectedGraph()
for _, e := range []simple.Edge{
{F: simple.Node(0), T: simple.Node(1)},
{F: simple.Node(4), T: simple.Node(5)},
} {
g.SetEdge(e)
}
func() {
defer func() {
r := recover()
if r != nil {
t.Error("unexpected panic with non-contiguous ID range")
}
}()
ModularizeMultiplex(DirectedLayers{g}, nil, nil, true, nil)
}()
}
func TestNonContiguousWeightedDirectedMultiplex(t *testing.T) {
g := simple.NewWeightedDirectedGraph(0, 0)
for _, e := range []simple.WeightedEdge{
{F: simple.Node(0), T: simple.Node(1), W: 1},
{F: simple.Node(4), T: simple.Node(5), W: 1},
} {
g.SetWeightedEdge(e)
}
func() {
defer func() {
r := recover()
if r != nil {
t.Error("unexpected panic with non-contiguous ID range")
}
}()
ModularizeMultiplex(DirectedLayers{g}, nil, nil, true, nil)
}()
}
func BenchmarkLouvainDirectedMultiplex(b *testing.B) {
src := rand.New(rand.NewSource(1))
for i := 0; i < b.N; i++ {
ModularizeMultiplex(DirectedLayers{dupGraphDirected}, nil, nil, true, src)
}
}
func directedMultiplexFrom(raw []layer) (DirectedLayers, []float64, error) {
var layers []graph.Directed
var weights []float64
for _, l := range raw {
g := simple.NewWeightedDirectedGraph(0, 0)
for u, e := range l.g {
// Add nodes that are not defined by an edge.
if g.Node(int64(u)) == nil {
g.AddNode(simple.Node(u))
}
for v := range e {
w := 1.0
if l.edgeWeight != 0 {
w = l.edgeWeight
}
g.SetWeightedEdge(simple.WeightedEdge{F: simple.Node(u), T: simple.Node(v), W: w})
}
}
layers = append(layers, g)
weights = append(weights, l.weight)
}
g, err := NewDirectedLayers(layers...)
if err != nil {
return nil, nil, err
}
return g, weights, nil
}