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