blob: 11d38eb95aa602920c7bd71c1b13b31fa11573b1 [file] [log] [blame] [edit]
// Copyright ©2016 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 (
"errors"
"fmt"
"math"
"golang.org/x/exp/rand"
"gonum.org/v1/gonum/graph"
)
// Interval is an interval of resolutions with a common score.
type Interval struct {
// Low and High delimit the interval
// such that the interval is [low, high).
Low, High float64
// Score is the score of the interval.
Score float64
// Reduced is the best scoring
// community membership found for the
// interval.
Reduced
}
// Reduced is a graph reduction.
type Reduced interface {
// Communities returns the community
// structure of the reduction.
Communities() [][]graph.Node
}
// Size is a score function that is the reciprocal of the number of communities.
func Size(g ReducedGraph) float64 { return 1 / float64(len(g.Structure())) }
// Weight is a score function that is the sum of community weights. The concrete
// type of g must be a pointer to a ReducedUndirected or a ReducedDirected, otherwise
// Weight will panic.
func Weight(g ReducedGraph) float64 {
var w float64
switch g := g.(type) {
case *ReducedUndirected:
for _, n := range g.nodes {
w += n.weight
}
case *ReducedDirected:
for _, n := range g.nodes {
w += n.weight
}
default:
panic(fmt.Sprintf("community: invalid graph type: %T", g))
}
return w
}
// ModularScore returns a modularized scoring function for Profile based on the
// graph g and the given score function. The effort parameter determines how
// many attempts will be made to get an improved score for any given resolution.
func ModularScore(g graph.Graph, score func(ReducedGraph) float64, effort int, src rand.Source) func(float64) (float64, Reduced) {
return func(resolution float64) (float64, Reduced) {
max := math.Inf(-1)
var best Reduced
for i := 0; i < effort; i++ {
r := Modularize(g, resolution, src)
s := score(r)
if s > max {
max = s
best = r
}
}
return max, best
}
}
// SizeMultiplex is a score function that is the reciprocal of the number of communities.
func SizeMultiplex(g ReducedMultiplex) float64 { return 1 / float64(len(g.Structure())) }
// WeightMultiplex is a score function that is the sum of community weights. The concrete
// type of g must be pointer to a ReducedUndirectedMultiplex or a ReducedDirectedMultiplex,
// otherwise WeightMultiplex will panic.
func WeightMultiplex(g ReducedMultiplex) float64 {
var w float64
switch g := g.(type) {
case *ReducedUndirectedMultiplex:
for _, n := range g.nodes {
for _, lw := range n.weights {
w += lw
}
}
case *ReducedDirectedMultiplex:
for _, n := range g.nodes {
for _, lw := range n.weights {
w += lw
}
}
default:
panic(fmt.Sprintf("community: invalid graph type: %T", g))
}
return w
}
// ModularMultiplexScore returns a modularized scoring function for Profile based
// on the graph g and the given score function. The effort parameter determines how
// many attempts will be made to get an improved score for any given resolution.
func ModularMultiplexScore(g Multiplex, weights []float64, all bool, score func(ReducedMultiplex) float64, effort int, src rand.Source) func(float64) (float64, Reduced) {
return func(resolution float64) (float64, Reduced) {
max := math.Inf(-1)
var best Reduced
for i := 0; i < effort; i++ {
r := ModularizeMultiplex(g, weights, []float64{resolution}, all, src)
s := score(r)
if s > max {
max = s
best = r
}
}
return max, best
}
}
// Profile returns an approximate profile of score values in the resolution domain [low,high)
// at the given granularity. The score is calculated by bisecting calls to fn. If log is true,
// log space bisection is used, otherwise bisection is linear. The function fn should be
// monotonically decreasing in at least 1/grain evaluations. Profile will attempt to detect
// non-monotonicity during the bisection.
//
// Since exact modularity optimization is known to be NP-hard and Profile calls modularization
// routines repeatedly, it is unlikely to return the exact resolution profile.
func Profile(fn func(float64) (float64, Reduced), log bool, grain, low, high float64) (profile []Interval, err error) {
if low >= high {
return nil, errors.New("community: zero or negative width domain")
}
defer func() {
r := recover()
e, ok := r.(nonDecreasing)
if ok {
err = e
return
}
if r != nil {
panic(r)
}
}()
left, comm := fn(low)
right, _ := fn(high)
for i := 1; i < int(1/grain); i++ {
rt, _ := fn(high)
right = math.Max(right, rt)
}
profile = bisect(fn, log, grain, low, left, high, right, comm)
// We may have missed some non-monotonicity,
// so merge low score discordant domains into
// their lower resolution neighbours.
return fixUp(profile), nil
}
type nonDecreasing int
func (n nonDecreasing) Error() string {
return fmt.Sprintf("community: profile does not reliably monotonically decrease: tried %d times", n)
}
func bisect(fn func(float64) (float64, Reduced), log bool, grain, low, scoreLow, high, scoreHigh float64, comm Reduced) []Interval {
if low >= high {
panic("community: zero or negative width domain")
}
if math.IsNaN(scoreLow) || math.IsNaN(scoreHigh) {
return nil
}
// Heuristically determine a reasonable number
// of times to try to get a higher value.
maxIter := int(1 / grain)
lowComm := comm
for n := 0; scoreLow < scoreHigh; n++ {
if n > maxIter {
panic(nonDecreasing(n))
}
scoreLow, lowComm = fn(low)
}
if scoreLow == scoreHigh || tooSmall(low, high, grain, log) {
return []Interval{{Low: low, High: high, Score: scoreLow, Reduced: lowComm}}
}
var mid float64
if log {
mid = math.Sqrt(low * high)
} else {
mid = (low + high) / 2
}
scoreMid := math.Inf(-1)
var midComm Reduced
for n := 0; scoreMid < scoreHigh; n++ {
if n > maxIter {
panic(nonDecreasing(n))
}
scoreMid, midComm = fn(mid)
}
lower := bisect(fn, log, grain, low, scoreLow, mid, scoreMid, lowComm)
higher := bisect(fn, log, grain, mid, scoreMid, high, scoreHigh, midComm)
for n := 0; lower[len(lower)-1].Score < higher[0].Score; n++ {
if n > maxIter {
panic(nonDecreasing(n))
}
lower[len(lower)-1].Score, lower[len(lower)-1].Reduced = fn(low)
}
if lower[len(lower)-1].Score == higher[0].Score {
higher[0].Low = lower[len(lower)-1].Low
lower = lower[:len(lower)-1]
if len(lower) == 0 {
return higher
}
}
return append(lower, higher...)
}
// fixUp non-monotonically decreasing interval scores.
func fixUp(profile []Interval) []Interval {
max := profile[len(profile)-1].Score
for i := len(profile) - 2; i >= 0; i-- {
if profile[i].Score > max {
max = profile[i].Score
continue
}
profile[i+1].Low = profile[i].Low
profile = append(profile[:i], profile[i+1:]...)
}
return profile
}
func tooSmall(low, high, grain float64, log bool) bool {
if log {
return math.Log(high/low) < grain
}
return high-low < grain
}