Skip to Tutorial Content

## Today’s target

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

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

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

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

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:

- Separate a multiplex network into its constituent networks with `to_uniplex()`
- Identify bridging ties with `tie_is_bridge()` and count them per node with `node_by_bridges()`
- Measure structural holes with `node_by_constraint()`, and interpret what high and low constraint mean substantively
- Distinguish structural holes from structural folds, and explain why a fold can constrain a broker rather than free them
- Classify a node’s brokerage into Gould and Fernandez’s five roles with `node_x_brokerage()`, once nodes belong to known groups
- Distinguish structural, regular, and automorphic equivalence, and compute each with `node_in_structural()`, `node_in_regular()`, and `node_in_automorphic()`
- Partition a network into equivalent classes, and understand the census, clustering, and k-selection choices behind it
- Read a dendrogram and a blockmodel, and justify a choice of k
- Score how well a partition fits with `net_by_inconsistency()`, and search for one directly with `node_in_block()`
- Contract a network into a reduced graph of positions with `to_blockmodel()`

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 `TRUE`s, 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.

    ## ℹ Assigning alphabetic baby names at random.

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: So far every broker has looked alike — constraint measures how much brokerage potential a node has, but not what kind. Once nodes belong to known groups, we can be far more specific: the Brokerage roles section below unpacks Gould and Fernandez’s typology of five distinct ways to sit between others.

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.

## Brokerage roles

On this page: Five roles · Counting roles · Whole network

Constraint told us how much brokerage potential a node has, but it treated every broker alike: a broker is simply someone whose contacts are not tied to one another. Once nodes belong to known groups, though, we can ask a sharper question — not just how much a node brokers, but what kind of brokering it does. Passing information between two members of your own team is a rather different act from passing it between two outsiders, or from being the single gateway through which news reaches your team.

Gould and Fernandez (1989) turned this intuition into a compact typology. Take any directed two-path in which a broker B sits between a source A and a target C (that is, A → B → C), and look only at which groups the three belong to. That alone distinguishes five kinds of brokerage.

### Five roles from group membership

To make the five roles concrete, imagine a law firm with offices in different cities, and think of B as the lawyer who relays a piece of advice from A on to C:

- A coordinator brokers within a single group: A, B, and C all belong to the same group (a lawyer relaying advice between two colleagues in their own office).
- An itinerant broker (or consultant) stands outside the group it serves: A and C share a group, but the broker B belongs to a different one (an out-of-town lawyer connecting two colleagues who both work in one office).
- A gatekeeper guards the way in: the source A is an outsider while the broker B and the target C share a group, so B governs what reaches its own group (a lawyer who takes advice from another office and passes it to a colleague in theirs).
- A representative speaks out: the source A and broker B share a group but the target C is an outsider, so B governs what its group sends out (a lawyer relaying a colleague’s advice to another office).
- A liaison connects three different worlds: A, B, and C each belong to a different group (a lawyer brokering between two other offices, neither of them their own).

Notice that the direction of the tie matters: gatekeeper and representative are mirror images of one another, distinguished only by whether the broker sits on the receiving or the sending side of its own group’s boundary.

### Counting each node’s roles

`node_x_brokerage()` counts, for every node, how many two-paths it brokers in each of the five roles. It needs two things: a directed network (so that source and target can be told apart) and a `membership` — the groups the roles are defined against. Those groups are supplied from outside; here we use the advice network among lawyers in `ison_lawfirm`, with each lawyer’s `office` (Boston, Hartford, or Providence) as the grouping.

Run the code to count each lawyer’s brokerage roles in the advice network, grouped by office.

    node_x_brokerage(lawfirm_advice, "office")

Each row is a lawyer and each column one of the five roles, plus a `Total`. Reading across a row tells you how that lawyer brokers, not just how often: most counts fall in the Coordinator column, because the bulk of advice stays within an office, while the lawyers with high Gatekeeper or Representative counts are the ones mediating across office boundaries — regulating what flows in to, or out of, their own office. Liaison counts are the rarest, since a liaison needs a two-path spanning three different offices at once.

The raw counts depend on how big and how active each group is, which makes them hard to compare across nodes. Passing `standardized = TRUE` instead returns a z-score for each role, saying how far above or below chance a node’s count lies given the group sizes — often the fairer basis for comparison.

### Brokerage across the whole network

Sometimes we care less about which individual brokers most and more about the network’s overall brokerage profile: is advice mostly kept within offices, or does it flow across them? `net_x_brokerage()` returns a single count per role for the network as a whole.

Summarise the whole advice network’s brokerage profile by office.

    net_x_brokerage(lawfirm_advice, "office")

Comparing the columns tells us how open the firm’s advice-seeking is: a profile dominated by coordinators describes a firm where advice stays close to home, whereas large gatekeeper, representative, and liaison counts would mark a firm whose offices lean heavily on one another for guidance.

Try it yourself: `node_x_brokerage()` works on any directed network with a group attribute. Try it on `ison_monks`, whose nodes carry Sampson’s factional `groups` — which monks broker across factions rather than within them?

    # ison_monks is directed and carries a "groups" attribute (Sampson's factions).
    node_x_brokerage(ison_monks, ____)

    # The membership is the name of the node attribute, in quotation marks:
    node_x_brokerage(ison_monks, "groups")

    node_x_brokerage(ison_monks, "groups")

Going further: The Gould-Fernandez roles are one way to characterise brokerage, but not the only one. `node_by_brokering_activity()` counts how often a node sits on a two-path between different groups, `node_by_brokering_exclusivity()` counts only the two-paths where it is the sole broker, and `node_in_brokering()` combines the two to label nodes as powerhouses, connectors, linchpins, or sideliners (Hamilton et al. 2020). See `?measure_brokerage` and `?member_brokerage` for details.

In brief: Once nodes belong to known groups, `node_x_brokerage()` counts, per node, how many two-paths it brokers as a coordinator, itinerant broker, gatekeeper, representative, or liaison (Gould and Fernandez’s five roles), and `net_x_brokerage()` gives the same profile for the whole network. Both need a directed network and a `membership`; `standardized = TRUE` returns z-scores instead of raw counts.

## 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_motif()` 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.

### Evaluating a blockmodel

Reading a blockmodel tells you what a partition says. It does not tell you how well it says it. Two different partitions of the same network will both produce a blockmodel you can describe in words, and you need some way to prefer one over the other.

The idea is to ask how far each block departs from being ideal. The two ideal types you have implicitly been using are the null block, which should contain no ties at all, and the complete block, which should contain every possible tie. Counting the ties you would have to add or remove to make every block one or the other gives a single number: how much the partition has to lie about the network in order to describe it. `net_by_inconsistency()` reports exactly that, normalised by the number of cells, so 0 is a perfect fit. Note that it therefore runs the opposite way to most measures — it is a distance from the ideal, so lower is better.

Compare the structural-equivalence partition of `alge` against a coarser one and against a random partition.

    net_by_inconsistency(alge, node_in_structural(alge))

    # a coarser partition has to lump dissimilar nodes together
    net_by_inconsistency(alge, node_in_structural(alge, k = 2))

    # and an arbitrary partition should do worse than a fitted one
    net_by_inconsistency(alge, sample(rep(1:4, length.out = net_nodes(alge))))

Notice that finer partitions always fit at least as well as coarser ones, which is why the criterion cannot by itself choose k for you — in the limit, giving every node its own class fits perfectly and explains nothing. It compares partitions at a given k.

Ideal blocks other than null and complete are available through the `blocks` argument. A regular block, for instance, only asks that every row and every column contain at least one tie, rather than all of them — which is exactly the blockmodel counterpart of regular equivalence.

    # structural blockmodelling: blocks must be empty or full
    net_by_inconsistency(alge, node_in_structural(alge, k = 3))

    # regular blockmodelling: blocks must be empty or have every row and column
    # represented, which is a much easier standard to meet
    net_by_inconsistency(alge, node_in_structural(alge, k = 3), blocks = c("nul", "reg"))

Since permitting more ideal types can only lower the criterion, compare partitions only when you have given each the same vocabulary.

Finally, once you can score a partition you can search for a good one directly, rather than clustering a similarity matrix and hoping the result fits. `node_in_block()` does this: it tries partitions, keeps whichever is most consistent, and returns it.

Search for a three-position blockmodel of `alge` and compare it to the structural-equivalence solution.

    set.seed(123)
    nbm <- node_in_block(alge, k = 3)
    net_by_inconsistency(alge, nbm)
    net_by_inconsistency(alge, node_in_structural(alge, k = 3))

Because the search is stochastic, running it again may give a different answer, so set a seed and compare runs with `net_by_inconsistency()`.

In brief: `net_by_inconsistency()` scores how far a partition’s blocks are from ideal (0 is perfect, lower is better), with the ideal types set by `blocks`; `node_in_block()` searches directly for the partition that minimises it.

## 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_blockmodel()` 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_blockmodel(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_blockmodel()` 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()` works differently from the census-based functions you have seen so far, because the definition is recursive: two nodes are equivalent if their alters are equivalent, whose equivalence depends in turn on their alters. So instead of building a census once and clustering it, it starts by assuming every node is equivalent to every other and then repeatedly revises those similarities until they settle, by checking how well each node’s alters can be paired up with the other’s. The resulting similarity matrix is then clustered exactly as before.

Two algorithms are available via the `regularity` argument: `"rolesim"` (the default) pairs each node’s alters one-to-one, so that two nodes match only if their neighbourhoods line up as wholes; `"rege"` lets the same alter be matched more than once, which is more permissive, and is what UCINET computes.

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 recursive similarity), 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_x_brokerage()`, `net_x_brokerage()` | Gould and Fernandez’s five brokerage roles, per node and for the whole network
`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_in_motif()` | classes of similar local embedding, from a triad or tetrad census
`node_x_tie()`, `node_x_triad()`, `node_x_path()` | the tie, triad, and path censuses behind the census-based equivalences
`regularity_rolesim()`, `regularity_rege()` | the recursive similarity matrices behind `node_in_regular()`
`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
`net_by_inconsistency()` | scores how far a partition’s blocks are from ideal (0 is perfect); `blocks` sets which ideals
`node_in_block()` | searches directly for the partition that best fits an ideal block structure
`summary(census, membership = )` | averages each class’s census profile
`to_blockmodel()` | contracts a network into a reduced graph of positions
`graphr(..., node_color = , node_size = )` | maps memberships or measures onto the graph

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

### Glossary

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

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

# Position and Equivalence

#### by James Hollway