Skip to Tutorial Content

Today’s target

The earlier tutorials asked how central each node is, and which cohesive communities nodes huddle into. This tutorial asks a different question: what position does each node hold?

Position is a subtle idea, so it is worth pausing on before we start coding. Two nodes can sit on opposite sides of a network, never interacting, and never share a single tie — and yet occupy exactly the same position. Think of two branch managers in different cities, or two goalkeepers in different teams: they never pass the ball to each other, but each stands in the same relation to the rest of their team. “Position” is about the pattern of a node’s relationships, not about who its particular neighbours happen to be.

We will approach position from two directions, and take our time over each:

  1. First through structural holes and structural folds — two competing stories about where in a network innovation happens, and what it takes for a node to profit from its position.
  2. Then through equivalence — grouping nodes that hold similar positions into classes, or “roles”, and summarising how those roles relate to one another in a blockmodel and a reduced graph . We will meet all three of the classic equivalences: structural, regular, and automorphic.

gif of rick and morty characters hanging out with themselves

Catching up: This tutorial assumes you can already load or make a network in R, and draw it with graphr(). If any of that is hazy, work through the earlier {stocnet} tutorials first: run run_tute("Making") and run_tute("Manipulating") for network data, and run_tute("Visualising") for graphing, or read their static versions on the manynet and autograph websites. It also builds on the {netrics} tutorials on centrality (run_tute("Centrality"), for e.g. node_is_min() and mapping measures onto graphs) and community detection (run_tute("Community"), for partitioning nodes into groups).

New to network vocabulary?: Throughout this tutorial, key terms are italicised: hover over them for a definition, and a full glossary of the terms used appears at the end of the tutorial.

Aims

By the end of this tutorial, you should be able to:

Choose your own data: The worked examples below use ison_algebra, a multiplex network of interactions in an algebra class bundled with {manynet}. But wherever there is an exercise box, you are encouraged to swap in a network that interests you. Remember the three flavours of bundled data as a rough difficulty ladder — Classic (ison_*, small & tidy), Fiction (fict_*, mid-sized & fun), Real-world (irps_*, larger & realistic) — and that you can browse the full list with table_data().

Setting up

For this session, we’re going to use the “ison_algebra” dataset included in the {manynet} package. Do you remember how to call the data? Can you find out some more information about it via its help file?

Load the ison_algebra dataset, and open its help file to read about it. If you get stuck, work through the hints one at a time.

# There are two ways to bring a bundled dataset into your session.
# The first is data(), naming the dataset and the package it lives in:
data("ison_algebra", package = "manynet")
# The second is to assign it directly from the package with the :: operator:
ison_algebra <- manynet::ison_algebra
# To open the help file, put a ? in front of the (package-qualified) name:
?manynet::ison_algebra
data("ison_algebra", package = "manynet")
?manynet::ison_algebra
# If you want to see the network object, you can run the name of the object
# ison_algebra
# or print the code with brackets at the front and end of the code
# (ison_algebra <- manynet::ison_algebra)

From the help file (and by printing the object) we can see that the dataset is multiplex, meaning that it contains several different types of ties: friendship (friends), social (social) and task interactions (tasks). Keeping those three relationships separate, or deliberately combining them, turns out to matter a lot for the questions in this tutorial, so that is where we begin.

Separating multiplex networks

As a multiplex network, there are actually three different types of ties in this network. Bundling three relationships into one object is convenient for storage, but most position measures expect a single kind of tie at a time — “who advises whom” is a different question from “who is friends with whom”. So our first practical skill is pulling the three relations apart.

We can extract them and investigate them separately using to_uniplex(). Within the parentheses, put the multiplex object’s name, and then as a second argument put the name of the tie attribute in quotation marks.

Extract all three networks (friends, social, and tasks) with to_uniplex(), then graph each one and give it a descriptive title.

# Here's the basic idea/code syntax you will need to extract each type of network.
# Replace the first blank with a name for the new object,
# and the second with the tie type in quotation marks, e.g. "friends":
____ <- to_uniplex(ison_algebra, _____)
# So, to extract the friendship network:
friends <- to_uniplex(ison_algebra, "friends")
# To graph a network and add a title, add a ggtitle() layer with +
graphr(friends) + ggtitle("Friendship")
# Now do the same for the "social" and "tasks" tie types.
social <- to_uniplex(ison_algebra, "social")
graphr(social) + ggtitle("Social")
friends <- to_uniplex(ison_algebra, "friends")
graphr(friends) + ggtitle("Friendship")

social <- to_uniplex(ison_algebra, "social")
graphr(social) + ggtitle("Social")

tasks <- to_uniplex(ison_algebra, "tasks")
graphr(tasks) + ggtitle("Task")

Note also that these are weighted networks: each tie carries a number recording how strong that friendship, social, or task relationship is. graphr() automatically recognises these different weights and plots them, drawing stronger ties more heavily. That weighting matters for the theory we turn to next.

In brief: to_uniplex() extracts one type of tie from a multiplex network as its own (here weighted) network — the position measures on the following pages each apply to one type of tie at a time, so this is usually the first step in any positional analysis of multiplex data.

Structural holes

On this page: Measures · Bridges · Constraint · The wider family

Our first substantive question for this network is where innovation and creative ideas might be expected to appear. Why would network position have anything to do with creativity? The intuition, developed by Ronald Burt, is about information. If all of your contacts already know each other, they mostly know the same things, and talking to them tells you little that is new. But if you are connected to two groups that are not connected to each other, you hear two different conversations, and you are uniquely placed to combine ideas from one with problems in the other.

That gap between two otherwise-unconnected parts of a network is a structural hole , and a node that spans one is said to be in a brokerage position: it enjoys early access to non-redundant information, and control over what passes between the two sides. Before measuring anything, let’s check the core idea is clear.

Measuring structural holes

The idea of a structural hole is intuitive, but to work with it we need to turn it into numbers. There is no single “structural hole score”; instead there is a small family of related measures, each capturing a slightly different facet of the same idea. Try to reason through which of the following could stand in for the concept.

All of these measures are implemented in {netrics}. Rather than race through all six, we will concentrate on the two most common entry points — bridges and constraint — and only then glance at the rest of the family.

Bridges

One concrete way of thinking about how innovations and ideas flow across the network is to ask where the bottlenecks are. A tie that is the sole conduit for information to pass from one part of the network to another is called a bridge : formally, a tie whose removal would increase the number of components — that is, cut the network in two.

Remember that only ties can be bridges (a bridge is a kind of tie), though {netrics} also offers a node-based measure that counts how many bridges each node is adjacent to. So let’s look for bridges in the friends network. tie_is_bridge() returns a logical value for each tie (is it a bridge or not?), and node_by_bridges() returns a count for each node.

Run the code below to count the bridges in the friends network. sum() over a logical vector counts the TRUEs, and any(... > 0) asks whether any node touches a bridge.

sum(tie_is_bridge(friends))
any(node_by_bridges(friends)>0)

Both answers come back empty-handed: there are no bridges in the friends network. That is not a bug — it is a substantive finding. This friendship network is cohesive enough that no single tie is a lone conduit; cut any one friendship and information can still flow around another way. So if bridges can’t help us locate brokerage here, we need a more graded measure. That is where constraint comes in.

Constraint

Bridges are all-or-nothing: a tie either is one or it isn’t. But some nodes are clearly more deeply embedded in their local neighbourhood than others, and we would like to measure that by degree. Burt’s constraint does exactly this. It asks, for each node: how far are your contacts also tied to one another? The more your contacts form a closed, all-knowing-all triangle around you, the more “constrained” you are, and the fewer holes you have to exploit.

Let’s take a look at which actors are least constrained by their position in the task network — where advice about the algebra problems flows. {netrics} makes this easy with the node_by_constraint() function.

Calculate the constraint score of each node in the tasks network.

# The function is node_by_constraint(). It takes a network as its argument.
node_by_constraint(____)
# Remember we want the *task* network, which we extracted as 'tasks':
node_by_constraint(tasks)
node_by_constraint(tasks)

This function returns a vector of constraint scores that can range between 0 and 1. Reading a column of numbers is hard, though, so let’s put them back on the graph. We will size each node by its constraint score, and additionally pick out the least constrained actor — the one with the most brokerage potential — with node_is_min(), the minimum-finding cousin of the node_is_max() you met in the centrality tutorial.

Graph the tasks network, sizing nodes by their constraint score and colouring the least-constrained node. Build it up through the hints.

# First, attach two new node attributes with mutate():
# the constraint score itself, and a flag for the minimum.
tasks <- tasks %>%
  mutate(constraint = node_by_constraint(____),
         low_constraint = node_is_min(node_by_constraint(____)))
# Don't forget, we are still looking at the 'tasks' network.
# node_is_min() turns the numeric scores into a TRUE/FALSE flag,
# TRUE for whichever node(s) have the lowest constraint:
tasks <- tasks %>%
  mutate(constraint = node_by_constraint(tasks),
         low_constraint = node_is_min(node_by_constraint(tasks)))
# Now graph it: map 'constraint' to node_size and 'low_constraint' to node_color.
graphr(____, node_size = "____", node_color = "____")
graphr(tasks, node_size = "constraint", node_color = "low_constraint")
tasks <- tasks %>%
  mutate(constraint = node_by_constraint(tasks),
         low_constraint = node_is_min(node_by_constraint(tasks)))
graphr(tasks, node_size = "constraint", node_color = "low_constraint")

Why look for the minimum? Because constraint measures how well connected each node’s partners are, with the implication that having few partners that are already connected to each other puts a node in an advantageous position to identify and share novel solutions to problems. The lowest-constraint node is therefore the best-placed broker.

It helps to spell out the two extremes substantively:

  • A high constraint score means a node’s contacts mostly know one another. Whatever one contact knows, the others likely know too, so the node hears the same information several times over and has little room to play contacts off against each other.
  • A low constraint score means the opposite: the node’s contacts are disconnected from one another, so the node sits across one or more structural holes, enjoys access to diverse, non-redundant information, and can broker — choosing what passes between the two sides, and on what terms.

So what can we learn from this plot about where innovation might occur within this network?

The rest of the family

We flagged four other structural-hole measures in the quiz above, and they are all one function call away: node_by_effsize() for effective size (a node’s non-redundant contacts), node_by_redundancy() for how much a node’s contacts overlap, node_by_efficiency() for effective size relative to degree, and node_by_hierarchy() for whether a node’s constraint is concentrated in a single dominant contact.

You do not need to memorise the formulas; the point is to see that they broadly agree with constraint but each tells a slightly different part of the story.

Run a few of these on the task network. Do the least-constrained nodes also tend to have the largest effective size? (They should, roughly — more non-redundant contacts means less constraint.)

node_by_effsize(tasks)
node_by_redundancy(tasks)
node_by_hierarchy(tasks)

Broadly, effective size and constraint mirror one another — nodes with more non-redundant contacts are less constrained — but they are not interchangeable, and reporting more than one facet of a node’s structural-hole position is good practice. For formal definitions and references, see ?measure_broker_node.

Going further: Structural holes are closely related to brokerage as a behaviour: node_by_brokering_activity() and node_by_brokering_exclusivity() measure how often a node sits between its contacts and how exclusively, and node_x_brokerage() counts Gould and Fernandez’s five brokerage roles (coordinator, gatekeeper, representative, itinerant, liaison) once nodes belong to known groups.

In brief: Only ties can be bridges : tie_is_bridge() flags them and node_by_bridges() counts them per node. node_by_constraint() measures how closed each node’s neighbourhood is — low constraint marks potential brokers spanning structural holes — and node_by_effsize() and friends measure further facets of the same idea.

Structural folds

On this page: Finding folds · Ties that torture

The structural-hole story is a story about information: the broker profits by being the only channel between two worlds. But there is a well-known objection to it, which leads to a second, richer story about innovation.

Being the only bridge between two groups gives you access to novel information — but does it give you the standing to do anything with it? If you belong to neither group fully, you are an outsider to both, and an idea carried in by an outsider is easily dismissed. Burt’s broker hears the new idea first; but it may be someone trusted on both sides who can actually get it adopted.

This is the idea of a structural fold : not a gap between groups, but an overlap of them. A node in a structural fold is a genuine, recognised member of two (or more) cohesive groups at once. Because it is an insider to both, it enjoys the broker’s informational advantage — it hears two conversations — and the standing to translate a solution from one group into a problem in the other, and be taken seriously when it does. Folds are where information advantage meets the legitimacy to act on it.

Finding folds

{netrics} can look for nodes in structural folds directly, with node_is_fold(), which returns a TRUE/FALSE flag for each node. Under the hood it looks for nodes that are cohesively embedded in more than one group. Let’s try it on the (named) algebra network.

Run node_is_fold() on alge, and check whether any node is in a fold.

node_is_fold(alge)
any(node_is_fold(alge))

Just as the friends network had no bridges, the algebra network turns out to have no structural folds: every flag comes back FALSE. Again, this is a substantive result, not a failure. A fold requires two distinct cohesive groups that a node belongs to at once; in this small, tightly-overlapping class, the groups are not separate enough for anyone to genuinely straddle two of them. Folds tend to show up in larger settings — overlapping project teams, interlocking company boards, communities of practice — where cohesive groups are real and distinguishable but some people hold dual membership.

Ties that torture

So far both stories have treated an unusual position as an advantage: the broker profits from the hole, the fold-member from the overlap. But there is a darker side, and it is worth ending this half of the tutorial on.

Krackhardt’s “ties that torture” argument turns the fold on its head. Belonging fully to two cohesive groups does not only grant you two sets of resources — it also subjects you to two sets of obligations. Each group has its own norms, loyalties, and expectations, and an insider to both is answerable to both at once. When those expectations conflict — group A wants what group B forbids — the person in the fold is caught in the middle, and may be more constrained, not less.

Notice how neatly this inverts Burt’s picture. For Burt, the person whose contacts are all tied to one another is constrained (recall node_by_constraint()): everyone watches everyone, so there is no room to manoeuvre. Krackhardt points out that the fold-member sits inside not one but two such dense, watchful groups. The very embeddedness that gives a fold its legitimacy can also become a vice that grips.

Going further: The tension here — position as opportunity versus position as obligation — runs right through network theory. The same overlap that Vedres and Stark celebrate as a fold (a source of generative friction and innovation) is what Krackhardt warns can torture. Which reading applies is often an empirical question about whether the two groups’ norms reinforce or contradict one another.

In brief: A structural hole is a gap between groups, spanned from outside for information advantage; a structural fold is an overlap of groups, occupied from inside for information advantage plus the standing to act. node_is_fold() flags fold members. Krackhardt’s “ties that torture” is the reminder that dual membership brings dual obligations, and can constrain rather than liberate.

Structural equivalence

On this page: Finding classes · Census · Dendrograms · Choosing k

gif of rick multiplying

We now switch from asking about individual positions (who is a broker?) to asking about shared positions: which nodes play the same kind of role? Grouping nodes that occupy similar positions into classes is called finding equivalence classes, and there are three classic definitions of what “similar position” means. We will build up all three, but start with the strictest.

Two nodes are structurally equivalent if they have the same tie partners — they are connected to (and from) exactly the same others. Think of two assistants who both report to the same three managers: they need not know each other, but their ties point to the same people. Let’s make sure the definition is clear before we compute it.

We’re going to identify structurally equivalent positions across all the data that we have, including ‘task’, ‘social’, and ‘friend’ ties. So that is, we are using the multiplex ison_algebra dataset again and not a uniplex subgraph thereof — a node’s “role” here draws on all three kinds of relationship at once.

Finding structurally equivalent classes

In {netrics}, finding how the nodes of a network can be partitioned into structurally equivalent classes can be as easy as one function call, node_in_structural(), which returns a class label for each node.

Run the code below to compute the structural-equivalence classes and colour the graph by them.

node_in_structural(ison_algebra)

ison_algebra %>%
  mutate(se = node_in_structural(ison_algebra)) %>%
  graphr(node_color = "se")

That is all it takes to get an answer. But a lot is going on behind the scenes, and understanding it is what separates running the function from being able to interpret and defend its results. So over the next three subsections we will slow right down and unpack the three steps node_in_structural() performs for us: (1) taking a census of each node’s ties, (2) building a dendrogram of how similar those censuses are, and (3) cutting that tree into a chosen number of classes.

Step one: starting with a census

All equivalence classes are based on nodes’ similarity across some profile of motifs — small, countable local structures. In {netrics}, we call these motif censuses. Any kind of census can be used, and {netrics} includes a few options, but node_in_structural() is based off of the census of all the nodes’ ties, both outgoing and incoming, to characterise their relationships to tie partners. The function that produces this tie census is node_x_tie().

Produce the tie census of ison_algebra, and find its dimensions with dim(). What do the numbers of rows and columns represent?

# node_x_tie() takes the network as its argument:
node_x_tie(____)
node_x_tie(ison_algebra)
# Wrap the census in dim() to get its number of rows and columns:
dim(node_x_tie(ison_algebra))
node_x_tie(ison_algebra)
dim(node_x_tie(ison_algebra))

We can see that the result is a matrix of 16 rows and 96 columns, because we want to catalogue or take a census of all the different incoming/outgoing partners our 16 nodes might have across these three networks. (16 nodes \(\times\) 2 directions \(\times\) 3 tie types \(= 96\) columns.) Two nodes with identical rows here are structurally equivalent.

Note also that the result is a weighted matrix; what would you do if you wanted it to be binary (just present/absent)?

Turn the tie census into a binary one — try it yourself before peeking.

# One way: compare the census to 0 to get TRUE/FALSE, then add 0 to get 1/0.
as.matrix((node_x_tie(ison_algebra)>0)+0)
# A tidier way: simplify the network first by dropping the tie-type distinction,
# then take the census.
ison_algebra %>%
  select_ties(-type) %>%
  node_x_tie()
# But it's easier to simplify the network by removing the classification into different types of ties.
# Note that this also reduces the total number of possible paths between nodes
ison_algebra %>%
  select_ties(-type) %>%
  node_x_tie()

Note that node_x_tie() does not need to be passed to node_in_structural() — this is done automatically! However, the more generic node_in_equivalence() is available and can be used with whichever census (node_x_*() output) is desired. Feel free to explore using some of the other censuses available in {netrics}, though some common ones are already used in the other equivalence convenience functions, e.g. node_x_triad() in node_in_regular() and node_x_path() in node_in_automorphic() — functions we will actually use later in this tutorial.

Step two: growing a tree of similarity

The next part takes this census and creates a dendrogram based on distance or dissimilarity among the nodes’ census profiles. This is all done internally within e.g. node_in_structural(), though there are two important parameters that can be set to obtain different results.

First, users can set the type of distance measure used. For enthusiasts, this is passed on to stats::dist(), so that help page should be consulted for more details. By default "euclidean" is used.

Second, we can also set the type of clustering algorithm employed. By default, {netrics}’s equivalence functions use hierarchical clustering , "hier", but for compatibility and enthusiasts, we also offer "concor", which implements a CONCOR (CONvergence of CORrelations) algorithm.

We can see the difference from varying the clustering algorithm and/or distance by plotting the dendrograms (hidden) in the output from node_in_structural().

Run the code to plot three dendrograms — varying first the distance, then the algorithm — and watch how the tree changes.

alge <- to_named(ison_algebra) # fake names to make comparison clearer
plot(node_in_structural(alge, cluster = "hier", distance = "euclidean"))

# changing the type of distance used
plot(node_in_structural(alge, cluster = "hier", distance = "manhattan"))

# changing the clustering algorithm
plot(node_in_structural(alge, cluster = "concor", distance = "euclidean"))

So plotting a membership vector from {netrics} returns a dendrogram with the names of the nodes on the y-axis and the distance between them on the x-axis. Using the census as material, the distances between the nodes is used to create a dendrogram of (dis)similarity among the nodes. Basically, as we move to the right, we’re allowing for more and more dissimilarity among those we cluster together. A fork or branching point indicates the level of dissimilarity at which those two or more nodes would be said to be equivalent. Where two nodes’ branches join/fork represents the maximum distance among all their leaves, so more similar nodes’ branches fork closer to the tree’s canopy, and less similar (groups of) nodes don’t join until they form basically the trunk.

Note that with the results using the hierarchical clustering algorithm, the distance directly affects the structure of the tree (and the results).

The CONCOR dendrogram operates a bit differently to hierarchical clustering though. Instead it represents how converging correlations repeatedly bifurcate the nodes into one of two partitions. As such the ‘distance’ is really just the (inverse) number of steps of bifurcations until nodes belong to the same class.

Beginner note: To read a dendrogram, pick a vertical line (a cut-point) and slide it from right to left: every branch it crosses becomes its own cluster, containing exactly the leaves (nodes) that branch reaches. The further right you cut, the fewer and more internally varied the clusters; the further left, the more numerous and strict. Since the tree’s shape depends on the distance measure and clustering algorithm you chose, always report those choices alongside your results.

Step three: identifying the number of clusters

Another bit of information represented in the dendrogram is where the tree should be cut (the dashed red line) and how the nodes are assigned to the branches (clusters) present at that cut-point.

But where does this red line come from? Or, more technically, how do we identify the number of clusters into which to assign nodes?

{netrics} includes several different ways of establishing k, or the number of clusters. Remember, the further to the right the red line is (the lower on the tree the cut point is) the more dissimilar we’re allowing nodes in the same cluster to be. The simplest option is to set k ourselves by just passing it an integer, say k = 2. What we are saying here is that we want to partition the nodes into two clusters, no matter how dissimilar they are.

Run the code to force a two-cluster solution, and see where the red line lands.

plot(node_in_structural(to_named(ison_algebra), k = 2))

But we’re really just guessing. Maybe 2 is not the best k? To establish what the best k is for this clustering exercise, we need to iterate through a number of potential k and consider their fitness by some metric. There are a couple of options here.

One is to consider, for each k, how correlated this partition is with the observed network. When there is one cluster for each vertex in the network, cell values will be identical to the observed correlation matrix, and when there is one cluster for the whole network, the values will all be equal to the average correlation across the observed matrix. So the correlations in each by-cluster matrix are correlated with the observed correlation matrix to see how well each by-cluster matrix fits the data.

Of course, the perfect partition would then be where all nodes are in their own cluster, which is hardly ‘clustering’ at all. Also, increasing k will always improve the correlation. But if one were to plot these correlations as a line graph, then we might expect there to be a relatively rapid increase in correlation as we move from, for example, 3 clusters to 4 clusters, but a relatively small increase from, for example, 13 clusters to 14 clusters. By identifying the inflection point in this line graph, {netrics} selects a number of clusters that represents a trade-off between fit and parsimony. This is the k = "elbow" method.

The other option is to evaluate a candidate for k based not on correlation but on a metric of how similar each node in a cluster is to others in its cluster and how dissimilar each node is to those in a neighbouring cluster. When averaged over all nodes and all clusters, this provides a ‘silhouette coefficient’ for a candidate of k. Choosing the number of clusters that maximizes this coefficient, which is what k = "silhouette" does, can return a somewhat different result to the elbow method.

Compare the elbow and silhouette solutions, holding everything else the same.

plot(node_in_structural(to_named(ison_algebra), k = "elbow"))
plot(node_in_structural(to_named(ison_algebra), k = "silhouette"))

Ok, so it looks like the elbow method returns k == 3 as a good trade-off between fit and parsimony. The silhouette method, by contrast, sees k == 4 as maximising cluster similarity and dissimilarity. Either is probably fine here, and there is much debate around how to select the number of clusters anyway. However, the silhouette method seems to do a better job of identifying how unique the 16th node is. The silhouette method is also the default in {netrics}.

Note that there is a somewhat hidden parameter here, range. Since testing across all possible numbers of clusters can get computationally expensive (not to mention uninterpretable) for large networks, {netrics} only considers up to 8 clusters by default. This however can be modified to be higher or lower, e.g. range = 16.

Finally, one last option is k = "strict", which only assigns nodes to the same partition if there is zero distance between them. This is quick and rigorous solution, however oftentimes this misses the point in finding clusters of nodes that, despite some variation, can be considered as similar on some dimension.

Try the strict solution. How many clusters does it produce, and why?

plot(node_in_structural(to_named(ison_algebra), k = "strict"))

Here for example, no two nodes have precisely the same tie-profile, otherwise their branches would join/fork at a distance of 0. As such, k = "strict" partitions the network into 16 clusters. Where networks have a number of nodes with strictly the same profiles, such a k-selection method might be helpful to recognise nodes in exactly the same structural position, but here it essentially just reports nodes’ identity.

In brief: node_in_structural() partitions nodes into structurally equivalent classes in three steps: a tie census (node_x_tie()), a dendrogram built from the distances between census profiles (cluster = "hier" or "concor", distance = "euclidean" or others), and a cut-point choosing the number of clusters (k = an integer, "strict", "elbow", or the default "silhouette").

Blockmodelling

On this page: Summarising profiles · Plotting blockmodels

gif of mortys in a block parade

Summarising profiles

Ok, so now we have a result from establishing nodes’ membership in structurally equivalent classes. We can graph this of course, as we did above.

Colour the graph by structural-equivalence class.

alge <- to_named(ison_algebra)
alge %>%
  mutate(se = node_in_structural(alge)) %>%
  graphr(node_color = "se")

While this plot adds the structurally equivalent classes information to our earlier graph, it doesn’t really help us understand how the classes relate. That is, we might be less interested in how the individuals in the different classes relate, and more interested in how the different classes relate in aggregate. This aggregation of a network into the relationships between classes is called blockmodelling , and it is the pay-off of all the equivalence work we just did.

gif of mortys learning about roles

One option that can be useful for characterising what the profile of ties (partners) is for each position/equivalence class is to use summary(). It summarises some census result by a partition (equivalence/membership) assignment. By default it takes the average of ties (values), but this can be tweaked by assigning some other summary statistic as FUN =.

Summarise the tie census by the structural-equivalence membership.

# Wrap node_x_tie() inside summary(), and pass a membership result.
# The census goes first, then membership = a membership vector.
summary(node_x_tie(____),
        membership = ____)
# Use the named algebra network 'alge' for both:
summary(node_x_tie(alge),
        membership = node_in_structural(alge))
summary(node_x_tie(alge),
        membership = node_in_structural(alge))

This node census produces 96 columns, \(16 \text{nodes} * 2 \text{directions} * 3 \text{edge types}\), so it takes a bit to look through what varies between the different classes as ‘blocked’. But there are only four rows (the four structurally equivalent classes, according to the default) — a big reduction from sixteen individuals.

Plotting blockmodels

Reading the summary table is hard work; a picture is easier. Another way to characterise the classes is to plot the blockmodel as a whole. Passing the plot() function an adjacency/incidence matrix along with a membership vector allows the matrix to be sorted and framed (without the membership vector, just the adjacency/incidence matrix is plotted).

Plot the blockmodel for the whole network, then for each tie type separately.

# Use the same plot() you used for the dendrograms, but pass it a matrix.
# as_matrix() turns the network into its adjacency matrix.
plot(as_matrix(____),
     membership = ____)
# For the whole network:
plot(as_matrix(alge),
     membership = node_in_structural(alge))
# For a single tie type, extract it first with to_uniplex(),
# but keep the membership from the whole network so the blocks line up:
plot(as_matrix(to_uniplex(alge, "friends")),
     membership = node_in_structural(alge))
# plot the blockmodel for the whole network
plot(as_matrix(alge),
     membership = node_in_structural(alge))

# plot the blockmodel for the friends, tasks, and social networks separately
plot(as_matrix(to_uniplex(alge, "friends")),
     membership = node_in_structural(alge))
plot(as_matrix(to_uniplex(alge, "tasks")),
     membership = node_in_structural(alge))
plot(as_matrix(to_uniplex(alge, "social")),
     membership = node_in_structural(alge))

By passing the membership argument our structural equivalence results, the matrix is re-sorted to cluster or ‘block’ nodes from the same class together. This can help us interpret the general relationships between classes.

Beginner note: To read a blockmodel plot: rows are senders and columns receivers, with the nodes reordered so that members of the same class sit next to one another, and lines marking the class boundaries. Each rectangle of cells (a ‘block’) then shows how one class relates to another: a dark, dense block means most members of the row class send (strong) ties to members of the column class, an empty block means they rarely or never do, and the blocks on the diagonal show ties within each class.

For example, when we plot the friends, tasks, and social networks using the structural equivalence results, we might characterise the four classes like so:

  • The first group (of 6) are a bit of a mix: there seem to be two popular friends, one that strongly reciprocates and the other that nominates no friendships but seems to nominate others in the group as social contacts instead. The first group rely heavily on the nerd for advice.
  • The second group (of 5) seem to be strongly reciprocal in friendship and social together, lightly advise each other but mostly go to the nerd for advice.
  • The third group (of 4) are also strongly reciprocal in friendship, but also sometimes nominate some in groups one and two as friends too. There is at least a pair that often hang out together socially, but this group do not hang out with the nerd much nor ask them for advice as much as members of the other groups.
  • The nerd is a loner, no friends, but everyone hangs out with them for task advice.

Reading a blockmodel like this — turning blocks of density into a sentence about each role — is the interpretive skill this whole section is building towards.

In brief: Once you have a membership vector, summary(node_x_tie(net), membership = ) averages each class’s tie profile, and plot(as_matrix(net), membership = ) draws the blockmodel: the adjacency matrix sorted into blocks whose density (or emptiness) characterises how each class relates to each other class.

Reduced graphs

On this page: From blocks to ties · Naming positions

The blockmodel plot showed us the classes as sorted blocks of a matrix. The final step is to treat those classes as nodes in their own right — to draw the network of roles. Let’s use the 4-cluster solution on the valued network (though binary is possible too) to create a reduced graph . A reduced graph is a transformation of a network such that the nodes are no longer the individual nodes but the groups of one or more nodes as a class, and the ties between these blocked nodes can represent the sum or average tie between these classes. Of course, this means that there can be self-ties or loops, because even if the original network was simple (not complex ), any within-class ties will end up becoming loops and thus the network will be complex.

From blocks to ties

to_blocks() carries out this contraction: pass it the network and a membership vector, and it returns a matrix with one row and column per class, where each cell holds the average tie (weight) from one class to another.

Contract the algebra network into its four structural-equivalence blocks.

(bm <- to_blocks(alge, node_in_structural(alge)))

Notice how this is just a compact, numerical version of the blockmodel plot from the previous page: each cell summarises a whole block of the sorted matrix, and the diagonal records the average tie within each class. Sixteen numbers now stand in for a sixteen-node network.

Naming positions

Since the reduced graph is a network like any other, we can convert it, name its (class-)nodes something more evocative, and graph it.

Turn the block matrix into a graph, give the four positions names, and plot it.

bm <- bm %>% as_tidygraph %>%
  mutate(name = c("Freaks", "Squares", "Nerds", "Geek"))
graphr(bm)

In the reduced graph, the loops show which positions have (on average, strong) ties among their own members, while the ties between the class-nodes summarise the relationships we read off the blockmodel on the previous page — for instance, every position sends ties towards the “Geek”, the loner everyone consults for task advice. A reduced graph like this is the pay-off of a positional analysis: a network of sixteen individuals compressed into a comprehensible structure of four roles.

In brief: to_blocks() contracts a network into its classes, yielding a reduced graph whose nodes are positions and whose ties (including loops) are the average ties within and between classes — a role-level summary of the whole network.

Comparing equivalences

On this page: Regular · Automorphic · Which when?

Structural equivalence was strict: it demanded the same tie partners. That is often too strict for thinking about roles. Consider two shop managers in different towns: they supervise different staff and answer to different regional bosses, so they share no tie partners at all and are not structurally equivalent — yet intuitively they play the same role. The other two classic equivalences relax the definition to capture exactly this.

Helpfully, ison_algebra lets us compute all three and compare them directly, because {netrics} provides node_in_regular() and node_in_automorphic() alongside node_in_structural(), all built on the same census → dendrogram → cut machinery you unpacked earlier — only starting from a different census.

Regular equivalence

Two nodes are regularly equivalent if they have similar patterns of ties — they connect to equivalent others, not necessarily to the same others. This is the most permissive of the three definitions, and usually the closest to the everyday idea of a social “role”: all teachers relate to some students, all students to some teacher, regardless of which particular ones. Under the hood, node_in_regular() uses a triad census (node_x_triad()) rather than the tie census, because it cares about the shape of local neighbourhoods rather than their exact membership.

Compute the regular-equivalence classes for alge, plot the dendrogram, and colour the graph by class.

node_in_regular(alge)
plot(node_in_regular(alge))

alge %>%
  mutate(re = node_in_regular(alge)) %>%
  graphr(node_color = "re")

Regular equivalence lumps almost everyone into a single broad role and splits off just one node. Substantively, that lone node is the one whose pattern of relating is genuinely distinct — the task hub everyone consults but who consults no one back — while everyone else, despite differing in exactly whom they tie to, occupies much the same kind of position.

Automorphic equivalence

Two nodes are automorphically equivalent if they can be swapped without changing the structure of the network — if you relabelled one as the other, the network would look identical. This sits between structural and regular equivalence in strictness: it does not require the same partners (as structural does), but it does require the same structural surroundings, which is more demanding than regular equivalence’s “equivalent others”. node_in_automorphic() uses a path census (node_x_path()), summarising each node by the distances to everyone else.

Compute the automorphic-equivalence classes for alge, plot the dendrogram, and colour the graph by class.

node_in_automorphic(alge)
plot(node_in_automorphic(alge))

alge %>%
  mutate(ae = node_in_automorphic(alge)) %>%
  graphr(node_color = "ae")

Automorphic equivalence here carves the network into many small classes: requiring identical structural surroundings is demanding, so few nodes qualify as truly interchangeable and most end up on their own.

Which equivalence when?

It is tempting to line the three up as rungs of one ladder, and in theory they do nest: every structurally equivalent pair is also automorphically equivalent, and every automorphically equivalent pair is also regularly equivalent — so structural is strictest, regular loosest.

Run all three side by side and compare how many classes each finds.

node_in_structural(alge)
node_in_regular(alge)
node_in_automorphic(alge)

In practice, though, the class counts you get here will not line up neatly with that theoretical nesting. Each function uses a different census (ties, triads, paths) and independently chooses its own number of clusters via the silhouette method, so the raw counts reflect those algorithmic choices as much as the underlying theory. The lesson is to treat structural, regular, and automorphic equivalence as three different lenses on position — a strict one, a role-based one, and an interchangeability one — rather than three interchangeable rungs. Which you reach for depends on your question: “who has identical contacts?” (structural), “who plays the same kind of role?” (regular), or “who is structurally interchangeable?” (automorphic).

In brief: node_in_structural() (same partners, via a tie census), node_in_regular() (same pattern / role, via a triad census), and node_in_automorphic() (interchangeable, via a path census) are three lenses on position, from strictest to loosest in theory — though each chooses its own k, so their class counts won’t nest neatly in practice.

Free play

Now it’s your turn, with a fresh network.

Find the regularly equivalent classes in the ison_lawfirm dataset. As this is a multiplex network, you may want to make it a uniplex network first (remember to_uniplex()), then apply node_in_regular() and colour a graph by the result. As an extension, also compute the structurally and automorphically equivalent classes and compare all three — do they agree on who plays which role?

# Load the data and peek at its tie types (it is multiplex):
data("ison_lawfirm", package = "manynet")
ison_lawfirm
# Extract one tie type, e.g. "friends", and find its regular-equivalence classes:
lawfirm_friends <- to_uniplex(ison_lawfirm, "friends")
node_in_regular(lawfirm_friends)
# Colour a graph by the classes, and compare with the other two equivalences:
lawfirm_friends %>%
  mutate(re = node_in_regular(lawfirm_friends)) %>%
  graphr(node_color = "re")
node_in_structural(lawfirm_friends)
node_in_automorphic(lawfirm_friends)

If you would rather choose your own data (browse the bundled datasets with table_data()), here is one suggestion per flavour, each with the multiplex and/or directed ties that make positional analysis interesting:

Classic (small, tidy) Fiction (moderate) Real-world (larger)
ison_monks (directed, weighted, multiplex ties among monks in Sampson’s monastery — the classic blockmodelling dataset: can you recover the factions he observed as positions?) fict_thrones (directed, multiplex ties among Game of Thrones characters — do the equivalence classes line up with the houses, or cut across them?) irps_911 (multiplex covert network around the 9/11 attacks — where are the structural holes, and who spans them while staying inconspicuous?)

Summary

gif of an enthusiastic standing ovation and cries of bravo

Well done – you have completed the tutorial on position and equivalence! Along the way, you have learned to use these functions:

Function What it does
to_uniplex() extracts one type of tie from a multiplex network
tie_is_bridge(), node_by_bridges() flags bridging ties; counts the bridges adjacent to each node
node_by_constraint() Burt’s constraint: how closed each node’s neighbourhood is
node_by_effsize(), node_by_redundancy(), node_by_efficiency(), node_by_hierarchy() further structural-holes measures: non-redundant contacts, their overlap, and more
node_is_fold() flags nodes in a structural fold (cohesive membership in more than one group)
node_is_min() flags the node(s) with the minimum score, for highlighting
node_in_structural() structurally equivalent classes (same tie partners; cluster, distance, k, range options)
node_in_regular(), node_in_automorphic() regularly and automorphically equivalent classes (same role; interchangeable)
node_x_tie(), node_x_triad(), node_x_path() the tie, triad, and path censuses behind the three equivalences
node_in_equivalence() generic equivalence classes from any node_x_*() census
plot() on a membership vector draws the dendrogram and its cut-point
plot(as_matrix(net), membership = ) draws the blockmodel: the matrix sorted into blocks
summary(census, membership = ) averages each class’s census profile
to_blocks() contracts a network into a reduced graph of positions
graphr(..., node_color = , node_size = ) maps memberships or measures onto the graph

When you are ready, continue with the other {netrics} tutorials — on centrality, community, and topology — where further ways of summarising network structure are introduced. Run run_tute() at the console to see all available tutorials.

Glossary

Here are some of the terms that we have covered in this tutorial:

Automorphiceq
Two or more nodes are automorphically equivalent if they can be interchanged without changing the structure of the network.
Blockmodel
A blockmodel reduces a network to a smaller comprehensible structure of the roles positions take with respect to one another.
Bridge
A bridge or isthmus is a tie whose deletion increases the number of components.
Community
A community is a set of nodes more densely connected to one another than to other nodes in the network.
Complex
A complex network is one that includes or can include loops or self-ties.
Component
A component is a connected subgraph not part of a larger connected subgraph.
Constraint
The constraint of a node is a measure of how much its connections are also connected to one another.
Dendrogram
A dendrogram is a tree diagram that records the sequences of merges or splits from some, say, hierarchical clustering.
Hierclust
Hierarchical clustering is a method of cluster analysis that seeks to build a hierarchy of clusters.
Motif
A subgraph that is exceptional or significant compared to a null model.
Multiplex
A network that includes multiple types of tie.
Reduced
A reduced graph is a contraction of a network into the ties within and between blocks.
Regulareq
Two or more nodes are regularly equivalent if they have similar motif patterns.
Structeq
Two or more nodes are structurally equivalent if they have similar ties to and from all other nodes in the network.
Structfold
A structural fold is an overlap between two or more cohesive groups in a network.
Structhole
A structural hole is a gap between two parts of a network that would benefit from being connected.
Weighted
A weighted network is where the ties have been assigned weights.

Position and Equivalence

by James Hollway