We use cookies and other tracking technologies to improve your browsing experience on our website, to show you personalized content and targeted ads, to analyze our website traffic, and to understand where our visitors are coming from.
Here’s a post which I probably should have written right before Vienna’s local elections, and not after. But better late than never: a descriptive go at candidates’ ads on Facebook in the run-up to the elections of 11 Oct 2020. For a previous blog post dealing with Facebook ads in the run-up to the European Parliamentary Elections 2019 see here.
To provide some general context, for a while now, Facebook has been offering access to details on electoral ads placed on the company’s social networking site. The service was introduced as some kind of response to the allegations of manipulative and illegitimate interference in the 2016 US presidential elections or the Brexit referendum via its platform. By Facebook’s own account, the library seeks to ‘provide advertising transparency by offering a comprehensive, searchable collection of all ads currently running from across Facebook apps and services, including Instagram.’ In addition to aggregated reports, the pertaining API allows to conduct ‘customized keyword searches of active and inactive ads about social issues, elections or politics.’1 Hence, and notwithstanding some limitations, the library provides a critical peep-hole into electoral campaigning. In this case, it’s the elections to the Vienna city council and its lower-level district councils.
2 Scope conditions
A basic yet critical step in the analysis of Facebook ads is to delineate which Facebook pages and ads to include. How to identify those pages and ads which actually relate to the Vienna 2020 elections? Since Facebook’s ad library does not group ads according to a specific campaign or cause this task is part of every analysis.
Here, I came up with the following main criteria which all have to be fulfilled.
political parties: I’ll include only those ads which were placed by parties which eventually won a mandate to the city council. An exception is the party of HC Strache. The latter I include simply because Strache is the former Vice-Chancellor of Austria and former leader of the FPÖ.
time: ads have to be placed within the three months prior to the election date (11 July to 11 Oct).
ads’ content: the text of the ad has to contain the word ‘Wien’ (Vienna). Admittedly, this is a somewhat crude condition, but it’s simply the most reasonable indicator I came up with for my time available for this post. The condition is particularly important when considering the ads placed by the ÖVP’s main candidate, Gernot Blümel. Blümel is also Austria’s finance minister and hence his ads may not only relate to the Vienna electoral campaign, but also to issues related to his federal position. The latter category of ads has to be excluded.
regional link: the Facebook page should have a clear link to Vienna. E.g. ads placed by a party’s branch from another state were not included.
minimum number of ads: to keep the number of pages somehow manageable, I included only pages which placed at least three ads within the period under consideration.
The above scope conditions might not be 100 % water tight, and there might be edge cases which erroneously fall out or into the set of ads eventually included. Overall though I would think to get with these criteria a sufficiently robust base to come to valid findings (and keep the effort for a blog post at a reasonable level). In any case it’s important to highlight that all subsequent results of the analysis will be contingent on these scoping decisions. That’s something to bear in mind.
If you’re primarily interested in the results of the analysis and can’t be bothered by all the R fuzz, just go directly here.
3 Getting the data
Note that there is by now also an R package which seeks to make data retrieval from Facebook’s API easier (Rfacebook). Since I had accessed Facebook’s API already previously via the httr package, I stuck to this approach.
Get data from Facebook API
#token from Facebook api appyour_fb_api_token <-"#########"#define fields of interestsearch_fields=c("ad_creation_time", "ad_delivery_start_time", "ad_delivery_stop_time","ad_creative_body", "page_id","page_name","currency","spend","demographic_distribution","funding_entity","impressions","potential_reach","publisher_platforms","region_distribution") %>% stringr::str_c(., collapse=", ")my_link<-"https://graph.facebook.com"#define GET requestpage_one_response <-GET(my_link,path ="/ads_archive",query =list(access_token = my_token,limit=100,ad_active_status="ALL",search_terms="''",fields=search_fields,ad_reached_countries="AT"))#get first page of resultspage_one_content<-content(page_one_response)df_ads_all <-tibble(data=page_one_content$data)df_ads_all <- x %>%unnest_wider(data) #extract link for next results pagenext_link <- page_one_content$paging$`next`#initiate loop until there is no link to a next pagepage_i=2while(length(next_link)>0) { next_response <-GET(next_link) next_content<-content(next_response) y <-tibble(data=next_content$data) df_next <- y %>%unnest_wider(data) df_ads_all <-bind_rows(df_ads_all, df_next) next_link <- next_content$paging$`next` page_i <- page_i+1print(page_i)print(df_next %>%nrow())}#save resultsdf_ads_all$time_api_download <-Sys.time()time_stamp <-format(Sys.time(), "%Y%m%d-%H%M")#write_rds(df_ads_all, path=paste0(wdr,"/data/scraped_ads",time_stamp,".rds"))
The code above provides us with all (!) ads placed in Austria. Overall, the result had more than 1 gig, and retrieving the data took a while. There are certainly ways to limit the search from the outset, but I thought it can’t harm to have the entire set, also for possible future analysis.
3.1 Apply scope conditions
Now let’s apply the scope conditions from above. Applying the filter for ads’ time of creation and whether ads include the word ‘Wien’ is straight forward:
df_ads <- df_ads %>%filter(str_detect(ad_creative_body, "Wien(?!.*Neustadt)")) #to also exclude ads which refer to Wiener Neustadt
Based on this result I ‘manually’ checked every remaining facebook page whether a party affiliation and a regional link with Vienna exists. If interested in these ‘coding decisions’ you can see the table by unfolding the section below.
Coding of pages of interest
WIEN-WAHL 2020: Facebook pages within scope and affiliated party
page
party
page_type
include
Gernot Blümel
ÖVP
individual
yes
Neue Volkspartei Wien
ÖVP
individual
yes
Josef Mantl
ÖVP
individual
yes
Zack Zack
no
Die Grünen Wien
GREENS
institutional
yes
SPÖ Wien
SPÖ
institutional
yes
Stadt Wien
no
SPÖ Neubau
SPÖ
institutional
ye
Michael Ludwig
SPÖ
individual
yes
Christoph Wiederkehr
NEOS
individual
yes
Initiative Vielfalt
no
NEOS Wien
NEOS
institutional
yes
SPÖ Innere Stadt
SPÖ
institutional
yes
Harald Zierfuß
ÖVP
institutional
yes
Dominik Nepp
FPÖ
individual
yes
Birgit Hebein
GREENS
individual
yes
LINKS
LINKS
institutional
yes
Austrian World Summit
no
SÖZ - Soziales Österreich der Zukunft
SÖZ
institutional
yes
NeueZeit Wien
no
Elisabeth Olischar
ÖVP
individual
yes
Maximilian Krauss
FPÖ
individual
yes
Marcus Schober
SPÖ
individual
yes
Barbara Novak
SPÖ
individual
yes
Wien wählt
no
Team HC Strache - Allianz für Österreich
STRACHE
institutional
yes
Markus Wölbitsch
ÖVP
individual
yes
Sebastian Bohrn Mena
no
Tierschutzvolksbegehren
no
Kathrin Gaal
SPÖ
individual
yes
Die Grünen Hietzing
GREENS
institutional
yes
Volt Österreich
VOLT
institutional
yes
Grüne Penzing
GREENS
institutional
yes
Jürgen Czernohorszky
SPÖ
individual
yes
Christina Schlosser
ÖVP
individual
yes
Ernst Woller
SPÖ
individual
yes
KOSMO
no
Schützen wir unsere Spitäler
no
Zeit.Gespräche mit Gerhard Schmid
no
Omar Al-Rawi
SPÖ
individual
yes
Michael Niegl
FPÖ
individual
yes
Die Grünen Wien - Innere Stadt
GREENS
institutional
yes
ServusTV
no
Team Markus Rumelhart - SPÖ Mariahilf
SPÖ
individual
yes
Weiterbildung in Wien | waff
no
kontrast.at
no
Andrea Grabner
ÖVP
individual
yes
Ewa Ernst-Dziedzic
GREENS
individual
yes
Greenpeace Österreich
no
Jörg Neumayer
SPÖ
individual
yes
Kasia Greco
ÖVP
individual
yes
Markus Ornig
NEOS
individual
yes
SPÖ Alsergrund
SPÖ
institutional
yes
Wiener SPÖ-Frauen
SPÖ
institutional
yes
Hannes Taborsky
ÖVP
individual
yes
Heinz-Christian Strache / HC Strache
STRACHE
individual
yes
Josef Taucher
SPÖ
individual
yes
Sabine Schwarz
ÖVP
individual
yes
Thomas Steinhart
SPÖ
individual
yes
Wirtschaftsbund Wien | Wirtschaft in Wien
ÖVP
institutional
yes
Paul Johann Stadler
FPÖ
individual
yes
Peter Hanke
SPÖ
individual
yes
Wandel
WANDEL
institutional
yes
Bernadette Arnoldner
ÖVP
individual
yes
FPÖ Favoriten
FPÖ
institutional
yes
Grünalternative Jugend Wien
GREENS
institutional
yes
Jan Tewes
VOLT
individual
yes
Marcus Franz
SPÖ
individual
yes
Neue Volkspartei Landstraße
ÖVP
institutional
yes
Plattform Christdemokratie
no
Servus Nachrichten
no
Die Grünen Döbling
GREENS
institutional
yes
Muhammed Yüksek
SPÖ
individual
yes
Europäische Kommission - Vertretung in Österreich
no
Grüne Liesing
GREENS
institutional
yes
Neubauer Grüne
GREENS
institutional
yes
SPÖ Donaustadt
SPÖ
institutional
yes
Benjamin Schulz
SPÖ
individual
yes
Daniel Soudek
ÖVP
individual
yes
Laura Sachslehner
ÖVP
individual
yes
Marek Skalicka
VOLT
individual
yes
Nemanja Damnjanovic
FPÖ
individual
yes
SPÖ Hernals
SPÖ
institutional
yes
SPÖ Oberösterreich
SPÖ
no
Stadt Wien - Umweltschutz
no
Die Grünen Donaustadt
GREENS
institutional
yes
Die Grünen Favoriten
GREENS
institutional
yes
Harry Kopietz
SPÖ
individual
yes
Institute of the Regions of Europe (IRE)
no
KPÖ Wien
KPÖ
no
Leo Kohlbauer
FPÖ
individual
yes
Moment Magazin
no
Peter Kraus
GREENS
individual
yes
Silvia Jankovic Margareten
SPÖ
individual
yes
Amnesty International Austria
no
Die Grünen Josefstadt
GREENS
institutional
yes
Grüne Mariahilf
GREENS
institutional
yes
Junge Generation Wien
no
Kreiskys Kinder
no
Lisa Fuchs
ÖVP
individual
yes
Neue Volkspartei Leopoldstadt
ÖVP
institutional
yes
Sozialistische Jugend Wien
SPÖ
institutional
yes
SPÖ Penzing
SPÖ
institutional
yes
Tabea Wich
VOLT
individual
yes
WARDA
no
Wien Energie
no
Die Grünen Leopoldstadt
GREENS
institutional
yes
e-control
no
Lea Halbwidl
SPÖ
individual
yes
Norbert Hofer
FPÖ
individual
yes
Nurten Yilmaz
SPÖ
individual
yes
Richard Knoll
VOLT
individual
yes
Roman Schmid
FPÖ
individual
yes
Rudolf Silvan
SPÖ
individual
yes
SOS Balkanroute
no
SPÖ Simmering
SPÖ
institutional
yes
Uschi Lichtenegger
GREENS
individual
yes
Alexander Trinkl
no
Die Grünen Wieden
GREENS
institutional
yes
Die Tagesstimme
no
Dietrich Kops
STRACHE
individual
yes
Europäisches Parlament Österreich
no
FPÖ Wien
FPÖ
institutional
yes
Freiheitliche Jugend
FPÖ
institutional
yes
Grüne Jugend Niederösterreich
GREENS
institutional
no
Ingrid Korosec
ÖVP
individual
yes
Jetzt24
no
KJÖ Steiermark
no
Lukas Burian
NEOS
individual
yes
Marcel Flitter
ÖVP
individual
yes
Matthias Friedrich
SPÖ
individual
yes
Michael Trinko
SPÖ
individual
yes
neudenkenheisst
no
ÖGPP - Österr. Gesellschaft für Politikberatung und Politikentwicklung
no
Paneuropa Bewegung Österreich
no
Robert Nagele
no
Udo Guggenbichler
FPÖ
individual
yes
Ulli Sima
SPÖ
individual
yes
UNTER PALMEN
no
vidaflex
no
9er Sektion
SPÖ
institutional
maybe
Die Grünen Landstraße
GREENS
institutional
yes
Echt Rot
no
Eugen Prosquill
WARDA
individual
no
FPÖ
FPÖ
institutional
yes
FPÖ Wien Donaustadt
FPÖ
institutional
yes
Gewerkschaft vida
no
Grüne Meidling
GREENS
institutional
yes
Herbert Kickl
FPÖ
individual
yes
Ina Dimitrieva
VOLT
individual
yes
Junge Generation Floridsdorf
no
Junge ÖVP Meidling
ÖVP
institutional
yes
Markus Reiter
GREENS
individual
yes
Meri Disoski
GREENS
individual
yes
miss
no
Neue Volkspartei Simmering
ÖVP
institutional
yes
Nikbakhsh & Oppitz
no
Politik muss wieder für die einfachen Menschen gemacht werden.
n o
Pro Gymnasium
no
Sektion Augarten
SPÖ
institutional
yes
SPÖ Josefstadt
SPÖ
institutional
yes
yenivatan.at
no
Zeitung der Arbeit
no
Andreas Babler
SPÖ
individual
yes
aufstehn
no
BMK Infothek
no
BunT für Polling
no
corinna.scharzenberger
ÖVP
individual
yes
Dávid Huszti
ÖVP
in
yes
Die Grünen Margareten
GREENS
institutional
yes
Die Grünen Simmering
GREENS
institutional
yes
Dorian Alexander Rammer
SPÖ
individual
yes
Dunav.at
no
Figlhaus Wien
no
Firas Saedaddin-Seite
SPÖ
individual
yes
FPÖ Wien Liesing
FPÖ
institutional
yes
Freiheitliche und Unabhängige FPÖ Maria Enzersdorf
Revolution ich geh ohne Maulkorb bis 2020 und weiter
no
Robert Pfisterer
no
Roman Haider
FPÖ
individual
yes
Samuel Ferraz-Leite
SPÖ
individual
yes
Saya Ahmad
SPÖ
individual
yes
Sektion Z
SPÖ
institutional
yes
Siemens Österreich
no
Silke Kobald
Socken mit Haltung
no
Solidarität
no
SPÖ Floridsdorf
SPÖ
institutional
yes
SPÖ Göllersdorf
SPÖ
institutional
yes
SPÖ Guntramsdorf
SPÖ
institutional
yes
SPÖ Landstraße
SPÖ
institutional
yes
SPÖ Margareten
SPÖ
institutional
yes
SPÖ Schruns u. Parteifreie
SPÖ
institutional
no
Stefan Haböck
no
Stefan Magnet
no
SUPERTRAMPS - Es gibt immer einen Weg
no
Team HC Strache Landstraße
STRACHE
institutional
yes
Team HC Strache Währing
STRACHE
institutional
yes
The Webherald
no
Tuncer Cinkaya
no
UNICEF Österreich
no
unzensuriert.at
no
Volkspartei Herzogenburg
no
Wien-Süd
no
wien.info
no
Wilfried Zankl
SPÖ
individual
yes
WKO Steiermark
no
Zero Waste Austria
no
data: Facebook Ad Library API
analysis: Roland Schmidt | @zoowalk | http://werk.statt.codes
Let’s consider only ads which were posted by those pages, add the party affiliations, limit the selection to those parties in which we are interested, and keep only those pages with at least three ads posted.
Code
### add party affiliations df_ads <- df_ads %>%filter(page_name %in% pages_of_interest$page_name[pages_of_interest$include=="yes"]) %>%left_join(., pages_of_interest %>%select(page_name, party, page_type), by=c("page_name"="page_name")) %>%mutate(name_party=map_chr(page_name, labeller_page) %>%paste0(., " (", party, ")")) %>%mutate(name_party=case_when(page_type=="institutional"~ page_name,TRUE~as.character(name_party)))### only main partiesmain_parties <-c("SPÖ", "ÖVP", "GREENS", "NEOS", "FPÖ", "STRACHE")vec_main_parties <-str_c(main_parties, collapse="|")df_ads <- df_ads %>%filter(str_detect(party, vec_main_parties))### remove pages with less than 3 adsdf_pages_small_n <- df_ads %>%group_by(page_name) %>%summarise(n_ads=n()) %>%filter(n_ads<3)df_ads <- df_ads %>%anti_join(., df_pages_small_n,by="page_name")
With our dataset now put into scope we have details on 5,038 ads by 105 distinct Facebook pages.
Now with our dataset eventually put into scope, let’s bring it into a format with which we can actually work.
Note that the data on ads’ prices (spend), the audience’s demographic distribution (gender and age) as well as ads’ impressions are contained as lists. We will have to bring them into an other format for our subsequent analysis.
Let’s first un-nest the data on spending. Note that Facebook’s API does not provide a specific price of an ad, but instead a price category (interval) in which each ad falls. Hence when calculating i.e. the total amount spent by a candidate, we are bound to operate within possible minimum and maximum totals. As a means of simplification, I also use each price segment’s mid point for further comparison.
We now have a dataframe with all ads of interest and most of the data rearrangement already implemented.
4 Number and value of ads
Let’s first look at the number and value of ads. The table below provides the aggregate number and amount spent on ads per party. These numbers comprise the expenditures by all Facebook pages included in the analysis. To drill further down and see the pertaining contribution of each page, click on the little arrow sign.
4.1 Overview Table
Code: Table on parties’ number of ads and total spending
The imho neat thing about this reactable table is that it comprises three tables with nested information: The aggregate values on the party level, the individual values for each Facebook page; and the funding entities for each page. To make this work, one has to subset the data of interest with the corresponding [index] value. See the code below:
WIEN-WAHL 2020: Number and total amount spent by party
Click to see details for indiviudal Facebook pages. Ads placed between 11 Jul 2020 and 11 Oct 2020.
spending
party
number of ads
min
mid
max
ÖVP
2,536
40,800
166,932
293,064
SPÖ
1,129
29,000
85,286
141,571
GREENS
647
66,800
102,677
138,553
NEOS
439
5,900
27,831
49,761
FPÖ
235
47,400
60,433
73,465
STRACHE
52
2,900
5,474
8,048
data: Facebook Ad Library API
analysis: Roland Schmidt | @zoowalk | http://werk.statt.codes
As it becomes quite clear, the ÖVP has bought the largest number of ads with 2,536, followed by the SPÖ with 1,129 ads. The gap is quite considerable. If we unfold the ÖVP row, we can see that the largest chunk of its ads was bought for Gernot Blümel with 1,083 ads.
When it comes to the total sum spent on Facebook ads, the picture is less specific. As already mentioned above, Facebook’s ad library API provides only each ad’s price category with its lower and upper bounds. Hence, when seeking to obtain a total sum of expenditures, we are limited to sum up the lower and upper bounds within which the overall total is located. This limitation is particularly consequential when a party bought many ads from the lowest price category which includes the value 0 (0-99). As we will see later, this is particularly the case with the ÖVP.
Bearing this caveat in mind and as an ‘educated guess’, let’s focus on the aggregated mid-values of each ad. Again, the ÖVP comes out on top with a total of €166,932 spent. This number exceeds quite considerably the mid-values obtained for the GREENS (€102,676) and the SPÖ (€85,286).
4.2 Number of ads
For an overview let’s plot the pertaining numbers for the top 5 pages of each party (in terms of number of ads). Hover with the mouse over the bars to get details.
Code: Graph on parties’ number of ads
Code
df_pl_pages <- df_ads_wide %>%ungroup() %>%group_by(party) %>%mutate(page_name_lum=fct_lump_n(page_name, n=5,other_level="all other")) %>%ungroup() %>%group_by(party, page_name_lum) %>%summarise(ads_n=n(),across(.cols=c(contains("spend_") &where(is.numeric)),.fns=sum )) %>%ungroup() %>%select(party, page_name_lum, ads_n, spend_lower_bound_rev, spend_mid, spend_upper_bound) %>%arrange(-ads_n)pl_pages <- df_pl_pages %>%mutate(party=fct_relevel(party, levels_party)) %>%ggplot()+labs(title="WIEN-WAHL 2020: Number of Facebook ads",subtitle=str_wrap(glue::glue("Top 5 Facebook pages per party in terms of number of ads and one 'lump' category for all other pages. Ads placed between {date_observation_start_format} and {date_observation_end_format}."), 100),caption=my_caption)+geom_bar_interactive(aes(x=ads_n,y=tidytext::reorder_within(x=page_name_lum,by = ads_n,within = party),tooltip=paste("Number of ads:", ads_n %>% scales::comma(., accuracy=1)),fill=party),stat="identity")+scale_y_discrete(labels=function(x) str_extract(x, regex(".*(?=___)")))+scale_x_continuous(expand=expansion(mult=c(0.01, 0.05)),breaks=seq(0, 1200, 300),limits=c(0, 1250),labels=scales::label_comma())+scale_fill_manual(values=vec_party_colors)+ lemon::facet_rep_wrap(vars(party),ncol=2,repeat.tick.labels = T,scales ="free_y")+theme_post()+theme(legend.position ="none",panel.grid.major.y =element_blank(),panel.grid.major.x =element_line(color="grey50", linewidth =0.1),panel.spacing.x=unit(0, units="cm"),axis.text.y =element_text(size=7),axis.text.x =element_text(size=7),axis.title.x =element_blank())
A few details stood out for me: The ÖVP and Blümel’s lead in terms of number of ads is quite remarkable. With 1,083 ads, Gernot Blümel’s tally exceeds the one of Vienna’s (previous and new) major Michael Ludwig with 224 ads many times over. But even if we would put Blümel’s record aside, and only focus on the number of ads bought by his party and colleagues, the ÖVP’s strong record remains fully in order. The page of the ÖVP Vienna placed with 712 ads more than the pages of the SPÖ Wien, Michael Ludwig, and the SPÖ Inner Stadt (1st district) together (706). While I expected the ÖVP to be ahead, I didn’t see the gulf to be so wide.
As for the Greens, what stood out to me was the small number of ads placed by the party’s main candidate, Birigt Hebein. With only 84 ads, her campaign - at least on Facebook’s platforms - left the main floor to the party’s institutional pages, first and foremost ‘Die Grüne Wien’ with 367 ads, but also to the pages of the district branches Hietzing and Penzing with 47 ads in total. (Tellingly, Hebein was effectively removed from the party leadership by her colleagues in the wake of the elections, despite the party’s all-time best result).
Finally, what also struck me was the very low number of ads placed by Team Strache. With as little as 52 ads in total, the party had a very limited campaign on Facebook. Even when considering that the party was newly founded, for the former leader of the FPÖ who was renown for its strong social media presence, this was all in all quite a dramatic decline (even if the number of ads and followers are obviously not the same).
4.3 Spending
Let’s now look at the money spent on ads. As already mentioned above, the analysis is somewhat complicated by the fact that Facebook does not provide the specific price of an ad, but only the price category of an ad, with its lower and upper bounds. In the graph above, the point indicates the mid-value of the interval, the line’s ends the lower and upper bounds.
With a few small differences, the general result on spending is congruent with the preceding analysis on the number of ads. The ÖVP and Blümel come out on top. The institutional page of the Greens in Vienna also spent considerably. Note that the top five pages in terms of money spent on ads are not always the top five pages in terms of number of ads bought.
5 Impressions
Eventually though, the number and amount spent on ads doesn’t tell us anything on how often an ad has been seen. Facebook’s ad library provides data on each ad’s ‘impressions’. An impression is defined as the number of times an ad entered a person’s screen.2 However, note that these figures do not tell us anything on how many unique individual have actually seen an ad. Furthermore, Facebook only provides interval data for an ad’s impression (lower/upper bound). Again, I use the mid-value of these interval as an ‘educated guess’ and aggregate lower and upper bounds for totals. Hover with the mouse cursor over the graph to get details.
Code: Impression of ads
df_ads_impressions <- df_ads_wide %>%select(party, page_name, contains("impressions")) %>%group_by(party, page_name) %>%summarize(across(.cols=(contains("impressions") &where(is.numeric)), .fns=~sum(., na.rm = T))) %>%group_by(party) %>%mutate(page_name_lum=fct_lump_n(page_name, n=5, w=impressions_mid,other_level ="all others")) %>%group_by(party, page_name_lum) %>%summarise(across(.cols=where(is.numeric), .fns=~sum(., na.rm=T),.names="sum_{.col}")) %>%ungroup()pl_df_impression <- df_ads_impressions %>%mutate(party=fct_relevel(party, levels_party)) %>%ggplot()+labs(title="WIEN-WAHL 2020: Total number of impressions of Facebook ads",subtitle=str_wrap(glue::glue("Top 5 Facebook pages per party. Ads placed between {date_observation_start_format} and {date_observation_end_format}. Impressions: Number of ads' appearances on a screen."), 100),caption=my_caption)+geom_linerange_interactive(aes(xmin=sum_impressions_lower_bound,xmax=sum_impressions_upper_bound,y=reorder_within(x=page_name_lum,by = sum_impressions_mid,within=party),color=party))+# tooltip=paste("min:", sum_impressions_lower_bound %>% scales::comma(), "\n",# "max:", sum_impressions_upper_bound %>% scales::comma())))+geom_point_interactive(aes(x=sum_impressions_mid,y=reorder_within(x=page_name_lum,by = sum_impressions_mid,within=party),color=party,tooltip=paste("min:", sum_impressions_lower_bound %>% scales::comma(),"\n","mid:", sum_impressions_mid %>% scales::comma(),"\n","max:", sum_impressions_upper_bound %>% scales::comma())))+scale_y_discrete(labels=function(x) str_extract(x, regex(".*(?=___)")))+scale_x_continuous(expand=expansion(mult=c(0.01, 0.05)),breaks=seq(0, 15*10^6, 5*10^6),minor_breaks =NULL,labels=scales::number_format(scale=.000001,accuracy =1,suffix="m") )+scale_color_manual(values=vec_party_colors)+ lemon::facet_rep_wrap(vars(party),ncol=2,repeat.tick.labels = T,scales ="free_y")+theme_post()+theme(legend.position ="none",panel.grid.major.y =element_blank(),panel.grid.major.x =element_line(color="grey50", linewidth =0.1),panel.spacing.x=unit(0, units="cm"),axis.text.y =element_text(size=7),axis.text.x =element_text(size=7),axis.title.x =element_blank())
What stands out is the value obtained for the institutional Facebook page of the Greens. With a total value of impressions ranging between 12,825,000 and 15,276,633, the page’s ads exceed the values by the pages of the ÖVP despite the latter’s higher number of ads.
5.1 Distribution of ads over impression categories
To get a better idea where the Green’s high impression number comes from, let’s disaggregate the total number of ads by their impression category. The table below shows for each impression interval, a) the number of ads per page, b) each interval’s share of the page’s total number of ads, and c) the contribution of the ads of each impression category to the total number of impressions. Note that these latter values are calculated on the basis of the mid values of each impression interval (e.g. impression category <1K = 0 - 999 impressions; mid value = 499.5)!
Code: Distribution of ads over impression categories
orange_pal <-function(x) rgb(colorRamp(c(plot_bg_color, "#ff9500"))(x), maxColorValue =255)firebrick_pal <-function(x) rgb(colorRamp(c(plot_bg_color, "#B22222"))(x), maxColorValue =255)rt_impressions <-reactable(tb_impression_cat %>%mutate(impressions_category=str_extract(impressions_category, regex("(?<=-).*")) %>%as.numeric()+1 ) %>%mutate(impressions_category = impressions_category /1000) %>%#%>% #keep numericarrange(impressions_category) %>%select(-ends_with("_sum"), -ends_with("_min"), -ends_with("_max"),-ends_with("_mid")), columns=list("impressions_category"=colDef(name="impression\ncategory", width=100,sortable = T,format=colFormat(prefix="<",suffix="K"),style =list(borderRight ="1px solid rgba(0, 0, 0, 0.1)")),"die_grunen_wien_n_abs"=colDef(name="Die Grünen Wien", minWidth=50),"die_grunen_wien_n_rel"=colDef(name="Die Grünen Wien",format =colFormat(percent=T,digits=1), style=function(value){ color <-orange_pal(value)list(background=color) },minWidth=50),"die_grunen_abs_impression_mid_rel"=colDef(name="Die Grünen Wien", format=colFormat(percent=T,digits =1),style=function(value){ color <-firebrick_pal(value)list(background=color) },minWidth=50),"gernot_blumel_abs_impression_mid_rel"=colDef(name="Gernot Blümel", format=colFormat(percent=T,digits =1),style=function(value){ color <-firebrick_pal(value)list(background=color) },minWidth=50),"neue_volkspartei_wien_abs_impression_mid_rel"=colDef(name="Neue Volkspartei Wien", format=colFormat(percent=T,digits =1),style=function(value){ color <-firebrick_pal(value)list(background=color) },minWidth=50),"gernot_blumel_n_abs"=colDef(name="Gernot Blümel", minWidth=50),"gernot_blumel_n_rel"=colDef(name="Gernot Blümel",format=colFormat(percent=T,digits=1),style=function(value){ color <-orange_pal(value)list(background=color) },minWidth=50),"neue_volkspartei_wien_n_abs"=colDef(name="Neue Volkspartei Wien", minWidth=50),"neue_volkspartei_wien_n_rel"=colDef(name="Neue Volkspartei Wien",format=colFormat(percent=T,digits =1),style=function(value){ color <-orange_pal(value)list(background=color) },minWidth=50)),columnGroups =list(colGroup(name="number of page's ads ",align="left",columns=str_subset(names(tb_impression_cat), regex("n_abs$"))),colGroup(name="% of page's total ads",align="left",columns=str_subset(names(tb_impression_cat), regex("n_rel$"))),colGroup(name="% of page's total impressions",align="left",columns=str_subset(names(tb_impression_cat), regex("mid_rel$")))),sortable = T,filterable = F,compact = T,fullWidth = T,defaultPageSize =15,theme=rt_theme)
Impressions
number of page's ads
% of page's total ads
% of page's total impressions
impression
category
Die Grünen Wien
Gernot Blümel
Neue Volkspartei Wien
Die Grünen Wien
Gernot Blümel
Neue Volkspartei Wien
Die Grünen Wien
Gernot Blümel
Neue Volkspartei Wien
<1K
77
718
321
21.0%
66.3%
45.1%
0.3%
5.2%
2.2%
<2K
34
94
73
9.3%
8.7%
10.3%
0.4%
2.0%
1.5%
<3K
23
52
37
6.3%
4.8%
5.2%
0.4%
1.9%
1.3%
<4K
19
33
24
5.2%
3.0%
3.4%
0.5%
1.7%
1.1%
<5K
11
28
28
3.0%
2.6%
3.9%
0.4%
1.8%
1.7%
<6K
18
8
14
4.9%
0.7%
2.0%
0.7%
0.6%
1.1%
<7K
6
9
18
1.6%
0.8%
2.5%
0.3%
0.8%
1.6%
<8K
14
14
15
3.8%
1.3%
2.1%
0.7%
1.5%
1.5%
<9K
10
9
7
2.7%
0.8%
1.0%
0.6%
1.1%
0.8%
<10K
5
5
9
1.4%
0.5%
1.3%
0.3%
0.7%
1.2%
<15K
32
20
40
8.7%
1.8%
5.6%
2.8%
3.6%
6.8%
<20K
19
12
25
5.2%
1.1%
3.5%
2.4%
3.0%
6.0%
<25K
20
8
18
5.4%
0.7%
2.5%
3.2%
2.6%
5.5%
<30K
8
8
17
2.2%
0.7%
2.4%
1.6%
3.2%
6.4%
<35K
5
5
11
1.4%
0.5%
1.5%
1.2%
2.3%
4.9%
1–15 of 35 rows
data: Facebook Ad Library API
analysis: Roland Schmidt | @zoowalk | http://werk.statt.codes
Let’s start with the lowest impression category, ads with fewer than 1000 impressions (<1K). As the table shows, Gernot Blümel ran 718 ads in this category. This number represents 66.3% of all ads bought by his page. Remarkably though, despite this high number, these ads only contributed 5.2% of the total number of impressions generated by all his ads. This discrepancy is quite remarkable, however, the situation is not much different for the other two pages with the overall largest number of pages. The 77 ads of the page of the Wiener Grünen amounted to 21.0% of all their ads, but contributed as little as 0.3% of the total number of impressions. For the page of the Neue Wiener Volkspartei the 77 ads triggered only 2.2% of the overall impression.
Hence, the question - where does the larger chunks of impressions come from?
You can sort the columns by clicking on their headings. If you sort, the pertaining column of the Viennese Greens, you’ll see that 13.9% of their total impressions originated from as little as 3 ads in the category of <700K impressions, or 0.8% of its total number of ads. Another 12.1% come from from only 2 ads from the <900K category. Hence, in short, the total number of impressions seems rather concentrated on a few ads. As with the page of Gernot Blümel, the picture is somewhat similar. 21.0% of all impressions generated by his ads originate from only 13 ads in the <125K impressions category.
Another way to visualize the origin of the difference in impressions is the graph below. The x-axis shows the impression categories/intervals of ads in increasing order. The y-axis shows the total number of impressions if we accumulate the impressions over the different impression categories. The graph reveals that the Green’s strong impression numbers are largely due to their ads placed in the highest impression categories (larger than 400K impressions). Neither Blümel nor Nepp had placed any ads in these categories.
pl_impression <- df_pl_impression %>%arrange(impressions_category_num) %>%ggplot()+labs(title="WIEN-WAHL 2020: Total impressions by impression category",subtitle="Top 3 pages with most total impressions.",y="impressions, accumulated",x="impression category of ads",caption=my_caption)+geom_vline(xintercept=seq(0.5, length(df_pl_impression$impressions_category_fac)+.5),linetype="dotted",color="grey50")+geom_pointrange(aes(x=impressions_category_fac,y=cumsum.abs_impression_mid,ymin=cumsum.abs_impression_min,ymax=cumsum.abs_impression_max,color=party))+geom_line(aes(x=impressions_category_fac,y=cumsum.abs_impression_mid,group=page_name_lum,color=party))+geom_text(data=. %>%group_by(page_name_lum) %>%slice_tail(n=1) %>%ungroup() %>%filter(!str_detect(page_name_lum, "Grünen")),aes(x=impressions_category_fac,y=cumsum.abs_impression_mid,label=page_name_lum, color=party),hjust=0,nudge_x=1)+geom_text(data=. %>%group_by(page_name_lum) %>%slice_tail(n=1) %>%ungroup() %>%filter(str_detect(page_name_lum, "Grünen")),aes(x=impressions_category_fac,y=cumsum.abs_impression_mid,label=page_name_lum, color=party),hjust=1,fontface="bold",nudge_x=-1)+scale_y_continuous(labels=scales::label_comma(scale=10^-6,accuracy=1,suffix="m"),expand=expansion(mult=c(0,0)))+scale_x_discrete(label=function(x) paste0("<",x,"K"),guide=guide_axis(n.dodge =2),expand=expansion(ad=c(0.5, 3)))+scale_color_manual(values=vec_party_colors)+theme_post()+theme(axis.title.y=element_markdown(angle=90,hjust=1,color="grey50"),axis.text.x =element_markdown(color="grey50"),panel.grid.minor.x =element_line(linewidth=1),legend.position ="none")
6 Demography
6.1 Age
Now let’s turn to some of the demographic data provided by Facebook’s ad library API. One demographic property is age. For this we have to unnest again the pertaining field. A critical detail here is to calculate an ad’s audience share for every age group. E.g. if an ad has an audience share of 100 % in the age group of 25 to 34, we still want to have the observations for all the other age group’s (with the resulting values of 0 %). Otherwise, with these observations missing, the subsequent calculation of i.e. the median per each age group would be distorted. These observations are created with the complete and nesting function of the tidyr package.
Code: Unnest age list column
df_age <- df_ads_wide %>%select(name_party, party, page_name, ad_id, ad_creative_body, demographic_distribution_df, impressions_mid) %>%unnest(cols=c(demographic_distribution_df)) %>%mutate(percentage=as.numeric(percentage)) %>%group_by(party) %>%mutate(name_party_lum=fct_lump(name_party, n=3, other_level="all others")) %>%group_by(party, name_party_lum, page_name, ad_id, age, impressions_mid, .drop=T) %>%summarize(age_share=sum(percentage)) %>%#sum across all genders for one adungroup() %>%complete(age, nesting(ad_id, name_party_lum, party, page_name, impressions_mid), fill=list(age_share=0)) %>%mutate(party=fct_relevel(party, levels_party)) %>%group_by(age, name_party_lum) %>%mutate(median_share=median(age_share, na.rm=T)) %>%ungroup()#every adid should appear in each age segment; => each adid should have 7 occurances;check <- df_age %>%group_by(ad_id) %>%summarise(n=n())#summary(check$n)# levels_main_pages <- c("Michael Ludwig","Gernot Blümel","Birgit Hebein","Christoph Wiederkehr","Dominik Nepp","Heinz-Christian Strache / HC Strache","Die Bierpartei")vec_main_pages <-c("Gernot Blümel|Michael Ludwig|Birgit Hebein|Christoph Wiederkehr|Dominik Nepp|Heinz-Christian Strache / HC Strache|Die Bierpartei")df_age_stats <- df_age %>%filter(str_detect(page_name, regex(vec_main_pages))) %>%mutate(name_party_lum=fct_drop(name_party_lum)) %>%group_by(name_party_lum, age) %>%summarise(category_median=median(age_share, na.rm=T),category_mean=stats::weighted.mean(x=age_share, #weighted meanw=impressions_mid,na.rm=T),category_sd=sd(age_share, na.rm=T)) %>%group_by(age) %>%mutate(category_median_max=max(category_median, na.rm=T)) %>%mutate(category_median_max_indic=case_when(category_median_max == category_median ~"max",TRUE~as.character("not max"))) %>%ungroup()
As an illustration, the ad with the text
Drittes Coronapaket für Wiens Wirtschafthttps://bit.ly/32gDbzs Die Coronakrise hat die Wirtschaftstreibenden unserer Stadt und vor allem den Wiener Arbeitsmarkt vor massive Herausforderungen gestellt. Als Bürgermeister war mir eine schnelle und unbürokratische Hilfe für die Wiener UnternehmerInnen ein wichtiges Anliegen. Zwei Coronapakete für Wiens Wirtschaft haben wir bereits geschnürt - darin inkludiert waren unter anderem das Wiener Ausbildungspaket und die Joboffensive 50plus.
Nun schicken wir ein drittes Paket auf den Weg, das unter anderem folgende Maßnahmen enthält:
• 13 Mio. Euro und 1.000 zusätzliche Plätze für die Joboffensive 50+ • 1,3 Mio. Euro für das Pilotprojekt Lehrlingsverbund • 22 Mio. Euro Unterstützungspaket für Tourismus und Hotellerie • Winter-Schanigärten 2020/2021 • 14 Mio. Wachstumsinitiative für Digitalisierung, Klimaschutz und Standortbelebung
run by the page of Michael Ludwig has the following age distribution:
The graph below plots one dot for each age share for every ad. To better compare the audience shares between candidates, I added a boxplot to the graph. It provides us with a summary of the distribution of all ads’ audience shares for each candidate in each age group. The vertical line within the box is the median value. Hover with the mouse over one dot to get details on the ad and highlight the ad’s impression share in the other age categories.
Code: Plot age profile of ads
pl_demographics <- df_age %>%mutate(ad_id=str_replace_all(ad_id, "'"," ")) %>%filter(str_detect(page_name, regex(vec_main_pages))) %>%mutate(name_party_lum=fct_drop(name_party_lum)) %>%ggplot()+labs(title="WIEN-WAHL 2020: Age profile of Facebook ads' audience",subtitle=str_wrap(glue::glue("Each dot represents one ad's audience share in a specific age group. Each ad has one dot in every age group. Over all age groups, each ad has an audience of 100 %. Only top candidates. Ads placed between {date_observation_start_format} and {date_observation_end_format}."), 110),x="Share of age group in ad's overall audience",y=NULL,caption=my_caption)+#geomsgeom_jitter_interactive(aes(y=reorder_within(x=name_party_lum,by=median_share,within = age),x=age_share,tooltip=glue::glue("Ad id: {ad_id}, Age group share: {scales::percent(age_share)}"),data_id=ad_id),fill="transparent",color="grey50",alpha=0.9,height =0.1, size=.3,width =0.05)+geom_boxplot(aes(y=reorder_within(x=name_party_lum,by=median_share,within = age),x=age_share,#weight=impressions_mid,color=party),outlier.shape =NA,fill="transparent")+#headingsgeom_text(aes(y=length(unique(name_party_lum))+1,x=1.2),label="median", size=2.5,hjust=1,check_overlap = T) +geom_text(aes(y=length(unique(name_party_lum))+1,x=1.4),label="SD", size=2.5,hjust=1,check_overlap = T) +#median valuesgeom_text(data=df_age_stats,aes(x=1.2,y=reorder_within(x=name_party_lum,by=category_median,within = age ),label=c(category_median %>% scales::percent(.,suffix="",accuracy=0.1))),fontface=ifelse(df_age_stats$category_median_max_indic=="max","bold", "plain"),size=2.5,hjust=1)+#sd valuesgeom_text(data=df_age_stats,aes(x=1.4,y=reorder_within(x=name_party_lum,by=category_median,within = age ),label=c(category_sd %>% scales::percent(.,suffix="",accuracy=0.1))),hjust=1,size=2.5)+scale_y_discrete(labels=function(x) str_extract(x, regex(".*(?=\\(|___)")),expand=expansion(mult=c(0, 0.25)),drop=T)+scale_x_continuous(labels=scales::percent,expand=expansion(mult=c(0,0.2)),breaks=seq(0,1,.5))+scale_color_manual(values=vec_party_colors)+ lemon::facet_rep_wrap(vars(age),labeller=as_labeller(function(x) paste("age group:", x)),ncol=2,scales="free_y",repeat.tick.labels = T)+theme_post()+theme(legend.position ="none",axis.title.y =element_markdown(size=6, angle=90,color ="grey50",hjust =1),axis.text.x =element_text(size=7),axis.title.x =element_markdown(size=7, hjust=0),axis.text.y=element_text(size=9),panel.spacing.x =unit(0, units="cm"),panel.grid.major.y =element_blank())ggsave("pl_demographics.png",plot = pl_demographics,dpi=300,device="png")
I won’t go through all details, but to understand the graph let’s have a look at the age group 25-34. Ads placed by the Green’s main candidate Hebein have a median audience share of 41% in this age group. No other main candidate had such high median audience share in this or any other age group. On the other hand, if we look at higher/older age groups, we can see that Hebein’s medians were the lowest from the age 45 up. In contrast, Mayor Ludwig featured comparably high values in the older age groups. More generally, across age groups candidates have higher values in the age groups 25-54. Gernot Blümel has relatively evenly distributed audience shares. However, note that Blümel features in almost every age group a few (outlier) ads which have an audience share of 100% in the respective age group. These were ads which were particularly targeted at this specific age group.
6.2 Gender
Another demographic detail provided by Facebook’s ad library is the gender composition of an ad’s audience. Going back to our previous sample ad by Michael Ludwig, we obtain the following data:
Let’s aggregate these percentages to get subtotals for each gender.
# A tibble: 1 × 3
female male unknown
<dbl> <dbl> <dbl>
1 0.382 0.617 0.00118
Hence, in this specific case, 38% of the ad’s audience were women.
We can apply this analysis to all ads of an candidate and contrast the obtained average with those of other candidates. The results are presented in the graph below.
Each gray dot represents one ad’s audience share of the respective gender group. Again, the vertical line inside the boxplot indicates the median value of all ads’ audience shares of a candidate in one gender group.
# 3 observations for each ad (male/female/unknown)# check <- df_gender %>% # group_by(ad_id) %>% # summarise(n_ads=n())# summary(check)df_gender_stats <- df_gender %>%group_by(name_party_lum, page_name, gender) %>%summarise(gender_mean=mean(gender_sum, na.rm=T),gender_median=median(gender_sum, na.rm=T),gender_sd=sd(gender_sum, na.rm=T), ) %>%filter(str_detect(page_name, vec_main_pages)) %>%filter(gender!="unknown") pl_gender <- df_gender %>%filter(str_detect(page_name, vec_main_pages)) %>%filter(gender!="unknown") %>%ggplot()+labs(title="WIEN-WAHL 2020: Gender profile of Facebook ads' audience",subtitle=str_wrap(glue::glue("Each dot represents one ad's audience share in a specific gender group. Each ad has one dot in every gender group. Over all age groups, each ad has an audience of 100 %. Only top candidates. Ads placed between {date_observation_start_format} and {date_observation_end_format}. Category 'unknown' not shown."),110),x="Share of gender group in ad's overall audience",caption=my_caption)+geom_jitter_interactive(aes(y=gender,x=gender_sum,tooltip=glue::glue("Ad id: {ad_id}, Share: {scales::percent(gender_sum)}"),data_id=ad_id),color="grey50",alpha=0.9,height =0.2, size=.3,width =0.01)+geom_boxplot(aes(y=gender,x=gender_sum,color=party),outlier.shape =NA,fill="transparent")+#labelsgeom_text(aes(y=length(unique(gender))+1,x=1.2),label="median", size=2.5,hjust=1,check_overlap = T) +geom_text(aes(y=length(unique(gender))+1,x=1.3),label="SD", size=2.5,hjust=1,check_overlap = T) +#median valuesgeom_text(data=df_gender_stats,aes(x=1.2,y=gender,label=gender_mean %>% scales::percent(.,suffix="",accuracy=0.1)),# fontface=ifelse(df_age_stats$category_median_max_indic=="max",# "bold", "plain"),size=2.5,hjust=1)+#sd valuesgeom_text(data=df_gender_stats,aes(x=1.3,y=gender,label=gender_sd %>% scales::percent(.,suffix="",accuracy=0.1)),hjust=1,size=2.5)+#scalesscale_color_manual(values=vec_party_colors)+scale_x_continuous(labels=scales::label_percent())+scale_y_discrete(expand=expansion(add=c(0, 1.5)), drop=T)+facet_wrap(vars(name_party_lum),ncol=1)+theme_post()+theme(legend.position ="none",axis.text.y=element_text(size=9),panel.spacing.x =unit(0, units="cm"),panel.grid.major.y =element_blank(),axis.text.x =element_text(size=7),axis.title.x =element_markdown(size=7, hjust=0))ggsave("pl_gender.png", plot = pl_gender,dpi=300,device="png")
What we can see is that for all main candidates the median value of the male audience share is larger than that of the female audience share. The differences between the median values are particularly apart for Strache. But even for the Greens with their female candidate Hebein, ads were seen on average more often by men than by women.
This result made me wonder whether there were in fact any major Facebook pages during the campaign whose ads were seen on average by more women than men.
WIEN-WAHL 2020: Accounts which placed ads with a median female audience share of >50%
female audience share
Party
page
# ads
mean
median
SD
SPÖ
Wiener SPÖ-Frauen
16
100.0%
100.0%
0.0%
GREENS
Die Grünen Simmering
4
69.0%
76.1%
37.6%
GREENS
Die Grünen Hietzing
23
59.5%
58.3%
5.3%
GREENS
Die Grünen Landstraße
4
51.7%
57.7%
13.2%
GREENS
Die Grünen Döbling
10
54.3%
56.2%
5.8%
GREENS
Grüne Penzing
24
56.8%
56.2%
7.2%
SPÖ
SPÖ Alsergrund
10
51.0%
55.0%
10.2%
GREENS
Die Grünen Wien - Innere Stadt
16
54.7%
54.6%
11.7%
GREENS
Neubauer Grüne
10
58.4%
53.5%
20.6%
ÖVP
Ingrid Korosec
5
52.4%
52.3%
7.0%
GREENS
Die Grünen Josefstadt
6
54.7%
51.3%
8.9%
data: Facebook Ad Library API
analysis: Roland Schmidt | @zoowalk | http://werk.statt.codes
Indeed, as the table above shows there were indeed a few pages with an average female audience share larger than 50 %. What is noteworthy, these pages belong almost exclusively to the Greens. There were only 2 pages which belonged to the SPÖ, and 1 to the ÖVP. There was not a single page from the FPÖ, Strache and, more surprisingly, the Neos.
# A tibble: 3 × 2
party n
<fct> <int>
1 SPÖ 2
2 ÖVP 1
3 GREENS 8
7 Fin
So, again a blog post which got much longer than initially planned. If there’s anything you want to know more about, something that’s not clear, feel free to send me a DM via twitter.