---
title: "Ego-ERGMs: roles from local structure"
engine: knitr
editor: source
subtitle: "Ancillary self-study · ICPSR Network Analysis: Advanced Topics"
---

```{r}
#| label: setup
#| include: false
source("_helpers.R")
require_pkgs(c("ergm", "network", "networkdata"))
set.seed(6886)
```

{{< include _ancillary_header.qmd >}}

::: {.callout-note title="What this extends, and why it is here"}
**Extends:** the ERGM session (day 11, W3 Mon), and it connects back to Olga's week-1 community detection.

**The question it answers.** Community detection asks "which nodes cluster together?" An ego-ERGM asks a different and often more useful question: **"which nodes play the same structural role?"** Here, "role" means "the local network around them has the same generative recipe," even if they sit in completely different parts of the graph. Two managers in different departments can play the same brokering role; community detection puts them in different communities, an ego-ERGM puts them in the same role.

**How it works, in one sentence.** Fit a small ERGM to each node's *ego-network* (the node plus its neighbours), then cluster nodes by the ERGM coefficients those local fits produce. Nodes whose local structure is generated the same way land in the same role.

**Why it is optional in 2026:** it is a specialised technique, it overlaps with material Olga already covered from a different angle, and it depends on a function from a GitHub-only package. Here for the people whose research is about roles rather than communities.

**What you need:** `ergm`, `network`, and `ego_ergm()` from `networkdata` (already installed for the course, but see the install note below if you are on your own machine).
:::

::: {.callout-warning title="Install note: `ego_ergm()` is not on CRAN"}
`ego_ergm()` lives in the `networkdata` package by Olga Chyzh, installed from GitHub, not CRAN. On the course machines it is already there. On your own machine:

```r
# install.packages("remotes")
remotes::install_github("ochyzh/networkdata")
```

Everything else in this document is CRAN-standard `ergm`. If the install fails, the *concepts* in §1 and §5 are still worth reading: the method is simple enough to understand without running it.
:::

# 1. The idea, before the code

Here is the recipe the `ego_ergm()` function automates, so that the output makes sense rather than arriving as a black box:

1. **For each node**, extract its ego-network: the node, its neighbours, and the ties among them.
2. **Fit a small ERGM** to each ego-network, using a handful of local terms (edges, mutuality, triangles). This gives every node a short vector of coefficients describing how its local neighbourhood is put together.
3. **Cluster those coefficient vectors** with a mixture model. Nodes whose ego-networks are generated by similar coefficients get grouped into the same **role**.
4. **Report** one representative ERGM per role: the generative recipe for that kind of local structure.

The output is not "node 12 is in community 3." It is "node 12 plays role B, and role B is the kind of local neighbourhood where ties are reciprocated but rarely closed into triangles." That is a statement about *position type*, and it is invariant to where in the graph the node sits.

::: {.callout-note collapse="true" title="Depth: how this differs from community detection, precisely"}
Community detection partitions nodes by **connection**: a community is a set of nodes densely tied *to each other*. Two nodes in the same community are, more or less by definition, near each other in the graph.

An ego-ERGM partitions nodes by **generative structure**: a role is a set of nodes whose local neighbourhoods follow the same ERGM. Two nodes in the same role may be on opposite sides of the graph with no path between their neighbourhoods: they share a role because the *rules* generating their local ties are the same.

The canonical example is brokerage. A broker sits between two dense clusters; its ego-network has a distinctive low-transitivity, high-degree signature. Every broker in the graph shares that signature and lands in the same ego-ERGM role, while community detection scatters them across whichever communities they happen to bridge. If your theory is about *kinds of position* (brokers, hubs, peripherals), roles are what you want. If it is about *groups*, communities are.

This is "structural equivalence" in the classic blockmodeling sense, approached through a generative model rather than through algebraic role-equivalence. It connects directly to the blockmodeling day (day 9, W2 Thu).
:::

# 2. The data

Lazega's law firm: 71 lawyers, with directed ties recording who works with whom, plus attributes like office, seniority, and practice area. It ships with `networkdata`.

```{r}
#| label: data
suppressMessages({
  library(ergm)
  library(network)
  library(networkdata)
})

data(lazega)
c(lawyers   = network.size(lazega),
  ties      = network.edgecount(lazega),
  directed  = network::is.directed(lazega))
network::list.vertex.attributes(lazega)
```

# 3. Fitting the ego-ERGM

We ask for **three roles** and describe each ego-network with three local terms: `edges` (density), `mutual` (reciprocity), and `triangle` (closure). `core_size = 1` means "ego plus its immediate neighbours"; `min_size = 5` drops any ego-network too small to fit an ERGM to.

```{r}
#| label: fit
#| output: false
role_fit <- fit_or_load("ego_lazega",
  ego_ergm(net            = lazega,
           form           = c("edges", "mutual", "triangle"),
           core_size      = 1,
           min_size       = 5,
           roles          = 3,
           directed       = TRUE,
           edge_covariates = FALSE,
           seed           = 6886,
           steps          = 50,
           tol            = 1e-6))
```

```{r}
#| label: fit-names
names(role_fit)
```

::: {.callout-tip collapse="true" title="Stuck? The answer key told me to load `sampson` from `sna` and it failed"}
The original answer script for this material began by loading a `sampson` dataset from the `sna` package to test the pipeline, and that line errors: `data set 'sampson' not found`. The reason is mundane: `sampson` lives in **`ergm`**, not `sna` (`data(sampson, package = "ergm")`). It is a good illustration of a general habit: when `data("something")` fails, the dataset almost always exists, just in a different package. `data(package = .packages(all.available = TRUE))` lists everything available, and searching that list for the name is usually the fastest way to find it.

We do not need `sampson` here: Lazega is the example, so this is only a note for anyone working from the old script.
:::

# 4. Reading the roles

The `group.theta` matrix has one row per role and one column per term. It is the generative recipe for each kind of local neighbourhood.

```{r}
#| label: roles
role_theta <- role_fit$group.theta
dimnames(role_theta) <- list(paste("role", seq_len(nrow(role_theta))),
                             c("edges", "mutual", "triangle"))
round(role_theta, 3)
```

```{r}
#| label: role-sizes
assignments <- role_fit$role_assignments[, 2]
table(role = assignments)
```

Read the roles off the coefficients, comparing the two substantive roles (the third is a singleton: see the caveat below). They trade off **reciprocity against closure**, which is exactly the kind of distinction ego-ERGMs are built to surface:

- The **larger role** (41 lawyers) has a clearly higher `mutual` coefficient but a *lower* `triangle` coefficient: local neighbourhoods where working relationships are reciprocated but do not close into tight triads. Think coordinating positions: you work back-and-forth with many people who are not themselves connected.
- The **smaller role** (28 lawyers) has lower reciprocity but relatively higher closure: local neighbourhoods that are more triangulated, more clique-like.

That is a statement about *kind of position*, and: the whole point: two lawyers can share a role while working in different offices on different practice areas. Note this is the opposite of a "some are central, some are peripheral" story: both roles are substantive structural types, not a core-periphery ranking.

::: {.callout-important title="A blunt caveat about this particular fit"}
Look at the role sizes: two substantive roles and a **third role with a single member**. That lone-member role is not a finding: it is the mixture model isolating one lawyer whose ego-network is an outlier and could not be forced into either main group.

This is characteristic of ego-ERGMs on smallish networks, and you should treat it as a warning about the method's stability rather than as a discovered "role." Three things follow:

- **Do not over-interpret a singleton or near-singleton role.** It is often an artefact.
- **The number of roles is a choice you impose**, not something the data hand you. Try `roles = 2` and `roles = 4` and see whether the substantive story is stable. If it is not, the roles are not robust and you should say so.
- **Compare the package's `EE.BIC` score**, where larger (less negative) is better. The function computes $2\ell - k\log N$, the negative of the conventional BIC orientation. Do not trust the score alone on a network this small. The same asymptotic cautions from the ERGM session apply here.

Honesty about this is more useful than a clean-looking three-role table would have been. On a larger network the roles are far more stable; on 71 nodes they are suggestive, not settled.
:::

```{r}
#| label: bic
round(role_fit$EE.BIC, 1)
checkpoint(roles = nrow(role_theta),
           largest_role = max(table(assignments)),
           BIC = round(role_fit$EE.BIC, 0))
```

# 5. Exercise

::: {.panel-tabset}

## Core

Refit with **two** roles instead of three and compare. Does the two-role solution give you two substantively interpretable groups, or does it also isolate an outlier?

```{r}
#| eval: false
role_fit2 <- ego_ergm(net = lazega, form = c("edges", "mutual", "triangle"),
                      core_size = 1, min_size = 5, roles = ______,
                      directed = TRUE, edge_covariates = FALSE,
                      seed = 6886, steps = 50, tol = 1e-6)
table(role_fit2$role_assignments[, 2])
```

Then ask which solution has the higher (less negative) `EE.BIC` score and which one you would actually report? Explain whether the numerical and substantive criteria agree.

## Solution

```{r}
#| label: ex-two
#| output: false
role_fit2 <- fit_or_load("ego_lazega_2",
  ego_ergm(net = lazega, form = c("edges", "mutual", "triangle"),
           core_size = 1, min_size = 5, roles = 2,
           directed = TRUE, edge_covariates = FALSE,
           seed = 6886, steps = 50, tol = 1e-6))
```

```{r}
#| label: ex-two-show
table(role = role_fit2$role_assignments[, 2])
c(BIC_2roles = round(role_fit2$EE.BIC, 0),
  BIC_3roles = round(role_fit$EE.BIC, 0))
```

**The result.** The two-role solution is worse than three, not better: it **collapses entirely**, putting all 70 fittable lawyers into a single role and leaving the other role empty. So neither low-dimensional solution here produces the balanced, interpretable partition you would hope for: three roles gave you two groups plus an outlier, two roles gives you no separation at all. That instability is itself the finding: **role structure in this 71-node firm is not cleanly separable, and the method is telling you so if you ask it more than once.**

**The score and what to report.** `EE.BIC` is easy to read backwards because the field name says "BIC," but the function computes $2\ell-k\log N$. Larger (less negative) values are preferred. The three-role score is therefore better than the two-role score, and the two-role solution is also substantively degenerate. Both criteria favor three roles over two. That still does not turn the singleton third role into a stable discovery. On a network this small, the score informs your judgment; it does not replace stability checks across seeds and role counts.

What you would actually report: that ego-ERGM role structure on this network is **unstable across the number of roles**, that the recurring feature is one or two outlier egos rather than a robust partition, and that a firmer conclusion needs either a larger network or a substantive prior on how many roles to expect. That honest non-result is worth more than a confident three-role table you cannot reproduce at `roles = 2`.
:::

# 6. Readings

- **Salter-Townshend & Murphy (2015)**, "Role analysis in networks using mixtures of exponential random graph models," *Journal of Computational and Graphical Statistics* 24(2):520–538. The method `ego_ergm()` implements.
- **Lazega (2001)**, *The Collegial Phenomenon: The Social Mechanisms of Cooperation Among Peers in a Corporate Law Partnership*. Oxford University Press. The data and their substantive context.
- Connects to the **blockmodeling** session (day 9, W2 Thu): Abbe (2018) on stochastic block models: and to Olga's week-1 community detection. Ego-ERGMs, blockmodels, and community detection are three answers to overlapping questions; knowing which one matches your theory is the useful skill.

```{r}
#| label: versions
#| cache: false
#| code-fold: true
#| code-summary: "Versions: expand if your numbers differ from mine"
pk <- c("ergm", "network", "networkdata")
data.frame(package = pk,
           version = sapply(pk, function(p) as.character(packageVersion(p))),
           row.names = NULL)
cat("R", as.character(getRversion()), "\n")
```
