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:
- 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.
- 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.

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

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

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.

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

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.