This is the second part about my project that deals with the Twitter network of members of the Bundestag. After getting the necessary data, which was explained in part 1, we will now focus on creating a network graph with links between the representatives’ Twitter accounts for exploratory network analysis.
Note that again I will not reproduce full code examples but rather focus on some excerpts. If you want to have a look at the full code, please refer to the GitHub repository for this project, especially friends_network.R
. Also, this post only represents a starting point for exploratory network analysis and suggests some packages and techniques for that purpose. It is not an in-depth article about network analysis.
Preparation
We collected two datasets in part I: First, a dataset that lists all deputies with their Twitter account names (also known as Twitter handles) and some additional information, e.g. their party or their electoral district. This data was collected from Abgeordnetenwatch.de. I will call this dataset dep_twitter
. Second, for each deputy Twitter account we have collected data on their “friends” using the Twitter API. Remember that “friends” is the Twitter terminology for the list of users that someone follows (i.e. the users that appear in the “following” list of a certain Twitter account). I will call this dataset friends
. Let’s have a look at a sample from both datasets first:
A random sample from dep_twitter
:
twitter_name personal.first_name personal.last_name party
johannesvogel Johannes Vogel FDP
baerbelbas Bärbel Bas SPD
schickgerhard Gerhard Schick DIE GRÜNEN
gruenebeate Beate Müller-Gemmeke DIE GRÜNEN
houbenreinhard Reinhard Arnold Houben FDP
A sample from friends
, where user
refers to a deputy Twitter handle from dep_twitter
, screen_name
is a friend’s Twitter handle and name
a friend’s name as stated on Twitter.
user screen_name name
baerbelbas OlafLies Minister Olaf Lies
baerbelbas COntraPipeline COntra-Pipeline
baerbelbas Earthsmell Arturo de la Vega
johannesvogel Peter_Schaar Peter Schaar
baerbelbas BILD_Ruhrgebiet BILD Ruhrgebiet
johannesvogel sophiespelsberg Sophie Spelsberg
schickgerhard ulle_schauws Ulle Schauws
johannesvogel alias_ccm Christa C. Müller
johannesvogel marcusmeurer95 Marcus Meurer
johannesvogel andreaslandwehr Andreas Landwehr
There are much more variables in both datasets, but for our purpose the selected columns are just fine. We’re interested only in the links between deputy accounts on Twitter, this means we can omit all observations in friends
, where screen_name
doesn’t refer to a deputy Twitter handle. Let’s do this now:
library(dplyr)
# a few NAs for "screen_name"; remove those observations
friends <- filter(friends, !is.na(screen_name))
dep_accounts <- unique(friends$user) # Twitter handles of deputies
# only retain "friends" that are deputies
dep_friends <- filter(friends, screen_name %in% dep_accounts)
The dataset dep_friends
now only contains the connections between deputies on Twitter. Connections to Twitter accounts that are not accounts of deputy colleagues, which might also be interesting for further analysis, are removed. This reduces the dataset from ~340,000 observations to ~8,500 observations.
Friends / followers share between parties
At first, I want to focus on aggregate data at party level: In a set of Twitter accounts associated with party A, how many of those follow an account from party B?
The first step to answer this question is to create a dataset that doesn’t only include which deputy follows which colleague on Twitter (dep_friends
already contains this information), but also their respective party affiliation. For this, we can make two joins between dep_friends
and dep_accounts_parties
, the latter simply mapping deputy Twitter handles to their party:
# deputy Twitter handles and their party
dep_accounts_parties <- select(dep_twitter, twitter_name, party)
# make two joins to create a data frame with edges defined by
# "from_account", "from_party" and "to_account", "to_party"
edges_parties <- select(dep_friends,
from_account = user, to_account = screen_name) %>%
left_join(dep_accounts_parties,
by = c('from_account' = 'twitter_name')) %>%
rename(from_party = party) %>%
left_join(dep_accounts_parties,
by = c('to_account' = 'twitter_name')) %>%
rename(to_party = party)
The new dataset edges_parties
now contains the connections between deputies. These connections are also called the edges of a graph. They are specified in the columns from_account
and to_account
, plus the respective party affiliations as seen here in this random sample:
from_account to_account from_party to_party
christianduerr bstrasser FDP FDP
jenskoeppen kaiwegner CDU CDU
berlinliebich c_bernstiel DIE LINKE CDU
stefangelbhaar julia_verlinden DIE GRÜNEN DIE GRÜNEN
gydej nicolabeerfdp FDP FDP
We can now count the connections at party level using group_by()
and count()
:
# count how often each "from_party" -> "to_party" edge occurs
counts_p2p <- group_by(edges_parties, from_party, to_party) %>%
count() %>% ungroup()
head(counts_p2p, 10)
## from_party to_party n
## AfD AfD 180
## AfD CDU 42
## AfD CSU 2
## AfD DIE GRÜNEN 25
## AfD DIE LINKE 27
## AfD FDP 51
## AfD SPD 50
## CDU AfD 5
## CDU CDU 621
## CDU CSU 130
Of course, the size of the factions in the Bundestag differ and the number of Twitter users per faction do too. Hence absolute numbers are not very useful and we will add a column prop
with the respective proportions:
# count the absolute number of edges per "from_party"
# this is required to calculate the proportions
counts_party_edges <- group_by(counts_p2p, from_party) %>%
summarise(n_edges = sum(n))
# add a column "prop" for the "from_party" -> "to_party" edges proportions
counts_p2p <- left_join(counts_p2p, counts_party_edges,
by = 'from_party') %>%
mutate(prop = n/n_edges) %>% select(-n_edges)
head(counts_p2p, 3)
## from_party to_party n prop
## AfD AfD 180 0.47745358
## AfD CDU 42 0.11140584
## AfD CSU 2 0.00530504
This data can be visualized as a heatmap with ggplot2 and geom_raster()
. This displays the proportions of friends / followers between parties as a matrix, where the intensity of the color in the cells depends on the value in the cell. On the y-axis, i.e. the rows in the matrix, we put the party A that follows a party B which is listed on the x-axis. We also convert the proportions to percent (new column perc
) and display a rounded percentage (perc_label
) inside the cells. The fill color’s scale uses the viridis color map.
library(ggplot2)
ggplot(counts_p2p, aes(x = to_party, y = from_party, fill = perc)) +
geom_raster() +
geom_text(aes(label = perc_label), color = 'white') +
scale_fill_viridis_c(guide = guide_legend(
title = 'Followers / following\nshare in percent')) +
labs(x = 'party in column is followed by party in row',
y = 'party in row follows party in column',
title = 'Proportion of followings / followers between parties') +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
The following shows a heatmap with data collected on December 5 2018.
We see a strong diagonal, indicating that most parties’ deputies follow party colleagues on Twitter. The only exception here is the CSU, whose deputies twice as often follow CDU colleagues than colleagues from their own party. This doesn’t surprise much though, since both parties form an alliance in the Bundestag and the CSU is the smaller partner.
The SPD has the highest share of intra-party followings. Almost three quarters of their “followings” are towards other SPD colleagues. About 10% of these connections are towards the Green Party and almost 8% towards CDU. At the same time, SPD members are very frequently followed by members of other parties, as you can see at the high values between 10% and 17% across all parties in the SPD column.
The far-right party AfD has the fewest followers from other parties, as we can see in the (due to alphabetic ordering) left-most column. Their members mostly follow FDP and SPD accounts as visible in the bottom row.
This hasn’t changed much in newer data that I collected on July 2 of this year. However, the share of intra-party connections dropped from 58% to 48% for the AfD.
Similar things could be done on deputy level, too. However, I will continue with creating and visualizing a graph of the connections at deputy level.
Creating and visualizing the graph of Twitter friends with igraph
I will construct a graph of the deputy Twitter friends connections with the package igraph. The graph should display the connections between the individual deputies and also indicate their party membership. There are several ways to construct such a graph. I will use the graph_from_data_frame()
function and pass it a data frame of connections (called edge list) and a data frame that describes the nodes (vertices) with their attributes. In our case, the latter means all deputy Twitter accounts with the respective party affiliation.
We essentially already defined the edge list in the edges_parties
dataset that we created for the heatmap. It specifies the edges with the from_account
and to_account
columns. Additionally, it contains the respective party memberships in the columns from_party
and to_party
. We also already have the data frame that describes the nodes: dep_twitter
. However, if we directly use this dataset to create the graph, we will also include a few stray accounts that don’t connect to any of the other accounts. This is because a few deputies don’t follow any of their colleagues. We will create a nodes dataset without these accounts first:
accounts_connected <- unique(c(edges_parties$from_account, edges_parties$to_account))
accounts_not_connected <- dep_twitter$twitter_name[!(dep_twitter$twitter_name %in% accounts_connected)]
# these accounts are used as vertices (aka nodes):
dep_twitter_connected <- filter(dep_twitter, twitter_name %in% accounts_connected)
With this, we can create an igraph graph object now:
library(igraph)
g <- graph_from_data_frame(edges_parties,
vertices = dep_twitter_connected)
g
## IGRAPH 17b614f DN-- 359 8416 --
## + attr: name (v/c), personal.first_name (v/c), personal.last_name
## | (v/c), personal.gender (v/c), personal.birthyear (v/n),
## | personal.location.state (v/c), personal.location.city (v/c),
## | party (v/c), from_party (e/c), to_party (e/c)
## + edges from 17b614f (vertex names):
## [1] martinschulz->katarinabarley martinschulz->oezoguz
## [3] martinschulz->kahrs martinschulz->schneidercar
## [5] martinschulz->sigmargabriel martinschulz->fbrantner
## [7] fabiodemasi ->victorperli fabiodemasi ->f_schaeffler
## [9] fabiodemasi ->lgbeutin fabiodemasi ->pascalmeiser
## + ... omitted several edges
The output from the igraph object seems cryptic at first, because it is very condensed: 359 8416
refers to the number of vertices and edges respectively. Then follows a list of attributes after “+ attr”. After each attribute is a specifier in parentheses that denotes the scope of the attribute and its type. So for example name (v/c)
means that “name” is a vertex attribute of type character, personal.birthyear (v/n)
is a vertex attribute of type numeric and from_party (e/c)
is an edge attribute of type character. In the “+ edges” section a sample of edges is displayed.
With this igraph object we can calculate several graph centrality measures which allows us to identify the most important nodes in a graph. Let’s have a look at two measures: First, the degree which is the number of incoming and outgoing edges of a node. Second, the betweenness that roughly speaking quantifies the number of shortest paths that pass through a node. Let’s calculate both measures (we use the total degree per node, counting both incoming and outcoming edges):
degree_score <- degree(g, mode = 'total')
betw_score <- betweenness(g)
head(degree_score, 3)
## martinschulz fabiodemasi anked
## 6 13 107
head(betw_score, 3)
## martinschulz fabiodemasi anked
## 0.0000 0.0000 235.4485
We can combine this with the deputy data and order per score (see full script on GitHub) to get a top ten. The first table is ordered by degree, the second by betweenness score.
twitter_name full_name degr_score betw_score party
1 peteraltmaier Peter Altmaier 263 927.0231 CDU
2 kahrs Johannes Kahrs 251 3482.5345 SPD
3 sigmargabriel Sigmar Gabriel 248 1184.6123 SPD
4 katarinabarley Katarina Barley 224 1135.4914 SPD
5 c_lindner Christian Lindner 222 1422.2743 FDP
6 hubertus_heil Hubertus Heil 220 1410.6307 SPD
7 larsklingbeil Lars Klingbeil 216 1129.5727 SPD
8 petertauber Peter Tauber 192 588.7581 CDU
9 sven_kindler Sven-Christian Kindler 182 917.6740 DIE GRÜNEN
10 berlinliebich Stefan Liebich 179 2478.9492 DIE LINKE
twitter_name full_name degr_score betw_score party
1 kahrs Johannes Kahrs 251 3482.534 SPD
2 mvabercron Michael von Abercron 149 2543.222 CDU
3 berlinliebich Stefan Liebich 179 2478.949 DIE LINKE
4 f_schaeffler Frank Schäffler 139 1902.670 FDP
5 c_lindner Christian Lindner 222 1422.274 FDP
6 hubertus_heil Hubertus Heil 220 1410.631 SPD
7 ulschzi Ulrike Schielke-Ziesing 45 1353.456 AfD
8 tobiaslindner Tobias Lindner 175 1265.906 DIE GRÜNEN
9 sigmargabriel Sigmar Gabriel 248 1184.612 SPD
10 katarinabarley Katarina Barley 224 1135.491 SPD
We can also visualize our graph with the igraph package. Our graph is quite large and will be better to comprehend when we use different colors for each party. Hence each deputy’s node and outgoing edges should be colored according to her or his party membership. We define a named character vector first with HTML color hex-codes for the nodes and also add a semi-transparent version which we will use for the edges:
party_colors <- c(
'SPD' = '#CC0000',
'CDU' = '#000000',
'DIE GRÜNEN' = '#33D633',
'DIE LINKE' = '#800080',
'FDP' = '#EEEE00',
'AfD' = '#0000ED',
'CSU' = '#ADD8E6'
)
# add transparency as hex code (25% transparency)
party_colors_semitransp <- paste0(party_colors, '40')
names(party_colors_semitransp) <- names(party_colors)
We can assign a color to nodes and edges, by setting a color
attribute for both. The functions V(g)
and E(g)
give access to the vertex (i.e. node) and edge objects of a graph g
. We make the node color dependent on the party
attribute of each node. This attribute came from the dataset dep_twitter_connected
that we passed as vertices
argument to graph_from_data_frame()
when we constructed our graph. We also passed the edge list edges_parties
there, from which the from_party
attribute of each edge comes. We make the edge color dependent on that attribute:
V(g)$color <- party_colors[V(g)$party]
E(g)$color <- party_colors_semitransp[E(g)$from_party]
Creating a layout for visualizing a complex graph is not an easy task. You usually want the edges to overlap and cross each other as little as possible. igraph contains several layout generation algorithms for that purpose, which are implemented in functions prefixed by layout_with_
. I tried out the classic Fruchterman-Reingold algorithm (layout_with_fr()
), Kamada-Kawai (layout_with_kk()
) and finally found that Distributed Recursive Layout (Shawn Martin et al., layout_with_drl()
) provided the best result, as it seems clusters the parties very well (because of the high amount of intra-party connections):
lay <- layout_with_drl(g, options=list(simmer.attraction=0))
We’re now ready to visualize the graph with the computed layout. This can be done with the base R plot()
function to which we pass the graph object g
, the layout lay
and several visual adjustments. We also set a title and a legend.
plot(g, layout = lay,
vertex.size = 2, vertex.label.cex = 0.7,
vertex.label.color = 'black', vertex.label.family = 'arial',
vertex.label.dist = 0.5, vertex.frame.color = 'white',
edge.arrow.size = 0.2, edge.curved = TRUE)
title('Twitter network of members of the German Bundestag',
cex = 1.2, line = -0.5)
legend('topright', legend = names(party_colors), col = party_colors,
pch = 15, bty = "n", pt.cex = 1.25, cex = 0.8,
text.col = "black", horiz = FALSE)
You can see the plots for the data from December 2018 and July 2019 below. Make sure to click on the thumbnail because a graph of this size can only be visualized properly on a large image.
Making an interactive network visualization with visNetwork
Such a static image is fine for smaller graphs but we see that it gets quite crowded and hard to grasp in our scenario. One solution is to generate an interactive graph which allows us to zoom in and out, select specific deputies or parties and display additional information when hovering over certain nodes. The R package visNetwork can be used for that purpose. Switching from igraph to visNetwork is straight forward, as we can convert our igraph object to a visNetwork object via toVisNetworkData()
:
library(visNetwork)
vis_nw_data <- toVisNetworkData(g)
vis_nw_data
contains two data frames: vis_nw_data$nodes
and vis_nw_data$edges
. They’re essentially a tabular form of V(g)
and E(g)
which means their columns (like color
or party
) represent the attributes from the igraph nodes and edges.
Setting a title
column for the nodes data frame will show this title in the interactive plot when you move the pointer over the node. Here, we set the title to a string of the format “@twitterhandle (Firstname Lastname)”:
vis_nw_data$nodes$title <- sprintf('@%s (%s %s)', vis_nw_data$nodes$id,
vis_nw_data$nodes$personal.first_name,
vis_nw_data$nodes$personal.last_name)
We strip the transparency channel from the color hex-codes for the edges (the last two characters), because visNetwork can’t display it properly:
vis_nw_data$edges$color <- substr(vis_nw_data$edges$color, 0, 7)
Finally, we create a data frame that defines the legend:
vis_legend_data <- data.frame(label = names(party_colors),
color = unname(party_colors),
shape = 'square')
Constructing the interactive network graph works by passing the nodes and edges data frames to visNetwork()
and then adjusting the appearance and behavior by concatenating other vis*()
functions with %>%
pipe operators. I’ve added comments below that describe the effect of each line:
visNetwork(nodes = vis_nw_data$nodes, edges = vis_nw_data$edges,
height = '700px', width = '90%') %>%
# use same layout as before
visIgraphLayout(layout = 'layout_with_drl',
options=list(simmer.attraction=0)) %>%
# and same transparency
visEdges(color = list(opacity = 0.25), arrows = 'to') %>%
# set node highlighting
visNodes(labelHighlightBold = TRUE, borderWidth = 1,
borderWidthSelected = 12) %>%
# add legend
visLegend(addNodes = vis_legend_data, useGroups = FALSE,
zoom = FALSE, width = 0.2) %>%
# show drop down menus and highlight nearest edges
visOptions(nodesIdSelection = TRUE, highlightNearest = TRUE,
selectedBy = 'party') %>%
# disable dragging of nodes
visInteraction(dragNodes = FALSE)
If you run that directly in RStudio, it will show up in the viewer pane. You can also assign the result of the above code to an object like vis_nw
and then store it to an HTML file, which you can share and open in a web browser:
visSave(vis_nw, file = 'dep_visnetwork.html')
I’ve uploaded the results here:
Conclusion
We’ve seen that the first obstacle for creating and analyzing the Twitter network of members of the Bundestag is getting the data. This can be done with a combination of web scraping and querying the Twitter API as I’ve shown in part 1.
After some data preparation, we can already calculate some descriptive aggregate statistics like the friends / followers shares per party. Generating a graph with the igraph package opens the door for numerous network analysis tools. The nodes (vertices) of such a graph are deputy Twitter accounts and links (edges) between them represent who follows who on Twitter. Each node and edge can have additional attributes (meta data) such as name, weight or color. Several functions from igraph allow to compute centrality measures that help to identify important nodes in a network. A static plot of the graph can be generated using one of the layout algorithms that ship with igraph. Interactive plots, which can be created with the package visNetwork, give better insight when dealing with large graphs.
Of course there are a lot more things worth looking at. For example, we could also take into account the full friends network of deputies, i.e. not only concentrate on the links between deputies but also links to other Twitter accounts that are not members of the Bundestag. We’ve also not taken into account many variables from the Abgeordnetenwatch.de data. The tweets of the deputies were also not considered. We would still have to collect them (using the Twitter API), but at least we already have the Twitter handles of the deputies.
Besides the R code, the full data is also available in the GitHub repository for this project so this can act as a starting point for further analysis.
Recent Comments