library(netrics) library(autograph)
library(learnr) knitr::opts_chunk$set(echo = FALSE) library(netrics) library(autograph) stocnet_theme("default") clear_glossary() learnr::random_phrases_add(language = "fr", praise = c("C'est génial!", "Beau travail", "Excellent travail!", "Bravo!", "Super!", "Bien fait", "Bien joué", "Tu l'as fait!", "Je savais que tu pouvais le faire.", "Ça a l'air facile!", "C'était un travail de première classe.", "C'est ce que j'appelle un bon travail!"), encouragement = c("Bon effort", "Vous l'avez presque maîtrisé!", "Ça avance bien.", "Continuez comme ça.", "Continuez à travailler dur!", "Vous apprenez vite!", "Vous faites un excellent travail aujourd'hui.")) learnr::random_phrases_add(language = "en", praise = c("That's brilliant!", "Great work!", "Well done!", "Awesome!"), encouragement = c("Good effort")) friends <- to_uniplex(ison_algebra, "friends") social <- to_uniplex(ison_algebra, "social") tasks <- to_uniplex(ison_algebra, "tasks") alge <- to_named(ison_algebra)
sys_locale <- Sys.getlocale("LC_CTYPE") if (grepl("fr", sys_locale, ignore.case = TRUE)) { options(tutorial.language = "fr") } else { options(tutorial.language = "en") }
The earlier tutorials asked how central each node is,
and which cohesive r gloss("communities","community") 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:
r gloss("structural holes","structhole") and
r gloss("structural folds","structfold") —
two competing stories about where in a network innovation happens,
and what it takes for a node to profit from its position.r gloss("blockmodel") and a r gloss("reduced graph","reduced").
We will meet all three of the classic equivalences:
structural, regular, and automorphic.
::: {.callout}
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).
:::
::: {.callout} 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. :::
By the end of this tutorial, you should be able to:
r gloss("multiplex") network into its constituent networks with to_uniplex()tie_is_bridge() and count them per node with node_by_bridges()node_by_constraint(), and interpret what high and low constraint mean substantivelynode_in_structural(), node_in_regular(), and node_in_automorphic()to_blocks()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().
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.
As a r gloss("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 r gloss("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.
question("If we interpret ties with higher weights as strong ties, and lesser weights as weak ties, then, according to network theory (Granovetter's 'strength of weak ties'), where would we expect novel information to come from?", answer("Weak ties", correct = TRUE, message = paste(learnr::random_praise(), "Weak ties tend to reach beyond our immediate, tightly-knit circle, so they are more likely to bring in information we don't already have.")), answer("Strong ties", message = "Not quite -- strong ties usually connect us to people who already share our circle (and so our information), which is why Granovetter emphasised the *weak* ones for novelty."), answer("Isolates", message = "Not quite -- an isolate has no ties at all, so it can neither send nor receive novel information through the network."), answer("Highest degree nodes", message = "Not quite -- having many ties is not the same as having ties that reach into different parts of the network; a high-degree node may still sit inside a single, redundant cluster."), random_answer_order = TRUE, allow_retry = TRUE )
::: {.callout}
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.
:::
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
r gloss("structural hole","structhole"),
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.
question("Which network concepts are associated with innovation?", answer("Structural holes", correct = TRUE, message = "Being positioned in a structural hole is said to be a brokerage position that brings with it information arbitrage possibilities."), answer("Structural folds", correct = TRUE, message = "Being positioned in a structural fold, with in-group membership in multiple groups, can provide not only information arbitrage possibilities, but also the standing in the target group to introduce novelty successfully. (More on this in the next section.)"), answer("Structural balance", message = "Structural balance is about whether signed (e.g. friend/enemy) ties form stable, tension-free configurations -- a different question from where innovation appears."), answer("Structural equivalence", message = '<img src="https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExdmY4ejhpMnZoZmh3OGUxbTdjbXQwMTcwcmg0ZHo5dmJ6NzI3c3FxNyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9dg/2E7VU4zGVm8H1mFSAz/giphy.gif" alt="gif of a morty assassinating another morty"/>'), answer("Structuralism", message = "Structuralism is a broad intellectual movement, not a specific network mechanism for innovation."), random_answer_order = TRUE, allow_retry = TRUE )
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.
question("There are a number of measures that might be used to approximate the concept of structural holes. Select all that apply.", answer("Constraint", correct = TRUE, message = paste(learnr::random_praise(), "Constraint captures how far a node's contacts are tied to one another -- high constraint means few structural holes to exploit.")), answer("Effective size", correct = TRUE, message = "Yes -- effective size counts a node's *non-redundant* contacts, so it grows as the node spans more structural holes."), answer("Bridges", correct = TRUE, message = "Yes -- a bridge is precisely a tie that spans a structural hole between otherwise disconnected parts of the network."), answer("Redundancy", correct = TRUE, message = "Yes -- redundancy measures how much a node's contacts overlap, so it is the flip side of having structural holes."), answer("Efficiency", correct = TRUE, message = "Yes -- efficiency is effective size relative to degree, i.e. how much structural-hole benefit each contact yields on average."), answer("Hierarchy", correct = TRUE, message = "Yes -- hierarchy captures whether a node's constraint is concentrated in a single dominant contact, another facet of its structural-hole position."), random_answer_order = TRUE, allow_retry = TRUE )
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.
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 r gloss("bridge", "bridge"):
formally, a tie whose removal would increase the number of
r gloss("components","component") — 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.
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 r gloss("constraint", "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.
alge <- to_named(ison_algebra) friends <- to_uniplex(alge, "friends") social <- to_uniplex(alge, "social") tasks <- to_uniplex(alge, "tasks")
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:
So what can we learn from this plot about where innovation might occur within this network?
question("Do bridges innovate?", answer("No", correct = TRUE, message = paste(learnr::random_praise(), "Ties don't have agency -- people (nodes) do. It is the *broker* sitting in the structural hole who can act on the novel information, not the tie itself.")), answer("Yes", message = "Bridges can be important conduits for information, but ties do not typically have agency. Nodes do. Nodes in structural holes are called brokers. Nodes that are located in structural holes that would be there but for the node and their ties can be identified through various measures such as the number of bridges they are adjacent to, or their constraint score."), random_answer_order = TRUE, allow_retry = TRUE )
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.
::: {.callout}
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.
:::
::: {.callout}
In brief: Only ties can be r gloss("bridges","bridge"):
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.
:::
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 r gloss("structural fold","structfold"):
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.
question("What does a structural fold add to the structural-hole story?", answer("Membership: the node is a recognised insider to both groups, giving it the standing to get novel ideas adopted, not just heard.", correct = TRUE, message = learnr::random_praise()), answer("Nothing new -- a fold is just another name for a structural hole.", message = "Not quite -- a hole is a *gap between* groups spanned from outside, whereas a fold is an *overlap*: the node belongs to both groups at once."), answer("It measures how many ties a node has.", message = "Not quite -- that would be degree. A fold is about overlapping *group membership*, not tie count."), answer("It guarantees the node has the highest betweenness.", message = "Not quite -- betweenness is about lying on shortest paths; a fold is about cohesive membership in more than one group."), allow_retry = TRUE, random_answer_order = TRUE )
{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.
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.
question("According to Krackhardt's 'ties that torture' argument, why might a node in a structural fold be *more* constrained rather than less?", answer("It is subject to the norms and obligations of two cohesive groups at once, which may conflict.", correct = TRUE, message = paste(learnr::random_praise(), "Dual membership means dual accountability -- the same embeddedness that grants legitimacy can also trap the node between competing expectations.")), answer("It has too few ties to be influential.", message = "Not quite -- the problem isn't too few ties; it is being bound by the (possibly conflicting) obligations of two groups simultaneously."), answer("Its constraint score is always exactly zero.", message = "Not quite -- the argument is substantive, not about a particular numeric value; the point is that dual embeddedness can be constraining rather than liberating."), answer("Structural folds and structural holes are the same thing.", message = "Not quite -- they are opposites: a hole is a gap spanned from outside, a fold is an overlap of memberships."), allow_retry = TRUE, random_answer_order = TRUE )
::: {.callout} 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. :::
::: {.callout}
In brief: A r gloss("structural hole","structhole") is a
gap between groups, spanned from outside for information advantage;
a r gloss("structural fold","structfold") 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.
:::
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 r gloss("structurally equivalent", "structeq") 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.
question("Structural equivalence means identifying classes of nodes with...", answer("same/similar tie partners.", correct = TRUE, message = learnr::random_praise()), answer("same/similar pattern of ties.", message = "Close, but that is the definition for *regular* equivalence -- equivalent nodes tie to equivalent others, not necessarily the *same* others. We'll get to it."), answer("same/similar distance from all others.", message = "Not quite -- that is closer to *automorphic* equivalence (interchangeable positions). We'll get to it too."), allow_retry = TRUE, random_answer_order = TRUE )
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.
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.
All equivalence classes are based on nodes' similarity across some profile of
r gloss("motifs","motif") — 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.
The next part takes this census and creates a r gloss("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 r gloss("hierarchical clustering","hierclust"), "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"))
question("Do you see any differences?", answer("Yes", correct = TRUE, message = paste(learnr::random_praise(), "The dendrograms shift as you change the distance measure and the clustering algorithm -- which is exactly why it matters to report which options you used.")), answer("No", message = "Look again -- changing the distance (euclidean vs. manhattan) and especially the algorithm (hierarchical vs. CONCOR) reshapes the dendrogram and can regroup the nodes."), allow_retry = TRUE)
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.
::: {.callout} 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. :::
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.
::: {.callout}
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").
:::
On this page: Summarising profiles · Plotting blockmodels

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
r gloss("blockmodelling","blockmodel"), 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.
Reading the summary table is hard work; a picture is easier.
Another way to characterise the classes is to plot the
r gloss("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.
::: {.callout} 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:
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.
question("In a blockmodel plot sorted by class, what does an empty (white) off-diagonal block between class A (rows) and class B (columns) tell you?", answer("Members of class A rarely or never send ties to members of class B.", correct = TRUE, message = learnr::random_praise()), answer("Classes A and B are the same class.", message = "Not quite -- an empty block is about the *absence of ties* from one class to another, not about the classes being identical. Identical classes wouldn't be drawn as two separate blocks."), answer("Every member of class A sends a tie to every member of class B.", message = "That would be a full, dark block -- the opposite of an empty one."), answer("Class A has no members.", message = "Not quite -- an empty block still has rows (members); it just records that those members don't tie to class B."), allow_retry = TRUE, random_answer_order = TRUE )
::: {.callout}
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.
:::
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 r gloss("reduced graph","reduced").
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 r gloss("complex")),
any within-class ties will end up becoming loops and thus the network will be complex.
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.
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.
::: {.callout}
In brief: to_blocks() contracts a network into its classes,
yielding a r gloss("reduced graph","reduced") 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.
:::
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.
question("Match the intuition to the equivalence. Which statement describes *regular* equivalence?", answer("Nodes tie to equivalent others -- the same *pattern* of ties, not necessarily to the same people.", correct = TRUE, message = paste(learnr::random_praise(), "Two managers who each supervise their own (different) team are regularly equivalent.")), answer("Nodes have exactly the same tie partners.", message = "That is *structural* equivalence -- the strictest of the three."), answer("Nodes can be swapped without changing the network's structure.", message = "That is *automorphic* equivalence -- interchangeable positions."), allow_retry = TRUE, random_answer_order = TRUE )
Two nodes are r gloss("regularly equivalent","regulareq") 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.
Two nodes are r gloss("automorphically equivalent","automorphiceq") 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.
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).
question("You want to identify which employees play the 'regional manager' role in a company network, regardless of which particular staff or head office they connect to. Which equivalence best fits?", answer("Regular equivalence", correct = TRUE, message = paste(learnr::random_praise(), "Roles defined by *pattern* of ties -- relating to some staff below and some office above -- are exactly what regular equivalence captures.")), answer("Structural equivalence", message = "Too strict here -- it would require the managers to share the *same* staff and office, which regional managers in different regions do not."), answer("Automorphic equivalence", message = "Closer, but it demands identical structural surroundings; the everyday notion of the same *role* across different regions is better matched by regular equivalence."), allow_retry = TRUE, random_answer_order = TRUE )
::: {.callout}
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.
:::

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?) |

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.
Here are some of the terms that we have covered in this tutorial:
r print_glossary()
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.