Introduction
In the companion tutorial “Making Network Data”, we saw how to
identify network data bundled in packages, import it from external
files, and coerce it between the many classes used for network analysis
in R using the as_*() functions.
Collected data is rarely in exactly the shape needed for analysis, though. Perhaps the nodes need naming or anonymising, the ties are directed where they should not be, weights need thresholding, or a two-mode network needs projecting before a one-mode method can be applied. This tutorial is organised around such tasks: each section takes one part of a network — its nodes, its ties, its layers, modes, and waves — and shows how to build it up, change its properties, and understand the consequences, before a final section on narrowing a network down to what matters.
Along the way you will meet three recurring families of functions:
is_*()functions check whether a network has some property, returningTRUEorFALSEto_*()functions change that property, returning the modified network (in the same class it arrived in)add_*()/delete_*()and the{dplyr}-style verbs (mutate_*(),filter_*(),select_*(),rename_*(),join_*()) grow and prune networks’ nodes, ties, and attributes
Vocabulary:
{manynet} distinguishes reformatting — changing a
network’s type (direction, weights, signs, etc.) while keeping the same
number of nodes — from transforming — changing the network’s
order (its number of nodes), as projection does. Both are
done by to_*() functions, and each section below notes
which is happening, because it tells you whether your nodes will survive
the operation intact.
Aims
By the end of this tutorial, you should be able to:
Choose your own data: As in the “Making Network
Data” tutorial, the worked examples use small, clean networks so the
output is easy to read, but every “Your turn” and
“Free play” box invites you to bring your own. Remember
the three flavours as a rough difficulty ladder —
Classic (ison_*, small & tidy),
Fiction (fict_*, mid-sized & fun),
Real-world (irps_*, larger &
realistic) — and that every function here works on any of them. Each
“Your turn” box suggests datasets that have the right structure for the
task (e.g. a weighted network for
to_unweighted()), but you can always browse the full list
with table_data().
Operating on networks
Before changing anything, it helps to know how to look inside a
network object, and this same syntax turns out to offer a do-it-yourself
alternative to many of the named functions we meet later. Because the
data in the {manynet} package are either igraph objects at
their core, or stocnet data, we can query (and manipulate) them using
the same [, [[, and $ operators
used for matrices, lists, and data frames elsewhere in R. Run
the following code and inspect the two outputs.
ison_adolescents[1:3, 1:3]
ison_adolescents[[2]]
The first line returns the corner of the network’s adjacency matrix,
where a 1 indicates a tie between the row node and the
column node and a . indicates no tie (because this is a
sparse matrix representation; a ‘normal’ matrix representation would
represent absence of a tie as a 0). The second line returns the second
node’s
neighborhood : the set of nodes to which it is tied.
The $ operator extracts a named attribute, whether it
belongs to the nodes, the ties, or the network itself.
{manynet} is set to try and autocomplete the attribute name
for you, so you can type ison_adolescents$ and then hit the
Tab key to see the available attributes. 1
ison_adolescents$name
The same operators can also be used to assign new values.
Here the first line removes all of the first node’s ties, and the second
adds a new nodal attribute (the vector is 8 values long, matching the 8
nodes, which is how {manynet} knows it belongs to the
nodes):
test <- ison_adolescents
test[1, ] <- FALSE
test$smoker <- c(TRUE, FALSE, FALSE, TRUE, FALSE, TRUE, TRUE, FALSE)
test[1:3, 1:3]
test
Note that because this is an undirected network, removing the tie from node 1 to node 2 means there is also now no reciprocal tie from node 2 to node 1.
Throughout this tutorial, keep this assignment syntax in mind: many of the named functions we meet have an assignment equivalent, and we will point these out as we go. The named functions are usually clearer and safer (they check and update the network’s type for you), but the operators are handy for quick, surgical changes.
In brief: For
igraph/tidygraph objects, [ indexes a network like its
adjacency matrix, [[ returns a node’s neighbourhood, and
$ gets or sets a named node, tie, or network attribute. All
three can be used with <- to modify a network
directly.
Note though that for igraph/tidygraph objects, since the internal list elements are not named, the autocomplete necessarily braces the attribute names in backticks. This is not the case for stocnet objects, which are named lists.↩︎
Nodal properties
On this page: Adding & removing · Labels · Attributes
The previous page showed how to read a network with operators. Now we start changing it, one element at a time, beginning with the nodes: which nodes are present (adding and removing them), the names that identify them (labels), and the other variables they carry (attributes).
Adding & removing
Sometimes you need to change which nodes a network contains.
add_nodes() adds one or more (unconnected) nodes, while
delete_nodes() removes nodes by index or by name.
Run the code and check the node count in each
printout.
add_nodes(ison_adolescents, 1)
delete_nodes(ison_adolescents, "Sue")
A freshly added node has no ties yet — you would connect it with
add_ties(), which we meet in the next section — and
delete_nodes() also drops any ties attached to the nodes it
removes.
Labels
Whether a network is
labelled or not matters for how easily you can read
results, but also for research ethics: collected social network data
sometimes must be anonymised before it is shared or published.
is_labelled() checks whether a network is labelled, and
node_labels() retrieves the labels themselves.
ison_algebra records interactions among 16 anonymous
students in an algebra class. Since working with indices gets confusing
quickly, especially when discussing what we are seeing with others —
node 1 is not the same as node 2, and so on — we want to give the nodes
names.
The assignment equivalent sets the names directly:
net$label <- letters[1:8] relabels an 8-node network,
because a value with one entry per node is stored as a nodal attribute,
and label is the reserved attribute for labels.
we can use to_labelled() to assign (random) names to the
nodes. The function allows your chosen labels to be added, of course,
but if called without further arguments it assigns random (U.S.)
children’s names. Check whether ison_algebra is
labelled, and then label it randomly.
# First check with is_labelled(),
# then pipe the network to to_labelled() and print the result.
is_labelled(ison_algebra)
ison_algebra |> to_labelled()
To check your result: the printout should now say labelled,
and the nodes table should have gained a name column of
alphabetically ordered first names. Since these names are random, they
are useful for telling nodes apart, not for identifying real
people. Did you notice how the names are drawn in alphabetic
sequence?
The reverse operation, to_unlabelled(), strips all names
from a network. This is a one-step anonymiser. Anonymise
ison_adolescents, and then relabel it with letters using
$.
# Two separate steps:
# to_unlabelled(_____)
# Then assign to a copy and relabel:
# test <- ison_adolescents
# test$name <- _____
# (letters[1:8] gives the first eight lowercase letters)
to_unlabelled(ison_adolescents)
test <- ison_adolescents
test$name <- letters[1:8]
test
Note that to_unlabelled() removes the identifying labels
but leaves any other attributes in place — full anonymisation of your
data may require deleting or coarsening other attributes too, which
brings us to attributes just below.
Attributes
Adding nodal attributes to a given network is relatively
straightforward. An ‘attribute’ is just a variable attached to the nodes
(or ties) of a network, such as people’s age or the strength of their
friendships. {manynet} offers a more
{igraph}-like syntax,
e.g. add_node_attribute(), as well as a more
{dplyr}-like syntax, e.g. mutate_nodes(), for
those already familiar with these tools in R. Run the following
code and find the new columns in the printout.
ison_adolescents |>
mutate_nodes(colour = "red",
degree = 1:8)
One can also rename attributes with rename_nodes()
(which works like {dplyr}’s
rename(new = old)), and delete them in one of two
equivalent ways: the {dplyr} way, by assigning
NULL inside mutate_nodes(), or the
{igraph} way, with delete_node_attribute().
Compare the printouts before and after the changes
below.
ison_southern_women
ison_southern_women |>
delete_node_attribute("Surname") |>
rename_nodes(Honorific = Title)
Each of these _nodes verbs has a _ties
counterpart (mutate_ties(), rename_ties(),
delete_tie_attribute(), and so on) that does the same for
tie attributes; since a tie’s weight and sign are just special tie
attributes, we pick these up at the start of the next section on tie
properties.
In brief:
add_nodes()/delete_nodes() change which nodes
are present, to_labelled()/to_unlabelled()
name and anonymise them (check with is_labelled(), or set
names directly with $<-), and
mutate_nodes() adds or changes nodal attributes
(rename_nodes() renames them, and assigning
NULL deletes them).
Tie properties
On this page: Adding & removing · Attributes · Direction · Weights · Signs
Having dealt with the nodes, we turn to the ties — first which ties are present (adding and removing them), then the attributes they carry. Like nodes, ties can carry attributes of any kind; and three tie attributes are common and consequential enough to get their own reserved names and verbs — a tie’s direction , its weight , and its sign . These three are reformatting properties — changing them never changes the number of nodes — and for each we also show how to simplify it away, since many methods expect a plain, undirected, unweighted, unsigned network.
Adding & removing
add_ties() adds ties between named or indexed nodes, and
delete_ties() removes them — by index, or by naming the tie
with | between its two endpoints. Add a tie between
the first and third adolescents, then delete the tie between Carol and
Tina.
add_ties(ison_adolescents, list(1, 3))
delete_ties(ison_adolescents, "Carol|Tina")
With the ties in place, we can turn to what they carry.
Attributes
Just as mutate_nodes() attaches variables to nodes,
mutate_ties() attaches them to ties — a strength, a date, a
category, whatever your data records (add_tie_attribute()
is the more {igraph}-like alternative). Add a
weight to each of the adolescents’ 10
friendships.
ison_adolescents |> mutate_ties(weight = 1:10)
The _ties suffix carries across the
{dplyr}-style verbs (mutate_ties(),
rename_ties(), filter_ties(),
select_ties()), just as _nodes does for nodes,
and assigning NULL deletes a tie attribute. The weight we
just added is one of the special tie attributes that
{manynet} reserves a name and verbs for; the next three
subsections cover direction, weight, and sign in turn.
Direction
Ties either have a direction — who nominated whom, who exports to whom — or they do not. An undirected network treats every tie as mutual, while a directed network distinguishes each arc ’s sender and receiver. This matters practically because many measures and models are only defined for one or the other.
A common task is fixing direction after import. When an edgelist is
imported, {manynet} cannot always tell whether ties are
meant to be directed, and a heuristic used during the import may return
a directed network where an undirected one was
intended. Import the data/adols.csv file with
read_edgelist(), make it an igraph-class object, and then
make it undirected.
# Chain the three steps with pipes:
# read the edgelist, then coerce with as_igraph(),
# then reformat with to_undirected().
read_edgelist("data/adols.csv") |> as_igraph() |> to_undirected()
To check your result: the printout should describe an
undirected network with 8 nodes and 10 ties, matching the
original ison_adolescents.
{manynet} includes a full set of direction verbs:
to_undirected()merges arcs into undirected ties (a tie appears if an arc exists in either direction)to_directed()makes undirected ties directed (or, if already directed, does nothing)to_redirected()swaps the direction of every arc — useful when “who asks whom for advice” should become “who gives advice to whom”to_reciprocated()adds a reciprocal arc for every existing arc, so that all ties become mutual (see reciprocity )to_acyclic()removes just enough arcs to eliminate all cycles
Direction interacts with tie counts in ways worth seeing for
yourself. ison_networkers is a directed network of messages
among 32 researchers. Check how many (directed) ties it has, and
how many remain after to_undirected().
# net_ties() counts ties; run it on the original network
# and on its to_undirected() version.
net_ties(ison_networkers)
net_ties(to_undirected(ison_networkers))
Weights
In a
weighted network, ties carry a numeric value — a count of
messages, a volume of trade, a strength of friendship.
is_weighted() checks for weights, and
tie_weights() returns them as a vector, one value per
tie.
Many methods expect a binary network though, and even when weights
can be used, you may want to concentrate on the strongest ties.
to_unweighted() binarises (dichotomises) a network: by
default it keeps every tie with weight at least 1, but the
threshold argument lets you choose the cut-off.
Explore the weights of ison_networkers, then keep
only the pairs who exchanged at least 100 messages.
# Try summary(tie_weights(_____)) to see the distribution,
# then to_unweighted(_____, threshold = 100).
summary(tie_weights(ison_networkers))
to_unweighted(ison_networkers, threshold = 100)
To check your results: the weights range from 2 to 559 messages, and thresholding at 100 leaves a much sparser network of 32 ties — the backbone of heavy correspondents.
Going the other way, to_weighted() adds a weight
attribute, and weights can also be assigned directly:
mutate_ties(net, weight = ...) (the general tie-attribute
tool from just above) or the operator shorthand
net$weight <- ..., which works because a value with one
entry per tie is stored as a tie attribute.
Weights are often easier to read once they are put in proportion.
to_normalised() rescales each tie against the other ties of
the same node, so that a value says what share of a node’s ties
goes to that partner, rather than how many messages or dollars or
minutes it carries. The rule argument chooses what to
divide by — "sum" for a share, "max" for a
value read against the node’s strongest tie, "mean" for one
read against a tie of typical strength — and across chooses
whether to divide by the row, the column, or both. Rescale
ison_networkers so that each researcher’s ties show what
share of their messages went to each recipient.
# Try to_normalised(_____, rule = "sum", across = "rows"),
# then rowSums(as_matrix(_____)) to check.
to_normalised(ison_networkers, rule = "sum", across = "rows")
rowSums(as_matrix(to_normalised(ison_networkers, rule = "sum", across = "rows")))
To check your results: every row now sums to 1, so Lin Freeman’s 0.15 to Doug White says that about a seventh of Freeman’s messages went to White — a comparison that the raw counts, which range from 2 to 559, make hard.
Signs
In a
signed network, every tie is marked positive or negative.
irps_wwi is a classic example: the shifting alliances
(+) and enmities (-) among the six great
powers of Europe before the First World War. is_signed()
checks for signs, and tie_signs() retrieves them
(1 for positive, -1 for negative).
Print the network and retrieve its signs.
irps_wwi
tie_signs(irps_wwi)
Many measures are only defined for unsigned networks, and sometimes
the positive and negative ties are best analysed as separate networks —
friendship networks and conflict networks often obey quite different
logics. to_unsigned() extracts one or the other, via its
keep argument. Split the WWI network into its
alliance network and its conflict network, and compare their tie
counts.
# to_unsigned(_____, keep = "positive")
# to_unsigned(_____, keep = "negative")
# net_ties() counts each network's ties.
to_unsigned(irps_wwi, keep = "positive")
to_unsigned(irps_wwi, keep = "negative")
The reverse, to_signed(), adds signs to an unsigned
network, either from a logical mark vector or (without one)
at random.
Your turn: reformatting is easiest to see
on a network that already has the property you are changing. Here is one
network carrying weight or sign per flavour — check it with
is_weighted() or is_signed(), simplify it with
to_unweighted() or to_unsigned(), and confirm
the change with the same check:
| Classic | Fiction | Real-world |
|---|---|---|
ison_networkers (weighted) |
fict_marvel (signed) |
irps_wwi (signed) |
After to_unweighted(), is_weighted() should
flip from TRUE to FALSE; after
to_unsigned(), is_signed() should do the same;
and net_nodes() should never budge — reformatting never
changes the node count. (Signed networks are relatively rare;
fict_marvel pairs its signs with other complications, so
irps_wwi is the cleanest one to experiment on.)
In brief:
add_ties()/delete_ties() change which ties are
present, mutate_ties() attaches attributes to them,
to_undirected()/to_directed()/to_redirected()/to_reciprocated()
reformat tie direction, to_unweighted() binarises weighted
ties around a chosen threshold (and
to_weighted() adds weights), and
to_unsigned(keep = ) splits a signed network into its
positive or negative ties (and to_signed() adds signs).
Check each property with
is_directed()/is_weighted()/is_signed(),
and inspect values with
tie_weights()/tie_signs().
Multiplex networks
On this page: Joining networks · Layers
So far each network has had a single kind of tie. A multiplex network instead carries several types of tie among the same nodes — friendship and advice, alliance and trade. This page shows one way such networks arise — by joining two networks together — and then how to inspect their layers and pull a single one back out.
Joining networks
What if the extra ties you want to add are already collected as a
second network among the same nodes? join_ties() merges
another network’s ties into the current one as an additional
type of tie. Join a ring network’s ties to the
adolescents’ friendships and inspect the resulting tie
table.
ison_adolescents |> join_ties(create_ring(8), attr_name = "ring")
The printout now reports a multiplex network: the
type column distinguishes the original ties
(orig) from the ring ties we joined. We look at how to
inspect and extract these types of tie in the next subsection, Layers.
There are also bind_* equivalents
(e.g. bind_ties()) that stack extra rows onto the existing
nodes or ties without creating a new type, and join_nodes()
for merging in a second network’s nodes.
Layers
A
multiplex network contains several types of tie
among the same nodes — remember the multiplex network we just built with
join_ties() above. is_multiplex() checks for
multiple tie types, and layer_names() lists them.
ison_lawfirm records three kinds of relationship among the
partners and associates of a New England law firm. Find out what
they are.
# is_multiplex(_____) checks, layer_names(_____) lists.
is_multiplex(ison_lawfirm)
layer_names(ison_lawfirm)
Most analyses of multiplex networks proceed one layer at a time, and
to_uniplex() extracts a single layer by name.
Extract the friendship layer from
ison_lawfirm.
# to_uniplex(_____, layer = "friends")
to_uniplex(ison_lawfirm, "friends")
To check your result: the friendship layer has 575 of the original
network’s 2571 ties, and the printout no longer describes the network as
multiplex. Note that the corresponding do-it-yourself route is
filter_ties(ison_lawfirm, type == "friends") —
to_uniplex() does this and additionally tidies up the
now-redundant type information.
to_uniplex() works one layer at a time.
to_layers() splits a multiplex network into all of them at
once, returning a named list with one network per layer, and
from_layers() puts such a list back together again.
Split ison_lawfirm into its layers.
to_layers(ison_lawfirm)
Sometimes you want a single network that keeps what every
layer records, rather than one layer or a list of them.
to_flat() combines the layers dyad by dyad, according to a
rule: “max” counts a tie in any layer as a tie, “min” keeps
only the ties present in every layer, and “sum” adds the layers up.
Flatten ison_lawfirm so that each tie records how
many of the three relationships that pair holds.
# to_flat(_____, rule = "sum")
to_flat(ison_lawfirm, rule = "sum")
To check your result: the flattened network is weighted, and 824 pairs share one relationship, 503 share two, and 247 share all three.
from_layers() does the same for networks you collected
separately. Give it two or more networks over the same nodes and it
makes each one a layer, which is the other way to build a multiplex
network: join_ties() marks where each tie came from, and
from_layers() names layers. Either way,
to_flat() then combines them. Keep only the pairs
who are both friends and advisors.
friends <- to_uniplex(ison_lawfirm, "friends")
advice <- to_uniplex(ison_lawfirm, "advice")
from_layers(friends = friends, advice = advice)
from_layers(friends = friends, advice = advice) |> to_flat(rule = "min")
The last line keeps the 358 pairs tied in both layers. Nodes are matched by name where the networks are labelled, and by position where they are not and are the same size.
Your turn: try the other layers of
ison_lawfirm, or explore another multiplex network:
| Classic | Fiction | Real-world |
|---|---|---|
ison_algebra (3 layers) |
fict_thrones (marriage/other) |
irps_911 (association/trust) |
In brief:
join_ties() merges a second network’s ties in as a new
type, producing a multiplex network.
is_multiplex() checks for multiple tie types and
layer_names() lists them, while
to_uniplex(layer = ) extracts a single layer by name (the
do-it-yourself equivalent of filter_ties(type == ...)).
to_layers() splits a network into all of its layers and
from_layers() reassembles them, or joins networks collected
separately as layers, while to_flat() combines the layers
of any multiplex network into one relation by a rule.
Multimodal networks
Where the previous page bundled several kinds of tie among one set of nodes, a multimodal network has more than one kind of node. The commonest case is a two-mode (or bipartite) network, where ties connect two distinct node sets — such as people and the events they attend. Since most network methods assume a single set of nodes, the key task is moving from two modes to one, usually by projection .
Modes
is_twomode() checks whether a network has two modes, and
projection transforms it into ties among just one of them:
to_mode1() projects onto shared ties among the first node
set, and to_mode2() onto the second. For more information
on projection, see for example Knoke et al. (2021). Note that these
are transforming functions: projection changes the number of
nodes.
Let’s try this out on a classic two-mode network,
ison_southern_women. Assign and name the
transformed networks something sensible using e.g.
women <- to_mode... so that we can continue working with
this data afterwards. To assign and immediately print the result, wrap
the line in parentheses.
# Print the original first, then project it both ways:
# ison_southern_women
# (s_women <- to_mode1(_____))
# (s_events <- to_mode2(_____))
ison_southern_women
(s_women <- to_mode1(ison_southern_women))
(s_events <- to_mode2(ison_southern_women))
Compare the three printouts: the original two-mode network of women attending events has become (1) a one-mode network among the women, where a tie means two women attended one or more of the same events, and (2) a one-mode network among the events, where a tie means two events shared one or more attendees.
Now use describing functions from the “Making Network Data” tutorial on the two projections you have created to find out:
- how many nodes there are in each of these networks,
- what the names of the nodes are, and
- what tie attributes there are in the networks.
# Recall the naming convention:
# net_nodes() counts nodes, node_labels() lists names,
# and tie attributes are listed with net_tie_attributes().
net_nodes(s_women)
net_nodes(s_events)
node_labels(s_women)
node_labels(s_events)
net_tie_attributes(s_women)
net_tie_attributes(s_events)
To check your results: the women projection has 18 nodes and the events projection 14, together the 32 nodes of the original network.
So we can see that the to_mode*() functions have created
a network of only one of the modes in the network. The ties in these
projected networks, representing shared connections to nodes of the
other mode, are
weighted — which connects back to the Weights section:
everything you learned there (tie_weights(),
to_unweighted(), thresholds) applies to projections
too.
Retrieve the tie weights from your women projection. Find the average (mean) of this vector of tie weights, and the average (mean) tie weight overall.
# Fill in the blank (this is a template, not runnable code):
# tie_weights(_____)
# Fill in the blank (this is a template, not runnable code):
# mean(tie_weights(_____))
# For the overall average, include also the pairs with no tie,
# by taking the mean over the whole (weighted) adjacency matrix:
# mean(as_matrix(_____))
tie_weights(s_women)
mean(tie_weights(s_women))
mean(as_matrix(s_women))
Ok, so now we know that projection transforms an (unweighted)
two-mode network into a weighted one-mode network and what these weights
represent. Note though that counting the frequency of shared ties to
nodes in the other mode is just one (albeit the default) option for how
ties in the projection are weighted. The similarity
argument to to_mode1()/to_mode2() offers 24
measures in all, which ?to_mode1 groups by what they are
sensitive to. Among them are the Jaccard index, which weights overlap by
participation; the Rand simple matching coefficient, which counts joint
absences as evidence too; Yule’s Q, which reads the association
between two nodes as an odds ratio; and the Pearson coefficient, which
asks whether two nodes depart from the average level of involvement in
the same direction. Measures within a group rank pairs identically and
differ only in their scale, so choosing between the groups matters
rather more than choosing within one.
The same measures compare the nodes of a network that is already
one-mode. to_proximity() asks how alike two nodes’
ties are, rather than how alike their affiliations are, and
returns a square node-by-node matrix — the structural equivalence of the
nodes. Because a node’s tie to the node it is being compared with cannot
be compared where it lies, the dyad argument states what to
do with those cells, and across chooses whether nodes are
compared on the ties they send, those they receive, or both.
Projection is not the only way between modes:
to_onemode()simply forgets the mode distinction, returning all 32 nodes in a single set — useful for methods that need a one-mode object but should keep both node setsto_multilevel()similarly retains all nodes but keeps the mode information, treating the network as multilevel so that within-mode ties may be addedto_twomode()goes the other way, splitting a one-mode network into two modes according to a logicalmarkvector
Your turn: project a different two-mode network and
inspect its modes. irps_revere is a famous historical
example (Paul Revere’s ties to Boston organisations on the eve of the
American Revolution):
| Classic | Real-world |
|---|---|
ison_southern_women (women × events) |
irps_revere (people × organisations) |
Whatever you pick, is_twomode() should return
TRUE before projection and FALSE after, and
is_weighted() should return TRUE on the
projection. (The fiction two-mode networks such as
fict_actually are multiplex, so projecting them
first needs a single tie type extracted with to_uniplex() —
you now know how!)
In brief:
is_twomode() checks for two modes, and
to_mode1()/to_mode2() project a two-mode
network into a weighted one-mode network of shared ties.
to_proximity() applies the same similarity
measures to a one-mode network, comparing nodes on their ties rather
than their affiliations. to_onemode(),
to_twomode(), and to_multilevel() move between
modes without projecting.
Dynamic networks
On this page: Waves · Changes
Networks are rarely static: friendships form and fade, democracy
diffuses, characters die. {manynet} distinguishes three
ways time can enter a network:
- a
longitudinal network is a panel, observed in a few
discrete waves, each of which re-states the ties. The moment each tie
was observed at is recorded in a
timetie attribute (wavein the older classes) – check withis_longitudinal() - a
dynamic network records a stream of events rather than a
set of observations. Either each row increments a tie’s value, in an
incrementattribute, or each tie states the interval it lasts over, inbegin/endattributes – check withis_dynamic() - a changing network is where one or more nodal attributes are changing over time
A network is longitudinal or dynamic, never both. Which one it is
decides what “the network at a moment” means, and so what
to_time() returns: the ties observed at that wave, the ties
accumulated up to that event, or the ties active over that interval.
net_times() counts the moments a network records, however
it records them, and to_times() returns the network at each
of them.
Waves
fict_potter records support ties among Harry Potter
characters across all the books in the series. Print it, check
that it is longitudinal, and count its waves with
net_waves().
# fict_potter
# is_longitudinal(_____)
# net_waves(_____)
fict_potter
is_longitudinal(fict_potter)
net_waves(fict_potter)
Note the wave column in the ties table and the
active column in the nodes table — the two reserved
attributes doing the longitudinal work here — as well as the changes
table between them (more on that below).
Two functions extract time-specific data from a longitudinal network:
to_time()returns the network as it stood at one wave — a single, ordinary network you can analyse with everything above (to_wave()is an alias, if you prefer the wave-based wording)to_waves()splits the network into a list of networks, one per wave, handy for comparing or looping over periods
Extract the network as it stood in the third book, and then split the whole series into waves.
# to_time(fict_potter, 3)
# pott_waves <- to_waves(fict_potter)
# length(pott_waves)
to_time(fict_potter, 3)
pott_waves <- to_waves(fict_potter)
length(pott_waves)
To check your results: the wave-3 network has 48 nodes — fewer than
the full cast of 64, because only characters active in that
book are retained — and to_waves() returns a list of 6
networks.
Changes
Where do those ‘active’ switches come from? Longitudinal
{manynet} networks can carry a changelog: a table
of node changes with columns time, node,
var, and value, each row recording that a
node’s attribute takes a new value from some time on.
fict_starwars records who converses with whom in the Star
Wars films, with changes tracking characters’ comings, goings, and even
Anakin’s changing allegiances. The changelog prints between the node and
tie tables, and can be manipulated with the *_changes()
family of verbs (filter_changes(),
mutate_changes(), select_changes(), etc.).
Print the network, then filter its changes to see Anakin’s
trajectory.
# fict_starwars
# filter_changes(fict_starwars, node == "Anakin")
fict_starwars
filter_changes(fict_starwars, node == "Anakin")
To make the changes take effect, to_time() returns the
network as it stood at a given time, with all the changes up to then
applied to the node attributes (as_changelist() takes the
same time and shows those changes as a table, without
applying them). Scope the network to episode 3 and check what
Anakin has become.
# eps3 <- to_time(fict_starwars, time = 3)
# then inspect e.g. the faction attribute:
# eps3 |> filter_nodes(label == "Anakin")
eps3 <- to_time(fict_starwars, time = 3)
eps3 |> filter_nodes(label == "Anakin")
Changes can also be added: add_changes() and
bind_changes() record new node changes on a network you are
constructing yourself, using the same
time/node/var/value
structure. Together with to_waves() this is the foundation
for the dynamic and diffusion analyses covered in later tutorials (in
the {netrics} and {migraph} packages).
Going further: In stocnet objects, there is also a ‘globals’ section that carries globally changing variables e.g. a variable that is constant across all nodes/ties in the network, but that changes over time.
In brief:
Longitudinal networks store panel observations in a wave
tie attribute and node changes in a changelog. net_waves()
counts the waves and net_times() the moments of a network
of any kind, to_time() extracts the network as it stood at
one of them, to_times() returns the network at each of
them, to_waves() is the panel-specific spelling of the
same, and filter_changes()/to_time() inspect
and apply the recorded node changes.
Missing data
On this page: Four states · Finding it · Imputing it
Somebody skips the questionnaire. Somebody joins the class in March. Both leave holes in a network, and they are not the same hole. Treating either as “no tie” invents data, and most network measures have no way of telling you that you did.
Four states
A tie in {manynet} can be in one of four states, and
they are kept apart:
- Not observed. The tie could have been reported and was not.
- Not possible. One of the nodes was not in the network at that moment, so there was nothing there to miss.
- Observed. A tie, or the absence of one.
- Observed but unvalued. The tie is there and its strength is unknown.
ison_classmates holds the first three. It records four
waves of friendship among 26 Dutch pupils, and its source distinguishes
a nomination that is missing from one that is structurally missing.
Print the network’s changes and see how each is
recorded.
# The changelog is the changes component:
# ison_classmates$changes
# Try filtering it to the 'na' and 'active' variables.
ison_classmates$changes |>
dplyr::filter(var %in% c("na", "active"))
Three pupils did not answer at a wave, logged as a change of their
na status, and one pupil left the class, logged as a change
of their active status. Note that each na
change is followed by a change back: a change says what a variable
becomes from that moment on, so a pupil who answers again the
following wave needs to say so.
Finding it
Nothing above lists the missing ties themselves, because a network
holds who did not report rather than one record per tie.
as_missinglist() derives them. List the missing
ties, and count how many fall at each wave.
# as_missinglist(ison_classmates) returns a tibble.
# table() on its 'time' column counts them by wave.
miss <- as_missinglist(ison_classmates)
nrow(miss)
table(miss$time)
73 nominations were never given: 25 at wave 2 and 48 at wave 3. Six records stand in for all of them. Two things are worth noticing. Only the nominations those pupils would have given are missing, since the others still nominated them. And the two pupils who did not answer at wave 3 miss 24 nominations each rather than 25, because the pupil who left the class is not there to nominate.
None of this makes them ties. Check that
net_ties() does not count them, and see what proportion
net_tie_missing() reports.
# net_ties(ison_classmates) counts the ties,
# nrow(ison_classmates$ties) counts the rows they are held in,
# and net_tie_missing(ison_classmates) reports the proportion missing.
net_ties(ison_classmates)
net_tie_missing(ison_classmates)
Each class carries this information in whatever way it can. An
igraph object cannot mark an edge as missing, so the list
travels beside the edges; a network object marks them among
its edges in its own na attribute, which is what
{ergm} expects; and a matrix holds each as a missing cell.
as_missinglist() reads all of them.
Imputing it
Once you know what is missing, you can decide what to do about it.
impute_ties() takes a rule saying how it
should decide, and a which saying which of the states to
treat.
Some rules answer whether a tie exists:
"zero"treats every missing tie as absent, which is the modal state in a sparse network"density"draws each missing tie at the density observed over the layer and the moment it was missing from"reciprocity"reconstructs a missing tie from what the other pupil reported, drawing it at the proportion of observed friendships that run both ways"indegree"draws a missing tie at the proportion of the pupils who did answer that named that classmate
Others answer what a tie’s value is, for a tie that is there but
whose strength is not known: "mean", "median",
and "modal".
The first four draw at random, so set a seed if you need them reproducible.
Impute the missing nominations three ways and compare how many ties each leaves.
# set.seed(1) first, since all but "zero" draw at random.
# net_ties() on each result shows what each imputation assumed.
set.seed(1)
net_ties(impute_ties(ison_classmates, "zero"))
net_ties(impute_ties(ison_classmates, "density"))
net_ties(impute_ties(ison_classmates, "reciprocity"))
Neither is a neutral choice, which is the point of keeping the
record. "zero" assumes every pupil who did not answer had
no friends, which is why it leaves the tie count untouched.
"reciprocity" assumes instead that a pupil who was named by
a classmate was likely to have named them back, at the rate the
answering pupils did.
Whichever you choose, the network remembers.
impute_ties() and impute_nodes() record what
they imputed, how much of it, and by which rule, which they record under
the name “imputation”:
describe_transformations(to_imputed(ison_classmates), details = TRUE)
This is item 4.6 of the GRAND reporting guidelines, which name six ways raw data is turned into analytic data. Each has a name of its own, so a later reader can see how much of the network is manufactured rather than observed, without reading past everything else that was done to it.
Nodes can be incomplete too, where a node is there but an attribute
of it is not known. net_node_incomplete() reports how much
of the nodal data is unknown, and impute_nodes() fills it.
to_imputed() runs both in a single call.
In brief: A tie
may be unavailable, unreported, unrecorded, or incomplete, and
{manynet} keeps the four apart. A network is
missing a tie where the tie itself was not observed; a tie or a
node is incomplete where it is there but an attribute of it is
not known. Missing ties are recorded as the nodes that did not report,
so they cost almost nothing to store, and as_missinglist()
derives them where you need them. They are not counted as ties by
net_ties(); net_tie_missing() reports how many
there are, and impute_ties() imputes them.
Modifying networks
On this page: Subgraphs · Isolates · Backbones
By now you can build up a network with all of its properties — labels and attributes, direction, weights and signs, layers, modes, and dynamics. Analysis often needs the opposite move: taking that comprehensive object and narrowing it down to just the part that matters. The verbs on this page all take a whole network and hand back a smaller or cleaner one.
Subgraphs
Earlier, delete_nodes() and delete_ties()
removed elements you named. More often you want to keep
elements that satisfy a condition, which is what the
filter_*() verbs and to_subgraph() do —
usually the more useful direction for analysis. Keep only the
Gryffindors from fict_potter, a network of support among
Harry Potter characters.
# The nodes have a "house" attribute:
# filter_nodes(fict_potter, house == "Gryffindor")
# to_subgraph(fict_potter, house == "Gryffindor") is equivalent.
fict_potter |> filter_nodes(house == "Gryffindor")
To check your result: 25 of the 64 characters remain, along with only
the ties among them (this is called an
induced
subgraph ). Filtering on tie attributes instead uses
filter_ties().
Isolates
Deleting or filtering ties can leave nodes stranded without any ties. Depending on your question, such isolates may be important actors (who is not integrated?) or clutter. Two transforming functions prune them:
delete_isolates()drops all nodes without tiesto_giant()keeps only the largest component , dropping smaller disconnected fragments as well as isolates
Betty’s only friendship is with Sue. Delete that tie and
watch the node count before and after
delete_isolates().
# stranded <- delete_ties(ison_adolescents, "Betty|Sue")
# then compare net_nodes(stranded) with
# net_nodes(delete_isolates(stranded))
stranded <- delete_ties(ison_adolescents, "Betty|Sue")
net_nodes(stranded)
net_nodes(delete_isolates(stranded))
A related cleaning task concerns
loops (self-ties): to_simplex() removes them,
turning a
complex network into a
simplex one.
In brief:
filter_nodes()/filter_ties() and
to_subgraph() keep only the elements satisfying a
condition, while delete_isolates(),
to_giant(), and to_simplex() prune stranded
nodes, minor components, and self-ties — all taking a whole network and
returning a narrower or cleaner one.
Backbones
Back on the weights page, we
thresholded ison_networkers at 100 messages. That is a
global cut-off: every remaining tie met the same number. The
trouble is that a global threshold reads volume rather than importance,
which will keep the busiest correspondents and drop the rest.
The
backbone of a network identifies and retains the ties that
are important locally, giving a cleaner network that reveals more
structure than activity. Each tie is compared with a null model built
from its own two endpoints, so a tie that is small overall but large for
the people it joins is nonetheless kept. tie_is_backbone()
marks ties that comprise the ‘backbone’ of the network, and
to_backbone() returns the network reduced to those
ties.
The filter= argument chooses which null model to use.
For a weighted network it defaults to "lans", which
compares a tie with the other weights of its own two nodes, and so
assumes nothing about how those weights are distributed. For an
unweighted network it defaults to "simmelian", which reads
shared neighbours rather than weights. The threshold
argument sets how demanding the test is, just as it does for
to_unweighted().
Extract the backbone of ison_networkers, and
compare it with the global threshold of 100 you used
earlier.
# to_backbone(ison_networkers) uses the LANS filter by default.
# Count the nodes that still have ties in each with
# net_nodes(delete_isolates(_____)).
net_ties(to_backbone(ison_networkers))
net_nodes(delete_isolates(to_backbone(ison_networkers)))
net_nodes(delete_isolates(to_unweighted(ison_networkers, threshold = 100)))
To check your results: the backbone holds 71 of the 440 ties, against the 32 the global threshold left. More telling is who is left: those 71 ties reach all 32 researchers, where the global threshold reached only 12. Both reductions are severe, yet only one of them keeps the shape of the whole network.
Other filters are available through filter, including
"disparity", "noise", and "mlf",
and to_backbone() documents what each one asks of a tie.
"disparity" is the best known of them, but be careful with
it: its null model expects a few very heavy ties among many light ones,
and where weights are more even it retains nothing at all. For backbone
models that resample a null distribution instead, such as those for
twomode projections, see the {backbone}
package.
In brief:
to_unweighted(threshold = ) cuts every tie against the same
number, while to_backbone() cuts each tie against a null
model local to its own endpoints, which keeps the shape of the network
rather than just its heaviest part.
Summary
Putting it together
Real-world data often needs several of this tutorial’s verbs in
sequence. irps_blogs is a large directed network of 1490
political blogs. Suppose we only want the well-connected core, as a
simple undirected network. We can chain reformatting and transforming
verbs to get there. Run this and watch the tie and node counts
fall as the network is tidied.
irps_blogs
irps_blogs |> to_undirected() |> to_giant()
Notice how to_undirected() keeps all 1490 nodes but
merges reciprocal ties, while to_giant() then drops every
node outside the main component. This kind of short pipeline — coerce,
reformat, transform — is the everyday work of getting real network data
into shape.
One last free play: build your own cleaning
pipeline. Pick any dataset from table_data() and chain at
least three verbs from different sections of this tutorial — for example
extract a layer, undirect it, threshold its weights, and drop the
isolates — checking with is_*(), net_nodes(),
and net_ties() at each step that it did what you
expected.
Function overview
Well done — you have completed the tutorial on manipulating network data! Along the way, you have learned to use these functions:
| Function | What it does |
|---|---|
[, [[, $ |
examine and assign into a network directly |
add_nodes(), add_ties(),
delete_nodes(), delete_ties() |
add/remove nodes and ties |
filter_nodes(), filter_ties(),
to_subgraph() |
keep elements satisfying a condition |
delete_isolates(),
to_giant()/to_component(),
to_simplex() |
prune isolates, minor components, self-ties |
to_labelled(), to_unlabelled() |
name or anonymise nodes |
mutate_nodes()/mutate_ties(),
rename_nodes()/rename_ties(),
delete_node_attribute()/delete_tie_attribute() |
add, change, rename, or delete attributes |
to_undirected(), to_directed(),
to_redirected(), to_reciprocated(),
to_acyclic() |
reformat tie direction |
to_unweighted(), to_weighted() |
binarise (around a threshold) or weight ties |
to_unsigned(keep = ), to_signed(),
tie_signs() |
split or create signed networks |
join_ties(), bind_ties(),
join_nodes() |
merge another network’s ties or nodes in |
to_uniplex(layer = ), layer_names() |
extract a layer from a multiplex network |
to_layers(), from_layers(),
to_flat(rule = ) |
split a network into its layers, join networks as layers, or combine those layers into one relation |
to_mode1(), to_mode2(),
to_onemode(), to_twomode(),
to_multilevel() |
move between modes |
to_proximity(similarity = ),
to_correlation(), to_cosine() |
measure how alike a one-mode network’s nodes are |
net_times(), to_time(),
to_times(), from_times() |
count, extract, and rejoin the moments of a network of any kind |
net_waves(), to_wave(),
to_waves(), to_slices() |
the same, in the wave and slice vocabularies |
filter_changes(), add_changes(),
as_changelist() |
inspect and record node changes over time |
is_labelled(), is_directed(),
is_weighted(), is_signed(),
is_multiplex(), is_twomode(),
is_longitudinal(), … |
check any of these properties |
From here, you can continue with the tutorials on visualising
networks (in the {autograph} package) or on measuring and
analysing them (in the {netrics} package).
Glossary
Here are some of the terms that we have covered in this tutorial:
- Arc
- An ordered pair of nodes indicating a directed tie or edge from a tail to a head.
- Backbone
- The backbone of a network comprises the ties that carry more weight, or hold more structure, than a null model local to their endpoints expects.
- Changing
- A changing network is one where nodal attributes, including whether the nodes are present or not, can change.
- 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.
- Directed
- A directed network is a network where the ties have a direction, from a sender to a receiver.
- Dynamic
- A dynamic network is one where ties appear or disappear at recorded points in continuous time.
- Edgelist
- An edgelist is a table listing the ties in a network, with the sending node in the first column and the receiving node in the second, and any tie attributes in further columns.
- Induced
- An induced subgraph comprises all ties in a subset of the nodes in a network.
- Isolate
- An isolate is a node with degree equal to zero.
- Label
- A labelled network includes unique labels for each node (or ties) in the network.
- Longitudinal
- A longitudinal network is one observed in two or more discrete waves or panels over time.
- Loop
- A loop is a self-tie with a single node as both endpoints, forming a cycle of length 1.
- Multilevel
- A network of more than one set of nodes that includes ties both between and within the different node sets.
- Multimodal
- A network that includes more than one set of nodes.
- Multiplex
- A network that includes multiple types of tie.
- Neighborhood
- The neighborhood of a node is the set of other nodes to which that node is connected.
- Network
- A network comprises one or more sets of nodes, one or more sets of ties among them, and potentially some node, tie, or network-level attributes.
- Node
- A node or vertex is an entity or actor within a network.
- Nodelist
- A nodelist is a table listing the nodes in a network, with their names in the first column and any nodal attributes in further columns.
- Order
- The order of a network is the number of its nodes.
- Projection
- A projection reduces a two-mode network to a one-mode network of shared ties to the other mode among one set of its nodes.
- Reciprocity
- A measure of how often nodes in a directed network are mutually linked.
- Signed
- A signed network is one where ties are marked as positive or negative, such as friendship and enmity or alliance and conflict.
- Simplex
- A simplex network is one without loops or multiple adjacencies.
- Subgraph
- A subgraph comprises a subset of the nodes and ties in a network.
- Tie
- A tie, edge, or link is a connection or relationship between two nodes.
- Twomode
- A two-mode (or bipartite) network is a network with two different sets of nodes, where ties connect only nodes from different sets, such as people and the events they attend.
- Undirected
- An undirected or line network is one in which tie direction is undefined.
- Weighted
- A weighted network is where the ties have been assigned weights.