surScat

Where surCoin() puts the categories of the variables on the graph, surScat() puts the cases themselves. Each case becomes a node, placed on a factorial plane extracted from the variables and coloured by the group it falls into. The result is a netCoin object holding the nodes, their coordinates and the options, which can be plotted as an interactive scattergram.

The data

iris holds 150 flowers, four measurements of each and the species it belongs to. The species is what makes it useful here: it is held out of the analysis as an illustrative variable, so it does not shape the plane, but it travels with the cases and can be compared afterwards against whatever grouping is found.

head(iris, 3)
#>   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
#> 1          5.1         3.5          1.4         0.2  setosa
#> 2          4.9         3.0          1.4         0.2  setosa
#> 3          4.7         3.2          1.3         0.2  setosa

A first scattergram

variables are the columns that travel with the cases; active are the ones that build the plane.

S <- surScat(iris, variables = names(iris), active = names(iris)[1:4])

All four active variables are numeric, so surScat() runs a principal component analysis. Had any of them been a factor it would have run a multiple correspondence analysis instead; type states it explicitly. Each axis is labelled with the share of variance it accounts for:

S$options$axesLabels
#> [1] "PC1 (73.0%)" "PC2 (22.9%)"

The node table holds one row per case: its variables, the coordinates fx and fy on the plane, and the clusterization surScat() computes on its own, of nclusters = 2 groups by default.

head(S$nodes[, c("Species", "Groups(2)", "fx", "fy")], 3)
#>   Species Groups(2)        fx         fy
#> 1  setosa  Group: 1 -1.321232 -0.5004175
#> 2  setosa  Group: 1 -1.214037  0.7027698
#> 3  setosa  Group: 1 -1.379296  0.3564318

It also holds ntext, the HTML description behind every node’s tooltip, and name, its identifier. Printing the object itself (S, or print(S)) does not dump the node table: it summarizes it instead, leaving out ntext and the k-means group columns, which are named on their own line.

S
#> <netCoin object>
#> Nodes: 150  (Sepal.Length, Sepal.Width, Petal.Length, Petal.Width, Species, name, fx, fy)
#> Links: 0
#> Clusterizations: Groups(2)
#> Planes: PCA

Two groups are enough to tell one species from the other two, which is as far as two groups can go:

table(S$nodes[["Groups(2)"]], S$nodes$Species)
#>           
#>            setosa versicolor virginica
#>   Group: 1     50          0         0
#>   Group: 2      0         50        50

The interactive scattergram opens in the browser, where the cases can be hovered, filtered and recoloured by any column of the node table:

plot(S)

Drawn statically from those same coordinates, the plane looks like this, with the default grouping on the left and the species on the right:

Adding clusterizations

addClusters() takes a clusterization computed elsewhere and adds it as one more column, so that several ways of grouping the same cases can be compared on one plane. A kmeans object is understood as it comes:

set.seed(1)
km <- kmeans(scale(iris[, 1:4]), 3, nstart = 25)
S <- addClusters(S, km, name = "kmeans(3)")
table(S$nodes[["kmeans(3)"]], S$nodes$Species)
#>            
#>             setosa versicolor virginica
#>   Kmeans: 1     50          0         0
#>   Kmeans: 2      0         39        14
#>   Kmeans: 3      0         11        36

An hclust has to be cut first, since the number of groups is not part of it:

hc <- cutree(hclust(dist(scale(iris[, 1:4]))), 3)
S <- addClusters(S, hc, name = "hclust(3)")
table(S$nodes[["hclust(3)"]], S$nodes$Species)
#>            
#>             setosa versicolor virginica
#>   Hclust: 1     49          0         0
#>   Hclust: 2      1         21         2
#>   Hclust: 3      0         29        48

Every column is an ordered factor whose groups are numbered by their mean coordinate on the first axis, so that group 1 always sits on the left. The labels take the name of the column without its number of groups, so "kmeans(3)" reads "Kmeans: 1" and on.

Several solutions at once

An object holding more than one solution is taken apart into one column per solution, each named after the method and its number of groups. A tidyLPA estimated for two, three and four profiles yields LPA(2), LPA(3) and LPA(4) in a single call:

mod <- tidyLPA::estimate_profiles(iris[, 1:4], 2:4,
                                  variances = "equal", covariances = "zero")
S <- addClusters(S, mod)
table(S$nodes[["LPA(3)"]], S$nodes$Species)
#>         
#>          setosa versicolor virginica
#>   LPA: 1     50          0         0
#>   LPA: 2      0         49         6
#>   LPA: 3      0          1        44

Of the three ways of cutting the cases in three, the latent profiles are the one that comes closest to the species.

Communities of the cases

addCommunities() groups the cases by a different route. It correlates them with one another over the variables, which is a transposed reading of the data frame: each case becomes a column and each variable a row, so that a link joins two cases whose profiles run parallel. The communities of that network are then a grouping of the cases, and reach the node table as any other one does.

S <- addCommunities(S, iris, variables = names(iris)[1:4], community = c("lo", "wa"))
#> Gathered into the residual group -- louvain(6): 2 communities (2 of fewer than 2 cases), 2 cases in all; walktrap(10): 2 communities (2 of fewer than 2 cases), 2 cases in all. Set minSize=1 to keep every community apart.
lou <- grep("^louvain", names(S$nodes), value = TRUE)
table(S$nodes[[lou]], S$nodes$Species)
#>                
#>                 setosa versicolor virginica
#>   Louvain: 1        49          1         0
#>   Louvain: 2         1          9        10
#>   Louvain: 3         0         24        26
#>   Louvain: 4         0         14        12
#>   Louvain: 5         0          0         2
#>   Louvain: rest      0          2         0

Note what this does and does not recover. One species comes out almost clean, while the other two stay mixed together across the communities. That is the method speaking, not a failure: a correlation between two cases centres each of them on its own mean across the variables, so what it compares is the shape of a profile, not its size. One species has a distinctly shaped flower, with petals small relative to its sepals; the other two share that shape and differ mostly in magnitude, which this reading deliberately sets aside.

Three arguments govern the result:

  • scale standardizes the variables before transposing. It is on by default, because without it the variable of widest range writes the profile on its own.
  • minL is how high a correlation two cases need to be linked. Raising it makes the network sparser, and the communities more and smaller.
  • minSize gathers the communities below it into a residual group, since a network of cases leaves isolated ones behind and igraph returns each of them as a community of its own. That group is pinned last and labelled with a word instead of a number, so it reads apart from the real ones.
levels(S$nodes[[lou]])
#> [1] "Louvain: 1"    "Louvain: 2"    "Louvain: 3"    "Louvain: 4"   
#> [5] "Louvain: 5"    "Louvain: rest"

Eight methods are available, by code or by name: edgeBetweenness (ed), fastGreedy (fa), labelProp (la), leadingEigen (le), louvain (lo), optimal (op), spinglass (sp) and walktrap (wa). Their costs do not grow alike, and each carries the size it can meet: louvain answers on thousands of cases in seconds, whereas optimal solves the exact maximum by integer programming and does not reach the 150 of iris.

addCommunities(S, iris, variables = names(iris)[1:4], community = "op")
#> Error in `addCommunities()`:
#> ! optimal does not answer on a network of 150 nodes; it bears 100. Choose one of fastGreedy, labelProp, leadingEigen, louvain, walktrap, cap the nodes with maxN in surScat, or raise maxNodes to wait for it anyway.

A second plane

The scattergram is drawn on one plane, but others can be added to it and picked from a selector. addAxes() takes two further coordinates, either factorial or observed, and adds them under a name of their own:

S <- addAxes(S, iris[, c("Petal.Length", "Sepal.Width")], name = "petals")
names(S$layouts)
#> [1] "PCA"    "petals"

Each clusterization stays as it is; only the coordinates change under it, which is what makes the two planes comparable.

Cluster centroids

$clusters holds the (weighted) centroid of every group of every clusterization the object holds, on every plane it holds: a flat list with one entry per clusterization-plane pair, its name joining the two with a dot, and one row per group.

S$clusters
#> <netCoin clusters> 8 clusterizations x 2 planes (16 sets of centroids) 
#> Clusterizations: Groups(2), kmeans(3), hclust(3), LPA(2), LPA(3), LPA(4), louvain(6), walktrap(10) 
#> Planes: PCA, petals
S$clusters[["Groups(2).PCA"]]
#>          PC1 (73.0%) PC2 (22.9%)
#> Group: 1  -1.2979252  -0.3012007
#> Group: 2   0.6489626   0.1506003

Printing it, on its own or as part of the object, only says how many clusterizations and planes it covers, rather than dumping every matrix.

addAxes extends every clusterization already there to the plane it just added, and addClusters computes the centroids of whatever it adds on every plane already there, so the two stay in step automatically. kmeans(3) was added earlier, before petals existed, yet its centroids are there on that plane too:

S$clusters[["kmeans(3).petals"]]
#>           Petal.Length Sepal.Width
#> Kmeans: 1     1.462000    3.428000
#> Kmeans: 2     4.369811    2.673585
#> Kmeans: 3     5.510638    3.095745

Larger data

Two arguments matter once the cases are many. patterns collapses the cases that share the same values into a single node, whose size is how many they are; a clusterization computed case by case is then reduced to the commonest group of each pattern. And maxN, 2000 by default, caps the nodes by drawing a random sample of them, since a scattergram of tens of thousands of points is neither readable nor quick. The object records which cases were drawn, so that addCommunities() can build its network on them.

Saving and exporting

plot(S, dir = "./temp/iris/")   # write the interactive page to a directory
saveGhml(S, file = "iris.graphml")