github.com/gopherd/gonum@v0.0.4/graph/community/louvain_undirected_multiplex_test.go (about) 1 // Copyright ©2015 The Gonum Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package community 6 7 import ( 8 "math" 9 "reflect" 10 "sort" 11 "testing" 12 13 "math/rand" 14 15 "github.com/gopherd/gonum/floats" 16 "github.com/gopherd/gonum/floats/scalar" 17 "github.com/gopherd/gonum/graph" 18 "github.com/gopherd/gonum/graph/internal/ordered" 19 "github.com/gopherd/gonum/graph/simple" 20 ) 21 22 var communityUndirectedMultiplexQTests = []struct { 23 name string 24 layers []layer 25 structures []structure 26 27 wantLevels []level 28 }{ 29 { 30 name: "unconnected", 31 layers: []layer{{g: unconnected, weight: 1}}, 32 structures: []structure{ 33 { 34 resolution: 1, 35 memberships: []intset{ 36 0: linksTo(0), 37 1: linksTo(1), 38 2: linksTo(2), 39 3: linksTo(3), 40 4: linksTo(4), 41 5: linksTo(5), 42 }, 43 want: math.NaN(), 44 }, 45 }, 46 wantLevels: []level{ 47 { 48 q: math.Inf(-1), // Here math.Inf(-1) is used as a place holder for NaN to allow use of reflect.DeepEqual. 49 communities: [][]graph.Node{ 50 {simple.Node(0)}, 51 {simple.Node(1)}, 52 {simple.Node(2)}, 53 {simple.Node(3)}, 54 {simple.Node(4)}, 55 {simple.Node(5)}, 56 }, 57 }, 58 }, 59 }, 60 { 61 name: "small_dumbell", 62 layers: []layer{ 63 {g: smallDumbell, edgeWeight: 1, weight: 1}, 64 {g: dumbellRepulsion, edgeWeight: -1, weight: -1}, 65 }, 66 structures: []structure{ 67 { 68 resolution: 1, 69 memberships: []intset{ 70 0: linksTo(0, 1, 2), 71 1: linksTo(3, 4, 5), 72 }, 73 want: 7.0, tol: 1e-10, 74 }, 75 { 76 resolution: 1, 77 memberships: []intset{ 78 0: linksTo(0, 1, 2, 3, 4, 5), 79 }, 80 want: 0, tol: 1e-14, 81 }, 82 }, 83 wantLevels: []level{ 84 { 85 q: 7.0, 86 communities: [][]graph.Node{ 87 {simple.Node(0), simple.Node(1), simple.Node(2)}, 88 {simple.Node(3), simple.Node(4), simple.Node(5)}, 89 }, 90 }, 91 { 92 q: -1.4285714285714284, 93 communities: [][]graph.Node{ 94 {simple.Node(0)}, 95 {simple.Node(1)}, 96 {simple.Node(2)}, 97 {simple.Node(3)}, 98 {simple.Node(4)}, 99 {simple.Node(5)}, 100 }, 101 }, 102 }, 103 }, 104 { 105 name: "small_dumbell_twice", 106 layers: []layer{ 107 {g: smallDumbell, weight: 0.5}, 108 {g: smallDumbell, weight: 0.5}, 109 }, 110 structures: []structure{ 111 { 112 resolution: 1, 113 memberships: []intset{ 114 0: linksTo(0, 1, 2), 115 1: linksTo(3, 4, 5), 116 }, 117 want: 5, tol: 1e-10, 118 }, 119 { 120 resolution: 1, 121 memberships: []intset{ 122 0: linksTo(0, 1, 2, 3, 4, 5), 123 }, 124 want: 0, tol: 1e-14, 125 }, 126 }, 127 wantLevels: []level{ 128 { 129 q: 0.35714285714285715 * 14, 130 communities: [][]graph.Node{ 131 {simple.Node(0), simple.Node(1), simple.Node(2)}, 132 {simple.Node(3), simple.Node(4), simple.Node(5)}, 133 }, 134 }, 135 { 136 q: -0.17346938775510204 * 14, 137 communities: [][]graph.Node{ 138 {simple.Node(0)}, 139 {simple.Node(1)}, 140 {simple.Node(2)}, 141 {simple.Node(3)}, 142 {simple.Node(4)}, 143 {simple.Node(5)}, 144 }, 145 }, 146 }, 147 }, 148 { 149 name: "repulsion", 150 layers: []layer{{g: repulsion, edgeWeight: -1, weight: -1}}, 151 structures: []structure{ 152 { 153 resolution: 1, 154 memberships: []intset{ 155 0: linksTo(0, 1, 2), 156 1: linksTo(3, 4, 5), 157 }, 158 want: 9.0, tol: 1e-10, 159 }, 160 { 161 resolution: 1, 162 memberships: []intset{ 163 0: linksTo(0), 164 1: linksTo(1), 165 2: linksTo(2), 166 3: linksTo(3), 167 4: linksTo(4), 168 5: linksTo(5), 169 }, 170 want: 3, tol: 1e-14, 171 }, 172 }, 173 wantLevels: []level{ 174 { 175 q: 9.0, 176 communities: [][]graph.Node{ 177 {simple.Node(0), simple.Node(1), simple.Node(2)}, 178 {simple.Node(3), simple.Node(4), simple.Node(5)}, 179 }, 180 }, 181 { 182 q: 3.0, 183 communities: [][]graph.Node{ 184 {simple.Node(0)}, 185 {simple.Node(1)}, 186 {simple.Node(2)}, 187 {simple.Node(3)}, 188 {simple.Node(4)}, 189 {simple.Node(5)}, 190 }, 191 }, 192 }, 193 }, 194 { 195 name: "middle_east", 196 layers: []layer{ 197 {g: middleEast.friends, edgeWeight: 1, weight: 1}, 198 {g: middleEast.enemies, edgeWeight: -1, weight: -1}, 199 }, 200 structures: []structure{ 201 { 202 resolution: 1, 203 memberships: []intset{ 204 0: linksTo(0, 6), 205 1: linksTo(1, 7, 9, 12), 206 2: linksTo(2, 8, 11), 207 3: linksTo(3, 4, 5, 10), 208 }, 209 want: 33.8180574555, tol: 1e-9, 210 }, 211 { 212 resolution: 1, 213 memberships: []intset{ 214 0: linksTo(0, 2, 3, 4, 5, 10), 215 1: linksTo(1, 7, 9, 12), 216 2: linksTo(6), 217 3: linksTo(8, 11), 218 }, 219 want: 30.92749658, tol: 1e-7, 220 }, 221 { 222 resolution: 1, 223 memberships: []intset{ 224 0: linksTo(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), 225 }, 226 want: 0, tol: 1e-14, 227 }, 228 }, 229 wantLevels: []level{ 230 { 231 q: 33.818057455540355, 232 communities: [][]graph.Node{ 233 {simple.Node(0), simple.Node(6)}, 234 {simple.Node(1), simple.Node(7), simple.Node(9), simple.Node(12)}, 235 {simple.Node(2), simple.Node(8), simple.Node(11)}, 236 {simple.Node(3), simple.Node(4), simple.Node(5), simple.Node(10)}, 237 }, 238 }, 239 { 240 q: 3.8071135430916545, 241 communities: [][]graph.Node{ 242 {simple.Node(0)}, 243 {simple.Node(1)}, 244 {simple.Node(2)}, 245 {simple.Node(3)}, 246 {simple.Node(4)}, 247 {simple.Node(5)}, 248 {simple.Node(6)}, 249 {simple.Node(7)}, 250 {simple.Node(8)}, 251 {simple.Node(9)}, 252 {simple.Node(10)}, 253 {simple.Node(11)}, 254 {simple.Node(12)}, 255 }, 256 }, 257 }, 258 }, 259 } 260 261 func TestCommunityQUndirectedMultiplex(t *testing.T) { 262 for _, test := range communityUndirectedMultiplexQTests { 263 g, weights, err := undirectedMultiplexFrom(test.layers) 264 if err != nil { 265 t.Errorf("unexpected error creating multiplex: %v", err) 266 continue 267 } 268 269 for _, structure := range test.structures { 270 communities := make([][]graph.Node, len(structure.memberships)) 271 for i, c := range structure.memberships { 272 for n := range c { 273 communities[i] = append(communities[i], simple.Node(n)) 274 } 275 } 276 q := QMultiplex(g, communities, weights, []float64{structure.resolution}) 277 got := floats.Sum(q) 278 if !scalar.EqualWithinAbsOrRel(got, structure.want, structure.tol, structure.tol) && !math.IsNaN(structure.want) { 279 for _, c := range communities { 280 ordered.ByID(c) 281 } 282 t.Errorf("unexpected Q value for %q %v: got: %v %.3v want: %v", 283 test.name, communities, got, q, structure.want) 284 } 285 } 286 } 287 } 288 289 func TestCommunityDeltaQUndirectedMultiplex(t *testing.T) { 290 tests: 291 for _, test := range communityUndirectedMultiplexQTests { 292 g, weights, err := undirectedMultiplexFrom(test.layers) 293 if err != nil { 294 t.Errorf("unexpected error creating multiplex: %v", err) 295 continue 296 } 297 298 rnd := rand.New(rand.NewSource(1)).Intn 299 for _, structure := range test.structures { 300 communityOf := make(map[int64]int) 301 communities := make([][]graph.Node, len(structure.memberships)) 302 for i, c := range structure.memberships { 303 for n := range c { 304 n := int64(n) 305 communityOf[n] = i 306 communities[i] = append(communities[i], simple.Node(n)) 307 } 308 ordered.ByID(communities[i]) 309 } 310 resolution := []float64{structure.resolution} 311 312 before := QMultiplex(g, communities, weights, resolution) 313 314 // We test exhaustively. 315 const all = true 316 317 l := newUndirectedMultiplexLocalMover( 318 reduceUndirectedMultiplex(g, nil, weights), 319 communities, weights, resolution, all) 320 if l == nil { 321 if !math.IsNaN(floats.Sum(before)) { 322 t.Errorf("unexpected nil localMover with non-NaN Q graph: Q=%.4v", before) 323 } 324 continue tests 325 } 326 327 // This is done to avoid run-to-run 328 // variation due to map iteration order. 329 ordered.ByID(l.nodes) 330 331 l.shuffle(rnd) 332 333 for _, target := range l.nodes { 334 got, gotDst, gotSrc := l.deltaQ(target) 335 336 want, wantDst := math.Inf(-1), -1 337 migrated := make([][]graph.Node, len(structure.memberships)) 338 for i, c := range structure.memberships { 339 for n := range c { 340 n := int64(n) 341 if n == target.ID() { 342 continue 343 } 344 migrated[i] = append(migrated[i], simple.Node(n)) 345 } 346 ordered.ByID(migrated[i]) 347 } 348 349 for i, c := range structure.memberships { 350 if i == communityOf[target.ID()] { 351 continue 352 } 353 if !(all && hasNegative(weights)) { 354 connected := false 355 search: 356 for l := 0; l < g.Depth(); l++ { 357 if weights[l] < 0 { 358 connected = true 359 break search 360 } 361 layer := g.Layer(l) 362 for n := range c { 363 if layer.HasEdgeBetween(int64(n), target.ID()) { 364 connected = true 365 break search 366 } 367 } 368 } 369 if !connected { 370 continue 371 } 372 } 373 migrated[i] = append(migrated[i], target) 374 after := QMultiplex(g, migrated, weights, resolution) 375 migrated[i] = migrated[i][:len(migrated[i])-1] 376 if delta := floats.Sum(after) - floats.Sum(before); delta > want { 377 want = delta 378 wantDst = i 379 } 380 } 381 382 if !scalar.EqualWithinAbsOrRel(got, want, structure.tol, structure.tol) || gotDst != wantDst { 383 t.Errorf("unexpected result moving n=%d in c=%d of %s/%.4v: got: %.4v,%d want: %.4v,%d"+ 384 "\n\t%v\n\t%v", 385 target.ID(), communityOf[target.ID()], test.name, structure.resolution, got, gotDst, want, wantDst, 386 communities, migrated) 387 } 388 if gotSrc.community != communityOf[target.ID()] { 389 t.Errorf("unexpected source community index: got: %d want: %d", gotSrc, communityOf[target.ID()]) 390 } else if communities[gotSrc.community][gotSrc.node].ID() != target.ID() { 391 wantNodeIdx := -1 392 for i, n := range communities[gotSrc.community] { 393 if n.ID() == target.ID() { 394 wantNodeIdx = i 395 break 396 } 397 } 398 t.Errorf("unexpected source node index: got: %d want: %d", gotSrc.node, wantNodeIdx) 399 } 400 } 401 } 402 } 403 } 404 405 func TestReduceQConsistencyUndirectedMultiplex(t *testing.T) { 406 tests: 407 for _, test := range communityUndirectedMultiplexQTests { 408 g, weights, err := undirectedMultiplexFrom(test.layers) 409 if err != nil { 410 t.Errorf("unexpected error creating multiplex: %v", err) 411 continue 412 } 413 414 for _, structure := range test.structures { 415 if math.IsNaN(structure.want) { 416 continue tests 417 } 418 419 communities := make([][]graph.Node, len(structure.memberships)) 420 for i, c := range structure.memberships { 421 for n := range c { 422 communities[i] = append(communities[i], simple.Node(n)) 423 } 424 ordered.ByID(communities[i]) 425 } 426 427 gQ := QMultiplex(g, communities, weights, []float64{structure.resolution}) 428 gQnull := QMultiplex(g, nil, weights, nil) 429 430 cg0 := reduceUndirectedMultiplex(g, nil, weights) 431 cg0Qnull := QMultiplex(cg0, cg0.Structure(), weights, nil) 432 if !scalar.EqualWithinAbsOrRel(floats.Sum(gQnull), floats.Sum(cg0Qnull), structure.tol, structure.tol) { 433 t.Errorf("disagreement between null Q from method: %v and function: %v", cg0Qnull, gQnull) 434 } 435 cg0Q := QMultiplex(cg0, communities, weights, []float64{structure.resolution}) 436 if !scalar.EqualWithinAbsOrRel(floats.Sum(gQ), floats.Sum(cg0Q), structure.tol, structure.tol) { 437 t.Errorf("unexpected Q result after initial reduction: got: %v want :%v", cg0Q, gQ) 438 } 439 440 cg1 := reduceUndirectedMultiplex(cg0, communities, weights) 441 cg1Q := QMultiplex(cg1, cg1.Structure(), weights, []float64{structure.resolution}) 442 if !scalar.EqualWithinAbsOrRel(floats.Sum(gQ), floats.Sum(cg1Q), structure.tol, structure.tol) { 443 t.Errorf("unexpected Q result after second reduction: got: %v want :%v", cg1Q, gQ) 444 } 445 } 446 } 447 } 448 449 var localUndirectedMultiplexMoveTests = []struct { 450 name string 451 layers []layer 452 structures []moveStructures 453 }{ 454 { 455 name: "blondel", 456 layers: []layer{{g: blondel, weight: 1}, {g: blondel, weight: 0.5}}, 457 structures: []moveStructures{ 458 { 459 memberships: []intset{ 460 0: linksTo(0, 1, 2, 4, 5), 461 1: linksTo(3, 6, 7), 462 2: linksTo(8, 9, 10, 12, 14, 15), 463 3: linksTo(11, 13), 464 }, 465 targetNodes: []graph.Node{simple.Node(0)}, 466 resolution: 1, 467 tol: 1e-14, 468 }, 469 { 470 memberships: []intset{ 471 0: linksTo(0, 1, 2, 4, 5), 472 1: linksTo(3, 6, 7), 473 2: linksTo(8, 9, 10, 12, 14, 15), 474 3: linksTo(11, 13), 475 }, 476 targetNodes: []graph.Node{simple.Node(3)}, 477 resolution: 1, 478 tol: 1e-14, 479 }, 480 { 481 memberships: []intset{ 482 0: linksTo(0, 1, 2, 4, 5), 483 1: linksTo(3, 6, 7), 484 2: linksTo(8, 9, 10, 12, 14, 15), 485 3: linksTo(11, 13), 486 }, 487 // Case to demonstrate when A_aa != k_a^𝛼. 488 targetNodes: []graph.Node{simple.Node(3), simple.Node(2)}, 489 resolution: 1, 490 tol: 1e-14, 491 }, 492 }, 493 }, 494 } 495 496 func TestMoveLocalUndirectedMultiplex(t *testing.T) { 497 for _, test := range localUndirectedMultiplexMoveTests { 498 g, weights, err := undirectedMultiplexFrom(test.layers) 499 if err != nil { 500 t.Errorf("unexpected error creating multiplex: %v", err) 501 continue 502 } 503 504 for _, structure := range test.structures { 505 communities := make([][]graph.Node, len(structure.memberships)) 506 for i, c := range structure.memberships { 507 for n := range c { 508 communities[i] = append(communities[i], simple.Node(n)) 509 } 510 ordered.ByID(communities[i]) 511 } 512 513 r := reduceUndirectedMultiplex(reduceUndirectedMultiplex(g, nil, weights), communities, weights) 514 515 l := newUndirectedMultiplexLocalMover(r, r.communities, weights, []float64{structure.resolution}, true) 516 for _, n := range structure.targetNodes { 517 dQ, dst, src := l.deltaQ(n) 518 if dQ > 0 { 519 before := floats.Sum(QMultiplex(r, l.communities, weights, []float64{structure.resolution})) 520 l.move(dst, src) 521 after := floats.Sum(QMultiplex(r, l.communities, weights, []float64{structure.resolution})) 522 want := after - before 523 if !scalar.EqualWithinAbsOrRel(dQ, want, structure.tol, structure.tol) { 524 t.Errorf("unexpected deltaQ: got: %v want: %v", dQ, want) 525 } 526 } 527 } 528 } 529 } 530 } 531 532 func TestLouvainMultiplex(t *testing.T) { 533 const louvainIterations = 20 534 535 for _, test := range communityUndirectedMultiplexQTests { 536 g, weights, err := undirectedMultiplexFrom(test.layers) 537 if err != nil { 538 t.Errorf("unexpected error creating multiplex: %v", err) 539 continue 540 } 541 542 if test.structures[0].resolution != 1 { 543 panic("bad test: expect resolution=1") 544 } 545 want := make([][]graph.Node, len(test.structures[0].memberships)) 546 for i, c := range test.structures[0].memberships { 547 for n := range c { 548 want[i] = append(want[i], simple.Node(n)) 549 } 550 ordered.ByID(want[i]) 551 } 552 ordered.BySliceIDs(want) 553 554 var ( 555 got *ReducedUndirectedMultiplex 556 bestQ = math.Inf(-1) 557 ) 558 // Modularize is randomised so we do this to 559 // ensure the level tests are consistent. 560 src := rand.New(rand.NewSource(1)) 561 for i := 0; i < louvainIterations; i++ { 562 r := ModularizeMultiplex(g, weights, nil, true, src).(*ReducedUndirectedMultiplex) 563 if q := floats.Sum(QMultiplex(r, nil, weights, nil)); q > bestQ || math.IsNaN(q) { 564 bestQ = q 565 got = r 566 567 if math.IsNaN(q) { 568 // Don't try again for non-connected case. 569 break 570 } 571 } 572 573 var qs []float64 574 for p := r; p != nil; p = p.Expanded().(*ReducedUndirectedMultiplex) { 575 qs = append(qs, floats.Sum(QMultiplex(p, nil, weights, nil))) 576 } 577 578 // Recovery of Q values is reversed. 579 if reverse(qs); !sort.Float64sAreSorted(qs) { 580 t.Errorf("Q values not monotonically increasing: %.5v", qs) 581 } 582 } 583 584 gotCommunities := got.Communities() 585 for _, c := range gotCommunities { 586 ordered.ByID(c) 587 } 588 ordered.BySliceIDs(gotCommunities) 589 if !reflect.DeepEqual(gotCommunities, want) { 590 t.Errorf("unexpected community membership for %s Q=%.4v:\n\tgot: %v\n\twant:%v", 591 test.name, bestQ, gotCommunities, want) 592 continue 593 } 594 595 var levels []level 596 for p := got; p != nil; p = p.Expanded().(*ReducedUndirectedMultiplex) { 597 var communities [][]graph.Node 598 if p.parent != nil { 599 communities = p.parent.Communities() 600 for _, c := range communities { 601 ordered.ByID(c) 602 } 603 ordered.BySliceIDs(communities) 604 } else { 605 communities = reduceUndirectedMultiplex(g, nil, weights).Communities() 606 } 607 q := floats.Sum(QMultiplex(p, nil, weights, nil)) 608 if math.IsNaN(q) { 609 // Use an equalable flag value in place of NaN. 610 q = math.Inf(-1) 611 } 612 levels = append(levels, level{q: q, communities: communities}) 613 } 614 if !reflect.DeepEqual(levels, test.wantLevels) { 615 t.Errorf("unexpected level structure:\n\tgot: %v\n\twant:%v", levels, test.wantLevels) 616 } 617 } 618 } 619 620 func TestNonContiguousUndirectedMultiplex(t *testing.T) { 621 g := simple.NewUndirectedGraph() 622 for _, e := range []simple.Edge{ 623 {F: simple.Node(0), T: simple.Node(1)}, 624 {F: simple.Node(4), T: simple.Node(5)}, 625 } { 626 g.SetEdge(e) 627 } 628 629 func() { 630 defer func() { 631 r := recover() 632 if r != nil { 633 t.Error("unexpected panic with non-contiguous ID range") 634 } 635 }() 636 ModularizeMultiplex(UndirectedLayers{g}, nil, nil, true, nil) 637 }() 638 } 639 640 func TestNonContiguousWeightedUndirectedMultiplex(t *testing.T) { 641 g := simple.NewWeightedUndirectedGraph(0, 0) 642 for _, e := range []simple.WeightedEdge{ 643 {F: simple.Node(0), T: simple.Node(1), W: 1}, 644 {F: simple.Node(4), T: simple.Node(5), W: 1}, 645 } { 646 g.SetWeightedEdge(e) 647 } 648 649 func() { 650 defer func() { 651 r := recover() 652 if r != nil { 653 t.Error("unexpected panic with non-contiguous ID range") 654 } 655 }() 656 ModularizeMultiplex(UndirectedLayers{g}, nil, nil, true, nil) 657 }() 658 } 659 660 func BenchmarkLouvainMultiplex(b *testing.B) { 661 src := rand.New(rand.NewSource(1)) 662 for i := 0; i < b.N; i++ { 663 ModularizeMultiplex(UndirectedLayers{dupGraph}, nil, nil, true, src) 664 } 665 } 666 667 func undirectedMultiplexFrom(raw []layer) (UndirectedLayers, []float64, error) { 668 var layers []graph.Undirected 669 var weights []float64 670 for _, l := range raw { 671 g := simple.NewWeightedUndirectedGraph(0, 0) 672 for u, e := range l.g { 673 // Add nodes that are not defined by an edge. 674 if g.Node(int64(u)) == nil { 675 g.AddNode(simple.Node(u)) 676 } 677 for v := range e { 678 w := 1.0 679 if l.edgeWeight != 0 { 680 w = l.edgeWeight 681 } 682 g.SetWeightedEdge(simple.WeightedEdge{F: simple.Node(u), T: simple.Node(v), W: w}) 683 } 684 } 685 layers = append(layers, g) 686 weights = append(weights, l.weight) 687 } 688 g, err := NewUndirectedLayers(layers...) 689 if err != nil { 690 return nil, nil, err 691 } 692 return g, weights, nil 693 }