diff --git a/graph/layout/isomap.go b/graph/layout/isomap.go new file mode 100644 index 00000000..c448010b --- /dev/null +++ b/graph/layout/isomap.go @@ -0,0 +1,63 @@ +// Copyright ©2019 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 layout + +import ( + "gonum.org/v1/gonum/graph" + "gonum.org/v1/gonum/graph/path" + "gonum.org/v1/gonum/mat" + "gonum.org/v1/gonum/spatial/r2" + "gonum.org/v1/gonum/stat/mds" +) + +// IsomapR2 implements a graph layout algorithm based on the Isomap +// non-linear dimensionality reduction method. Coordinates of nodes +// are computed by finding a Torgerson multidimensional scaling of +// the shortest path distances between all pairs of node in the graph. +// The all pair shortest path distances are calculated using the +// Floyd-Warshall algorithm and so IsomapR2 will not scale to large +// graphs. Graphs with more than one connected component cannot be +// layed out by IsomapR2. +type IsomapR2 struct{} + +// Update is the IsomapR2 spatial graph update function. +func (IsomapR2) Update(g graph.Graph, layout LayoutR2) bool { + nodes := graph.NodesOf(g.Nodes()) + v := isomap(g, nodes, 2) + if v == nil { + return false + } + + // FIXME(kortschak): The Layout types do not have the capacity to + // be cleared in the current API. Is this a problem? I don't know + // at this stage. It might be if the layout is reused between graphs. + // Someone may do this. + + for i, n := range nodes { + layout.SetCoord2(n.ID(), r2.Vec{X: v.At(i, 0), Y: v.At(i, 1)}) + } + return false +} + +func isomap(g graph.Graph, nodes []graph.Node, dims int) *mat.Dense { + p, ok := path.FloydWarshall(g) + if !ok { + return nil + } + + dist := mat.NewSymDense(len(nodes), nil) + for i, u := range nodes { + for j, v := range nodes { + dist.SetSym(i, j, p.Weight(u.ID(), v.ID())) + } + } + + k, v, _ := mds.TorgersonScaling(nil, nil, dist) + if k < dims { + return nil + } + + return v +} diff --git a/graph/layout/isomap_test.go b/graph/layout/isomap_test.go new file mode 100644 index 00000000..20345be3 --- /dev/null +++ b/graph/layout/isomap_test.go @@ -0,0 +1,180 @@ +// Copyright ©2019 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 layout + +import ( + "path/filepath" + "testing" + + "gonum.org/v1/gonum/graph" + "gonum.org/v1/gonum/graph/simple" + "gonum.org/v1/gonum/spatial/r2" + "gonum.org/v1/plot" + "gonum.org/v1/plot/vg" +) + +var isomapR2Tests = []struct { + name string + g graph.Graph +}{ + { + name: "line_isomap", + g: func() graph.Graph { + edges := []simple.Edge{ + {simple.Node(0), simple.Node(1)}, + } + g := simple.NewUndirectedGraph() + for _, e := range edges { + g.SetEdge(e) + } + return orderedGraph{g} + }(), + }, + { + name: "square_isomap", + g: func() graph.Graph { + edges := []simple.Edge{ + {simple.Node(0), simple.Node(1)}, + {simple.Node(0), simple.Node(2)}, + {simple.Node(1), simple.Node(3)}, + {simple.Node(2), simple.Node(3)}, + } + g := simple.NewUndirectedGraph() + for _, e := range edges { + g.SetEdge(e) + } + return orderedGraph{g} + }(), + }, + { + name: "tetrahedron_isomap", + g: func() graph.Graph { + edges := []simple.Edge{ + {simple.Node(0), simple.Node(1)}, + {simple.Node(0), simple.Node(2)}, + {simple.Node(0), simple.Node(3)}, + {simple.Node(1), simple.Node(2)}, + {simple.Node(1), simple.Node(3)}, + {simple.Node(2), simple.Node(3)}, + } + g := simple.NewUndirectedGraph() + for _, e := range edges { + g.SetEdge(e) + } + return orderedGraph{g} + }(), + }, + { + name: "sheet_isomap", + g: func() graph.Graph { + edges := []simple.Edge{ + {simple.Node(0), simple.Node(1)}, + {simple.Node(0), simple.Node(3)}, + {simple.Node(1), simple.Node(2)}, + {simple.Node(1), simple.Node(4)}, + {simple.Node(2), simple.Node(5)}, + {simple.Node(3), simple.Node(4)}, + {simple.Node(3), simple.Node(6)}, + {simple.Node(4), simple.Node(5)}, + {simple.Node(4), simple.Node(7)}, + {simple.Node(5), simple.Node(8)}, + {simple.Node(6), simple.Node(7)}, + {simple.Node(7), simple.Node(8)}, + } + g := simple.NewUndirectedGraph() + for _, e := range edges { + g.SetEdge(e) + } + return orderedGraph{g} + }(), + }, + { + name: "tube_isomap", + g: func() graph.Graph { + edges := []simple.Edge{ + {simple.Node(0), simple.Node(1)}, + {simple.Node(0), simple.Node(2)}, + {simple.Node(0), simple.Node(3)}, + {simple.Node(1), simple.Node(2)}, + {simple.Node(1), simple.Node(4)}, + {simple.Node(2), simple.Node(5)}, + {simple.Node(3), simple.Node(4)}, + {simple.Node(3), simple.Node(5)}, + {simple.Node(3), simple.Node(6)}, + {simple.Node(4), simple.Node(5)}, + {simple.Node(4), simple.Node(7)}, + {simple.Node(5), simple.Node(8)}, + {simple.Node(6), simple.Node(7)}, + {simple.Node(6), simple.Node(8)}, + {simple.Node(7), simple.Node(8)}, + } + g := simple.NewUndirectedGraph() + for _, e := range edges { + g.SetEdge(e) + } + return orderedGraph{g} + }(), + }, + { + name: "wp_page_isomap", // https://en.wikipedia.org/wiki/PageRank#/media/File:PageRanks-Example.jpg + g: func() graph.Graph { + edges := []simple.Edge{ + {simple.Node(0), simple.Node(3)}, + {simple.Node(1), simple.Node(2)}, + {simple.Node(1), simple.Node(3)}, + {simple.Node(1), simple.Node(4)}, + {simple.Node(1), simple.Node(5)}, + {simple.Node(1), simple.Node(6)}, + {simple.Node(1), simple.Node(7)}, + {simple.Node(1), simple.Node(8)}, + {simple.Node(3), simple.Node(4)}, + {simple.Node(4), simple.Node(5)}, + {simple.Node(4), simple.Node(6)}, + {simple.Node(4), simple.Node(7)}, + {simple.Node(4), simple.Node(8)}, + {simple.Node(4), simple.Node(9)}, + {simple.Node(4), simple.Node(10)}, + } + g := simple.NewUndirectedGraph() + for _, e := range edges { + g.SetEdge(e) + } + return orderedGraph{g} + }(), + }, +} + +func TestIsomapR2(t *testing.T) { + for _, test := range isomapR2Tests { + o := NewOptimizerR2(test.g, IsomapR2{}.Update) + var n int + for o.Update() { + n++ + } + p, err := plot.New() + if err != nil { + t.Errorf("unexpected error: %v", err) + continue + } + p.Add(render{o}) + p.HideAxes() + path := filepath.Join("testdata", test.name+".png") + err = p.Save(10*vg.Centimeter, 10*vg.Centimeter, path) + if err != nil { + t.Errorf("unexpected error: %v", err) + continue + } + ok := checkRenderedLayout(t, path) + if !ok { + got := make(map[int64]r2.Vec) + nodes := test.g.Nodes() + for nodes.Next() { + id := nodes.Node().ID() + got[id] = o.Coord2(id) + } + t.Logf("got node positions: %#v", got) + } + } +} diff --git a/graph/layout/testdata/line_isomap_golden.png b/graph/layout/testdata/line_isomap_golden.png new file mode 100644 index 00000000..d39f8387 Binary files /dev/null and b/graph/layout/testdata/line_isomap_golden.png differ diff --git a/graph/layout/testdata/sheet_isomap_golden.png b/graph/layout/testdata/sheet_isomap_golden.png new file mode 100644 index 00000000..467a7b5e Binary files /dev/null and b/graph/layout/testdata/sheet_isomap_golden.png differ diff --git a/graph/layout/testdata/square_isomap_golden.png b/graph/layout/testdata/square_isomap_golden.png new file mode 100644 index 00000000..130f3821 Binary files /dev/null and b/graph/layout/testdata/square_isomap_golden.png differ diff --git a/graph/layout/testdata/tetrahedron_isomap_golden.png b/graph/layout/testdata/tetrahedron_isomap_golden.png new file mode 100644 index 00000000..cbbdda58 Binary files /dev/null and b/graph/layout/testdata/tetrahedron_isomap_golden.png differ diff --git a/graph/layout/testdata/tube_isomap_golden.png b/graph/layout/testdata/tube_isomap_golden.png new file mode 100644 index 00000000..e0f6737b Binary files /dev/null and b/graph/layout/testdata/tube_isomap_golden.png differ diff --git a/graph/layout/testdata/wp_page_isomap_golden.png b/graph/layout/testdata/wp_page_isomap_golden.png new file mode 100644 index 00000000..7846140f Binary files /dev/null and b/graph/layout/testdata/wp_page_isomap_golden.png differ