Learning to play Hearthstone, with DATA!
08 Dec 2015I've recently started to play Hearthstone, and I'm absolutely awful. I usually lose two or three matches for each one that I win, including some really frustrating closes loses (usually my own fault). In an attempt to get better I noticed that Blizzard posted the deck lists from the World Championships at BlizzCon. In the comments Yriii collected a data file on all of the decks and made a really cool Tableau dashboard (go check it out, it is awesome). Inspired by his efforts and wanting to up my own game I downloaded the data and decided to do some quick network analysis and visualization with R, igraph and ggplot2.
Basically I want to see how the pros play and how their decks relate both to each other and their win rate.
First up is reading the data into R for analysis, which is easily done with some base commands.
hearth <- read.csv('Card_Scatterplot_data.csv', stringsAsFactors = FALSE)
head(hearth)
## Card.Rarity Card Deck.Count Card.Name Mana
## 1 Common Druid of the Saber 10 Druid of the Saber 2
## 2 Common Druid of the Saber 10 Druid of the Saber 2
## 3 Common Leper Gnome 10 Leper Gnome 1
## 4 Common Leper Gnome 10 Leper Gnome 1
## 5 Common Refreshment Vendor 40 Refreshment Vendor 4
## 6 Common Druid of the Claw 4 Druid of the Claw 5
## Deck.Class Deck.Id Event Player.Name Rarity
## 1 druid 10 2015 Blizzcon World Championship nias Common
## 2 druid 10 2015 Blizzcon World Championship nias Common
## 3 druid 10 2015 Blizzcon World Championship nias Common
## 4 druid 10 2015 Blizzcon World Championship nias Common
## 5 druid 40 2015 Blizzcon World Championship lovecx Common
## 6 druid 4 2015 Blizzcon World Championship hotform Common
## Card.Type
## 1 Minion
## 2 Minion
## 3 Minion
## 4 Minion
## 5 Minion
## 6 Minion
We've got data about cards and players mixed together, so for clarity let's break it out into two separate files. First up are the cards, let's take a look at the mana curve and rarities for all of the championship decks.
library(reshape2)
card.ref <- unique(hearth[,c('Card', 'Mana', 'Rarity', 'Card.Type')])
library(ggplot2)
ggplot(card.ref, aes(x = Mana, fill = Rarity)) + geom_bar(binwidth=1, origin = -0.5, color='black') + scale_x_continuous(breaks=0:20)
One and two mana cards are the most common with a fairly smooth taper off towards 10. Let's also take a look at the relative mana distributions across players and classes. For this we'll need a violin plot, where the y-axis shows the distribution of mana in the deck and the width of the figure represents how many cards in the deck have that value.
ggplot(hearth, aes(x = Deck.Class, y = Mana, fill= Deck.Class)) + geom_violin() + facet_grid(Player.Name ~ .) + xlab('Deck Class') + guides(fill=FALSE)
Looks like hunters tend to bring out a lot more low cost cards while druids come into play a little later.
For players let's summarize what decks they have and their total mana cost across all three decks. Additionally we can fold in data about where each participant is from and their total wins from the Team Liquid Wiki.
library(plyr)
library(dplyr)
##
## Attaching package: 'dplyr'
##
## The following objects are masked from 'package:plyr':
##
## arrange, count, desc, failwith, id, mutate, rename, summarise,
## summarize
##
## The following objects are masked from 'package:stats':
##
## filter, lag
##
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
hearth$player.combo <- paste(hearth$Player.Name, hearth$Deck.Class, sep=',')
player.ref <- ddply(hearth, c('player.combo'), summarize,
total.mana = sum(Mana),
player.name = max(Player.Name),
decks = paste(unique(Deck.Class), collapse=','))
player.details <- data.frame(player.name = unique(player.ref$player.name),
wins= c(7, 13, 6, 9, 10, 4, 3, 5, 4, 3, 2, 15, 9, 5, 11, 8),
location = c('CN', 'NA', 'NA', 'AP', 'AP', 'EU', 'CN', 'AP', 'EU', 'NA', 'CN', 'EU', 'AP', 'NA', 'EU', 'CN') )
player.ref<-left_join(player.ref, player.details)
## Joining by: "player.name"
ggplot(player.ref, aes(x=location, y=wins)) + geom_boxplot()
An EU player won the tournament so their distribution as the highest ceiling, it is also interesting how clustered all the players from the Asia-Pacific region are in performance.
The fun thing about decks is that each card doesn't operate independently. Combos and other strategies often depend on chains of cards being in the deck at the same time. Most statistical measures consider each case as independent, so each card in a deck is a stand alone case. However graph/network analysis focuses on the relationship between different objects. This allows us to look at what pairings, triads or larger grouping of cards are found together in a deck.
Network analysis needs two things, an edge list which lists all of the relationships and a node list that lists all of the objects. This network is going to be a bimodal network, it will have two types of nodes, players and cards. If a player has used a card in their deck they will be linked. Both edges (relationships) and nodes (cards or people) have attributes which are attached to them. Cards will have their mana count as an example. Let's build the network!
library(igraph)
##
## Attaching package: 'igraph'
##
## The following objects are masked from 'package:dplyr':
##
## %>%, as_data_frame, groups, union
##
## The following objects are masked from 'package:stats':
##
## decompose, spectrum
##
## The following object is masked from 'package:base':
##
## union
hearth.edge <- hearth[,c('Card', 'player.combo')] #grab the data for the edges
hearth.net <- graph.data.frame(hearth.edge) #make a basic network
hearth.nl <- get.data.frame(hearth.net, what='vertices') #get a list of all the nodes
library(dplyr)
hearth.nl <- left_join(hearth.nl, card.ref, by=c('name' = 'Card')) #attach card info
hearth.nl <- left_join(hearth.nl, player.ref, by=c('name' = 'player.combo')) #attach player info
hearth.nl$type <- ifelse(is.na(hearth.nl$Rarity)==TRUE, TRUE, FALSE) #Flag if a node represents a player or card
library(igraph)
hearth.net <- graph.data.frame(hearth.edge, vertices=hearth.nl, directed=FALSE) #rebuild the network
l <- layout_nicely(hearth.net)
plot(hearth.net, vertex.color= V(hearth.net)$type, vertex.size = 4, vertex.label=ifelse(V(hearth.net)$type==TRUE, V(hearth.net)$name, NA), vertex.label.family='Helvetica', vertex.label.color='black', layout=l) #visualize
I know this looks like crap, updated plot coming soon!
Currently there are multiple edges connecting some nodes. So if there is a card that appears two of a player's three decks the card edge will be linked by two edges. For simplicity these muti-edges can be collapsed into one with an attribute called weight. Therefore the two edges from beforehand are collapsed into one with a weight value of 2.
E(hearth.net)$weight <- 1
hearth.w.net <- simplify(hearth.net, edge.attr.comb = 'sum')
plot(hearth.w.net, vertex.color= V(hearth.w.net)$type, vertex.size = 3, vertex.label=NA, edge.color=ifelse(E(hearth.w.net)$weight>1, 'red', 'black'), layout=l)
Let's transform the bipartite network into two other ones. The first only consists of cards, each edge between two cards means that they are in the same deck. The other is only players, with edges showing how many cards their decks have in common.
hearth.proj <- bipartite_projection(hearth.net)
card.net <- hearth.proj[[1]]
player.net <- hearth.proj[[2]]
l.c <- layout_nicely(card.net)
#Card Network
plot(card.net, vertex.label=NA, vertex.color=factor(V(card.net)$Rarity), vertex.size=3, layout=l)
l.p <- layout_nicely(player.net)
plot(player.net, vertex.size=3, layout=l.p)
#Player Network
First up is the card deck. Let's compute some network centrality measures. Centrality refers to the position of a given node (card) within the network. There are a couple different centrality measures. Closeness centrality is the "Kevin Bacon" card of the tournament. Like the seven degrees of Kevin Bacon the card with the highest closeness centrality can "hop" along edges to get to any of the other cards quickly. The card with the highest closeness centrality is therefore at the intersection of a lot of different decks.
Betweenness captures if a card is on the shortest path between two other cards. So if a card is part of a lot of multi-card combos or tends to appear with multiple other cards in a deck it should have high betweenness.
Eigenvector centrality is a measure of the 'prestige' of a card. If card is connected to a lot of other cards which are also well connected it will have higher eigenvector centrality. Pagerank, Google's search algorithm is a cousin of this measure. Google scores a page high if it is linked too by other pages with a lot of links. Eigenvector centrality will score a card high if it appears it decks alongside other popular cards.
card.close <- sort(closeness(card.net, normalized= TRUE))
card.btwn <- sort(betweenness(card.net, normalized= TRUE))
card.egn <- sort(evcent(card.net)$vector)
tail(card.close)
## Bear Trap Mind Control Tech Refreshment Vendor
## 0.3441227 0.3458904 0.3500867
## Alexstrasza Loatheb Dr. Boom
## 0.3513043 0.3550088 0.4089069
tail(card.btwn)
## Southsea Deckhand Alexstrasza Blood Knight Cone of Cold
## 0.06545342 0.07215199 0.08824178 0.09118004
## Loatheb Dr. Boom
## 0.16108166 0.38000917
tail(card.egn)
## Swipe Innervate Ancient of Lore
## 0.8198673 0.8198673 0.8198673
## Keeper of the Grove Force of Nature Piloted Shredder
## 0.8198673 0.8198673 1.0000000
So for closeness, Dr. Boom, Loatheb and Alexstrasza are the most "Kevin Bacon" like of the cards in the championship deck. They may not be the most popular but they are connected to a lot of different parts of the card network so you can get from one to any other part easily.
Betweenness sees Dr. Boom, Loatheb and the Cone of Cold as the cards which bridge otherwise unconnected parts of the network. So if you had two distinct decks these would be the most likely ones to overlap. This makes sense as these are neutral cards that activate in conjunction with others, so they can be paired with a large variety of decks.
Eigenvector shows the Piloted Shredder, Innvervate and Force of Nature, these are the most "prestigious" cards, the ones that are most likely to be picked alongside other really popular cards. Clearly these were popular choices at the tournament. This is probably due to the high number of druids in play.
Given that these players are some of the best in the world I was also interested in how much their decks differed from each others. Do all of the members of the tournament follow similar strategies or do some innovate? Also does one path lead to more victories?
One way to count how similar the decks are is to see how many cards overlap between two players. But this doesn't take into account that cards are non independent. Two decks may share a card but use it totally different ways. Given that we've already got a card network I looked at some of the network literature and came across this paper by Brian Uzzi. Basically it details a methods of how to determine how conventional or unconventional a pairing is in a network.
As an example the Murloc Knight and Shielded Minibot cards appear together six times. This may seem like a lot but it is also important to consider their relative popularity. How can we say that their rate of co-occurrence is more or less than what we'd expect by chance? One way is to consider some alternate realities. Each card in the network has a degree score, which is the total number of connections it has in the network, in other words it's relative popularity. Let's look at the Murloc Knight as an example.
mk <- make_ego_graph(card.net, 1, nodes='Murloc Knight')[[1]]
plot(mk, vertex.color=ifelse(V(mk)$name=='Murloc Knight', 'red', 'dodger blue'))
head(get.data.frame(mk))
## from to weight
## 1 Zombie Chow Ironbeak Owl 4
## 2 Zombie Chow Piloted Shredder 10
## 3 Zombie Chow Antique Healbot 1
## 4 Zombie Chow Shielded Minibot 8
## 5 Zombie Chow Murloc Knight 3
## 6 Zombie Chow Big Game Hunter 2
graph.strength(mk)['Murloc Knight']
## Murloc Knight
## 85
mk.el <- get.data.frame(mk)
mk.el.sub<-subset(mk.el, from=='Murloc Knight' | to=='Murloc Knight')
head(mk.el.sub)
## from to weight
## 5 Zombie Chow Murloc Knight 3
## 27 Ironbeak Owl Murloc Knight 3
## 48 Piloted Shredder Murloc Knight 6
## 68 Antique Healbot Murloc Knight 2
## 85 Shielded Minibot Murloc Knight 6
## 104 Murloc Knight Big Game Hunter 3
So our Knight has a total of 85 connections distributed throughout the network, two ties to the Antique Healbot but six co-occurrences with the Piloted Shredder. With this data we can imagine an alternative reality where these weights are different, let's say that there are six ties with the Healbot but only one with the Shredder. Or three and four. Either scenario preserves the total of seven (in this case) but creates different patterns of connection. By shuffling the connections within the card network a few hundred times we can quickly create a bunch of alternative universes which we can then compare reality to.
If a card's pairings are basically random then there shouldn't be much difference between the random alternative realities and the actual data. However if two cards are chosen together more often than chance the actual weight that we see will be a lot higher than the average of the weights across all of the alternatives we created. Similarly if two cards tend not to be chosen together their co-occurrence will be lower. These differences can be captured in a simple statistic called the z-score, which basically tells us how much higher or lower the scores from reality are then the average of the all the simulated scores.
A positive z-score means that a pairing is more conventional, negative more unconventional. By considering all of the card pairs that a player has chosen across their decks it is possible to see who was more conventional or different overall within the tournament.
library(tnet)
## Loading required package: survival
## tnet: Analysis of Weighted, Two-mode, and Longitudinal networks.
## Type ?tnet for help.
net <- cbind(get.edgelist(card.net, names=FALSE), E(card.net)$weight) #extract card pairing and weight
net <- symmetrise_w(net) #get ready
net <- as.tnet(net, type="weighted one-mode tnet")
#SHUFFLE
shuffle <- function(network, seed){
net2 <- rg_reshuffling_w(network, option="weights.local", seed=seed, directed=FALSE) #create alternative realities
ed <- net2$w #extract data
}
set.seed(11)
x1 <- runif(1000, 1, 100000) #do it 1,000 times
graph.grid.d<-ldply(x1, function(x1) shuffle(net,x1)) #glom all the results together
graph.grid.t<-as.data.frame(t(graph.grid.d)) #clean up
names(graph.grid.t)<-paste('n',1:ncol(graph.grid.t), sep='') #name
n0<-get.data.frame(card.net, what='edges') #grab data again
head(n0)
## from to weight
## 1 Druid of the Saber Leper Gnome 4
## 2 Druid of the Saber Druid of the Claw 4
## 3 Druid of the Saber Piloted Shredder 4
## 4 Druid of the Saber Shade of Naxxramas 4
## 5 Druid of the Saber Ancient of Lore 4
## 6 Druid of the Saber Emperor Thaurissan 2
graph.grid.top<-graph.grid.t[(1:nrow(n0)),] #grab the matching data (shuffle creates two entries for each pair but igraph just needs one.)
gg.fin<-cbind(n0, graph.grid.top) #stick it all together
gg.fin$simmean<-apply(gg.fin[4:ncol(gg.fin)],1,mean) #mean of simulations
gg.fin$simsd<-apply(gg.fin[4:ncol(gg.fin)],1,sd) # SD of sims
gg.fin$zs<-(gg.fin$weight-gg.fin$simmean)/gg.fin$simsd #Z-score
gg.trim <- gg.fin[, c('to','from','weight','zs')] #put it all together neatly
head(gg.trim)
## to from weight zs
## 1 Leper Gnome Druid of the Saber 4 0.4084864
## 2 Druid of the Claw Druid of the Saber 4 0.3692745
## 3 Piloted Shredder Druid of the Saber 4 0.3933630
## 4 Shade of Naxxramas Druid of the Saber 4 0.3831305
## 5 Ancient of Lore Druid of the Saber 4 0.4134733
## 6 Emperor Thaurissan Druid of the Saber 2 -2.5098347
z.net <- graph.data.frame(gg.trim, directed=FALSE) #rebuild the network,
combo.net <- hearth.net + z.net #add it back to og network, links players with cards
player.ref$convention <- sapply(player.ref$player.combo, function(x) sum(E(make_ego_graph(combo.net, 1, x)[[1]])$zs, na.rm=TRUE)) #get convetnionality score for each deck.
library(stargazer)
##
## Please cite as:
##
## Hlavac, Marek (2015). stargazer: Well-Formatted Regression and Summary Statistics Tables.
## R package version 5.2. http://CRAN.R-project.org/package=stargazer
stargazer(arrange(player.ref[,c('player.combo', 'total.mana', 'convention')], convention), type='html', summary=FALSE)
player.combo | total.mana | convention | |
1 | lifecoach,warlock | 170 | -74.285 |
2 | diemeng,shaman | 63 | -45.202 |
3 | thijs,priest | 104 | 25.332 |
4 | pinpingho,shaman | 100 | 129.144 |
5 | neilyo,warrior | 120 | 139.214 |
6 | lifecoach,warrior | 114 | 159.159 |
7 | hotform,mage | 92 | 188.359 |
8 | jab,mage | 87 | 315.386 |
9 | thijs,warrior | 81 | 352.817 |
10 | diemeng,hunter | 58 | 353.022 |
11 | kranich,warlock | 95 | 367.533 |
12 | lovecx,warlock | 88 | 369.054 |
13 | ostkaka,rogue | 86 | 426.239 |
14 | hotform,rogue | 86 | 440.618 |
15 | zoro,hunter | 65 | 441.553 |
16 | purple,rogue | 88 | 497.514 |
17 | ostkaka,warrior | 88 | 513.919 |
18 | notomorrow,hunter | 67 | 578.952 |
19 | kno,warlock | 89 | 597.674 |
20 | lovecx,paladin | 118 | 608.781 |
21 | kno,paladin | 119 | 611.119 |
22 | nias,hunter | 80 | 809.899 |
23 | diemeng,paladin | 82 | 868.611 |
24 | thijs,mage | 102 | 944.999 |
25 | ostkaka,mage | 103 | 948.882 |
26 | purple,mage | 108 | 948.882 |
27 | jab,hunter | 88 | 968.730 |
28 | neirea,mage | 111 | 970.598 |
29 | nias,mage | 111 | 970.598 |
30 | pinpingho,hunter | 90 | 972.104 |
31 | kranich,hunter | 86 | 990.616 |
32 | neilyo,hunter | 90 | 1,002.582 |
33 | zoro,paladin | 84 | 1,035.753 |
34 | neilyo,paladin | 92 | 1,040.213 |
35 | neirea,paladin | 95 | 1,041.619 |
36 | notomorrow,paladin | 86 | 1,045.557 |
37 | nias,druid | 105 | 2,233.152 |
38 | neirea,druid | 119 | 3,144.638 |
39 | purple,druid | 119 | 3,144.638 |
40 | kranich,druid | 117 | 3,288.334 |
41 | jab,druid | 115 | 3,303.764 |
42 | hotform,druid | 117 | 3,328.829 |
43 | kno,druid | 118 | 3,335.305 |
44 | lifecoach,druid | 116 | 3,487.367 |
45 | notomorrow,druid | 119 | 3,573.480 |
46 | pinpingho,druid | 114 | 3,616.308 |
47 | lovecx,druid | 120 | 3,629.818 |
48 | zoro,druid | 123 | 3,667.808 |
So the most novel deck (within the tournament) is Lifecoach's Warlock while the most conventional is Zoro's Druid. As a crude metric let's compare the mana curve of Lifecoach's deck to the other Warlocks.
wlock <- subset(hearth, Deck.Class=='warlock')
ggplot(wlock, aes(x=Player.Name, y=Mana, fill=Player.Name)) + geom_violin()
Very different with that investment in high mana cards. Let's also compare the druids
druid <- subset(hearth, Deck.Class=='druid')
ggplot(druid, aes(x=Player.Name, y=Mana, fill=Player.Name)) + geom_violin()
Much more uniform, which is probably why we see a lot more druids towards the top of the conventionality ratings.
Since these ratings have passed the initial sanity test let's see how they relate to success within the tournament.
player.ref <- cbind(player.ref, colsplit(player.ref$player.combo, ',', c('Player.Name', 'Deck')))
player.ref<- left_join(player.ref, player.details)
## Joining by: c("player.name", "wins", "location")
player.conv<-ddply(player.ref, 'Player.Name', summarize,
tot.convention=sum(convention),
wins = max(wins),
tot.mana = sum(total.mana),
location = max(as.character(location)))
ggplot(player.conv, aes(x=wins, y=tot.convention, color=tot.mana, label=Player.Name)) + geom_text(size=4) + xlab('Wins') + ylab('Conventionality (all decks)') + scale_colour_continuous(name = "Total Mana (all decks)", low='dodger blue', high='red') + geom_smooth()
## geom_smooth: method="auto" and size of largest group is <1000, so using loess. Use 'method = x' to change the smoothing method.
There is a clear trend towards more novel decks also winning more. HOWEVER it isn't statistically significant, so it may be that these results are due to chance. This is in part due to the small sample (16 players), so if anyone has a big database of decks and win rates let me know! Might be fun to test this technique in a more robust setting.
This analysis has a number of limitations. I didn't take the time to pull the head to head match ups to see if novel or conventional decks one at each round. Additionally a lot of the statistics are aggregated upwards to the player or deck level instead of looking at the micro-interactions between pairs or triads of cards. Still, I hope it has provided some insight and hopefully it will help me (and others) player a little better.
So there we have it. My Hearthstone win rate hasn't gotten much better but this was a fun little exploration into the decks being used by the best of the best. I highly doubt I'll ever be at that level but these results are encouraging as my play style so far has been to mess with odd or unusual deck compositions. Who knows maybe I'll find a killer combo or build and start climbing the ladder. Until then I'll see you in the 15-20 ranks.