Notes

This document describes the pre-processing and exploratory analysis of the data set that is the basis of article So haben die Schweizer Gemeinden in den letzten 40 Jahren gewählt published on srf.ch.

SRF Data attaches importance to the fact that the data pre-processing and analysis can be reproduced and checked. SRF Data believes in the principle of open data, but also open and comprehensible methods. On the other hand, it should be possible for third parties to build on this preparatory work and thus generate further evaluations or applications.

R-Script & processed data

The preprocessing and analysis of the data was conducted in the R project for statistical computing. The RMarkdown script used to generate this document and all the resulting data can be downloaded under this link. Through executing main.Rmd, the herein described process can be reproduced and this document can be generated. In the course of this, data from the folder input will be processed and results will be written to output.

SRF Data uses Timo Grossenbacher’s rddj-template as the basis for its R scripts. If you have problems executing this script, it may help to study the instructions from the rddj-template.

This report was generated on 2019-06-27 17:06:03. R version: 3.5.3 on x86_64-apple-darwin15.6.0. For this report, CRAN packages as of 2019-03-01 were used.

GitHub

The code for the herein described process can also be freely downloaded from https://github.com/srfdata/2019-06-party-history.

Other projects

Code and data by SRF Data are available on https://srfdata.github.io.

Disclaimer

The published information has been carefully compiled, but does not claim to be up-to-date, complete or correct. No liability is assumed for damages arising from the use of this script or the information drawn from it. This also applies to contents of third parties which are accessible via this offer.

Data description of output files

The following sections describe the results of the data preprocessing as stored in the output folder.

output/municipalities.csv

Contains metadata for all 2’240 municipalities as of April 2nd, 2017.

Attribute Type Description
id Numeric Official BFS-number
name String Official name of the municipality
name_de String Contains the “common” German translation if it exists
name_fr String Contains the “common” French translation if it exists
name_it String Contains the “common” Italian translation if it exists

output/parties.csv

Contains party classifications made by SRF Data with the help of political scientists, used throughout all projects related to elections.

Attribute Type Description
abbr String Abbreviations in D/F
group_id Numeric Unique ID for bigger parties and non-unique identifier for groups
bloc Enum Either “left”, “center” or “right” classifying the political views of this party
group_name String Description for groups (corresponding to group_id)

output/dominant_party.csv

Party strengths 1975-2015 with only the “dominant” party per year-municipality combination (dominating = the one with the highest support)

Attribute Type Description
year Numeric Election year
support Double Party strength in fraction of 1 (e.g. 0.2 = 20%)
municipality Numeric BFS ID of municipality in question
canton Numeric Cantonal abbreviation (two letters)
party Numeric Party or party grouping, referencing ID in output/parties.csv

output/national.csv

Party strengths 1971-2011 on the national level.

Note: Might equal to zero if party did not have any candidates that year.

Attribute Type Description
year Numeric Election year
party Numeric Party or party grouping, referencing ID in output/parties.csv
support Numeric Party strength in fraction of 1 (e.g. 0.2 = 20%)

output/by_municipality/municipality_{id}.csv

Party strengths 1971-2011 in municipality with BFS-number id.

Note: Only those year-party combinations are contained where the party actually had candidates.

Attribute Type Description
year Numeric Election year
party Numeric Party or party grouping, referencing ID in output/parties.csv
support Numeric Party strength in fraction of 1 (e.g. 0.2 = 20%)

output/by_party/party_{id}.csv

Party strengths 1971-2011 in all municipalities for party with id as defined in output/parties.csv.

Note: Only those year-municipality combinations are contained where the party actually had candidates.

Attribute Type Description
municipality Numeric Unique identifier, referencing ID in output/municipalities.csv
year Numeric Election year
party Numeric Party or party grouping, referencing ID in output/parties.csv
support Numeric Party strength in fraction of 1 (e.g. 0.2 = 20%)

Original data source

Party Strengths

Per Canton and national

We configured the following data cubes from the Federal Statistics Office and downloaded the result as a comma separated file without head:

  • input/px-x-1702020000_104.csv

From: https://www.pxweb.bfs.admin.ch/pxweb/de/px-x-1702020000_104/px-x-1702020000_104/px-x-1702020000_104.px/

Per District and Municipality
  • input/px-x-1702020000_105.csv

From: https://www.pxweb.bfs.admin.ch/pxweb/de/px-x-1702020000_105/-/px-x-1702020000_105.px

The following meta information was provided by the Statistics Office: Letzte Änderungen (12.01.2018): neuer Gemeindestand Erhebungsstichtag: Wahldatum Stand der Datenbank: 2015 Raumbezug: Gemeinden per 02.04.2017, inkl. Spezialgemeinden der politischen Statistik Erhebung: Statistik der Wahlen und Abstimmungen In verschiedenen Kantonen werden für gewisse Wahlen Spezialgemeinden /-bezirke («extraterritoriale» Gemeinden/Bezirke) ausgewiesen. Es handelt sich dabei vor allem um Auslandschweizer (Ausland-CH), die von mehreren Kantonen separat ausgewiesen werden und um (einzelne) Stimmen die vom jeweiligen Kanton keiner offiziellen Gemeinde zugeordnet wurden (Andere, Korrespondenzweg). Gemeinden mit so genannt «gemeinsamer Urne»: Dabei werden zwei oder mehrere Gemeinden zusammengefasst, deren Stimmzettel gemeinsam ausgezählt werden. 2009: Fusion von FDP und LPS auf nationaler Ebene unter der Bezeichnung «FDP.Die Liberalen». In der Waadt fusionierten FDP und LP im Jahr 2012, in Basel-Stadt haben FDP und LP nicht fusioniert. Da die LP-BS Mitglied der «FDP.Die Liberalen Schweiz» ist, wird sie zur FDP gezählt. Verwendete Zeichen: ‘…’ : Zahl unbekannt, weil (noch) nicht erhoben oder (noch) nicht berechnet, d.h. keine Kandidatur, stille Wahl oder Gemeinden mit gemeinsamen Urnen.

Per language region

-> input/su-d-17.02.02.03.02_SPR_SRF-Zehr.xlsx

We downloaded the table from the BFS website but realized that the values for 2015 were only temporary, so we asked poku@bfs.admin.ch for the updated data that we got per mail in mid June 2019.

It contained the following meta information: 1) Die Ergebnisse nach Sprachregion werden aufgrund der Gemeindeergebnisse bzw. der Zugehörigkeit der Gemeinden zu einer Sprachregion berechnet. Bis 2000 diente die Volkszählung als Grundlage der Zuteilung einer Gemeinde zu einem Sprachgebiet, danach wird die (kumulierte) Strukturerhebung verwendet. 2) 2009: Fusion von FDP und LPS auf nationaler Ebene unter der Bezeichnung “FDP.Die Liberalen”. Fusion von FDP und LP im Kanton Genf im Jahr 2010 und im Kanton Waadt im Jahr 2012. Im Kanton Basel-Stadt haben FDP und LP nicht fusioniert. Da die LP-BS Mitglied der „FDP.Die Liberalen Schweiz“ ist, wird die LP-BS auf gesamtschweizerischer Ebene der FDP zugeteilt.

Classification per language region

-> input/Raumgliederungen.xlsx

The classification of each municipality to a language region was extracted from the Application of Swiss Municipalities. The status of the data is the same as in the election data: 2nd of April 2017.

Geographical data

-> input/gd-b-00.03-875-gg17

The shapefiles for municipalities, cantons etc. we downloaded from Generalisierte Gemeindegrenzen. We worked with the files in the folder ggg_2017vz which contain the status of 31st of December 2017 (which is the same as it was on the 2nd of April).

Preparations

## [1] "package package:rmarkdown detached"
## Loading required package: knitr
## Loading required package: rstudioapi

Define packages

# from https://mran.revolutionanalytics.com/web/packages/checkpoint/vignettes/using-checkpoint-with-knitr.html
# if you don't need a package, remove it from here (commenting is probably not sufficient)
# tidyverse: see https://blog.rstudio.org/2016/09/15/tidyverse-1-0-0/
cat("
library(rstudioapi)
library(tidyverse) # ggplot2, dplyr, tidyr, readr, purrr, tibble
library(ggrepel) # repelling texts for ggplot
library(glue) # easier templating output
library(magrittr) # pipes
library(lintr) # code linting
library(sf) # mapping
library(rmarkdown) # needed for automated knitting",
file = "manifest.R")

Install packages

# if checkpoint is not yet installed, install it (for people using this
# system for the first time)
if (!require(checkpoint)) {
  if (!require(devtools)) {
    install.packages("devtools", repos = "http://cran.us.r-project.org")
    require(devtools)
  }
  devtools::install_github("RevolutionAnalytics/checkpoint",
                           ref = "v0.3.2", # could be adapted later,
                           # as of now (beginning of July 2017
                           # this is the current release on CRAN)
                           repos = "http://cran.us.r-project.org")
  require(checkpoint)
}
# nolint start
if (!dir.exists("~/.checkpoint")) {
  dir.create("~/.checkpoint")
}
# nolint end
# install packages for the specified CRAN snapshot date
checkpoint(snapshotDate = package_date,
           project = path_to_wd,
           verbose = T,
           scanForPackages = T,
           use.knitr = F,
           R.version = R_version)
rm(package_date)

Load packages

source("manifest.R")
unlink("manifest.R")
sessionInfo()
## R version 3.5.3 Patched (2019-03-11 r76221)
## Platform: x86_64-apple-darwin15.6.0 (64-bit)
## Running under: macOS Sierra 10.12.6
## 
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/3.5/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/3.5/Resources/lib/libRlapack.dylib
## 
## locale:
## [1] de_CH.UTF-8/de_CH.UTF-8/de_CH.UTF-8/C/de_CH.UTF-8/de_CH.UTF-8
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] rmarkdown_1.11   sf_0.7-3         lintr_1.0.3      magrittr_1.5    
##  [5] glue_1.3.0       ggrepel_0.8.0    forcats_0.4.0    stringr_1.4.0   
##  [9] dplyr_0.8.0.1    purrr_0.3.0      readr_1.3.1      tidyr_0.8.2     
## [13] tibble_2.0.1     ggplot2_3.1.0    tidyverse_1.2.1  checkpoint_0.4.0
## [17] rstudioapi_0.9.0 knitr_1.21      
## 
## loaded via a namespace (and not attached):
##  [1] tidyselect_0.2.5 xfun_0.5         haven_2.1.0      lattice_0.20-38 
##  [5] colorspace_1.4-0 generics_0.0.2   htmltools_0.3.6  yaml_2.2.0      
##  [9] rlang_0.3.1      e1071_1.7-0.1    pillar_1.3.1     withr_2.1.2     
## [13] DBI_1.0.0        modelr_0.1.4     readxl_1.3.0     plyr_1.8.4      
## [17] munsell_0.5.0    gtable_0.2.0     cellranger_1.1.0 rvest_0.3.2     
## [21] evaluate_0.13    rex_1.1.2        class_7.3-15     broom_0.5.1     
## [25] Rcpp_1.0.0       classInt_0.3-1   scales_1.0.0     backports_1.1.3 
## [29] jsonlite_1.6     hms_0.4.2        digest_0.6.18    stringi_1.3.1   
## [33] grid_3.5.3       cli_1.0.1        tools_3.5.3      lazyeval_0.2.1  
## [37] crayon_1.3.4     pkgconfig_2.0.2  xml2_1.2.0       lubridate_1.7.4 
## [41] assertthat_0.2.0 httr_1.4.0       R6_2.4.0         units_0.6-2     
## [45] nlme_3.1-137     compiler_3.5.3
# clean up
rm(detach_all_packages, path_to_wd, project_name, R_version, user_name)

Preprocessing

# prepare helper function that converts names to url-safe strings
slugify <- function(string) {
  string %>%
    tolower() %>%
    str_replace_all(c(
      "ü" = "ue", "[öœ]" = "oe", "[äæ]" = "ae", "ç" = "c", "[ôò]" = "o",
      "[éèêë]" = "e", "[àáâ]" = "a", "[îïì]" = "i", "[ùû]" = "u"
    )) %>%
    str_replace_all("[^\\w]+", "-") %>%
    str_replace_all("[^\\w]$", "")
}

Municipality data

# read election data
data <- read_csv(
  "input/px-x-1702020000_105.csv"
) %>%
  rename(
    municipality = 1,
    year = 2,
    party = 3,
    support = 4
  ) %>%
  # convert do number (decimals)
  mutate(support = ifelse(support == "...", NA, as.numeric(support) / 100)) %>%
  # keep municipalities only
  filter(str_detect(municipality, "^...... ")) %>%
  # remove dots
  mutate(municipality = str_replace(municipality, "^...... ", "")) %>%
  # remove (Gemeinsame Urne) from end of municipality names
  mutate(municipality = str_replace(municipality, " \\(Gemeinsame Urne\\)", ""))
## Parsed with column specification:
## cols(
##   `Bezirk (>>) / Gemeinde (......)` = col_character(),
##   Jahr = col_double(),
##   Partei = col_character(),
##   `Parteistärke in %` = col_character()
## )
## Warning in ifelse(support == "...", NA, as.numeric(support)/100): NAs durch
## Umwandlung erzeugt

In our data we have 11 election years (every four years from 1975 to 2015) for 2266 municipalities.

From the 24 parties that we encounter in the data set, we form the following groups:

# read party classification
parties <- read_csv("input/parties.csv")
## Parsed with column specification:
## cols(
##   abbr = col_character(),
##   group_id = col_double()
## )
# output
knitr::kable(
  parties %>%
    group_by(group_id) %>%
    summarise(abbr = glue_collapse(abbr, sep = ", "))
)
group_id abbr
1 FGA/AVF, PdA/PST, POCH, PSA, Sol.
2 SP/PS
3 GPS/PES
4 EVP/PEV
5 GLP/PVL
6 BDP/PBD
7 CSP/PCS, LdU/AdI, Sep./Sép., Übrige/Autres
8 CVP/PDC
9 FDP/PLR (PRD), LPS/PLS
10 Lega
11 SVP/UDC
12 EDU/UDF, FPS/PSL, MCR, Rep./Rép., SD/DS

Geodata

For some plots and also for the BFS IDs of the municipalities we read in the municipality boundaries that we got from the Federal Statistics Office at https://www.bfs.admin.ch/bfs/de/home/dienstleistungen/geostat/geodaten-bundesstatistik/administrative-grenzen/generalisierte-gemeindegrenzen.assetdetail.4342877.html.

municipality_geo <- read_sf(
  "input/gd-b-00.03-875-gg17/ggg_2017vz/shp/LV95/g2g17vz.shp",
  options = "ENCODING=ISO-8859-1"
)

# join bfs_id into municipality data if not done yet
if (!"bfs_id" %in% colnames(data)) {
  data %<>%
    left_join(
      municipality_geo %>%
        # remove geometry from join
        st_drop_geometry() %>%
        select(bfs_id = GMDNR, GMDNAME, KTNR),
      by = c("municipality" = "GMDNAME")
    ) %>%
    # replace the canton ids with canton names
    left_join(
      read_csv("input/canton_names.csv") %>%
        select(id, canton = abbr),
      by = c("KTNR" = "id")
    ) %>%
    select(-KTNR)
}
## Parsed with column specification:
## cols(
##   id = col_double(),
##   abbr = col_character(),
##   name = col_character(),
##   regi = col_character()
## )
# check if any municipalities still miss their id
knitr::kable(
  data %>%
    distinct(municipality, bfs_id) %>%
    filter(is.na(bfs_id))
)
municipality bfs_id
Niederösch NA
Oberösch NA
ZH-Ausland-CH NA
LU-Ausland-CH NA
UR-Ausland-CH NA
FR-CH de l’étranger NA
BS-Ausland-CH NA
AI-Ausland-CH NA
SG-Ausland-CH NA
AG-Ausland-CH NA
TG-Ausland-CH NA
VD-CH de l’étranger NA
VS-CH de l’étranger NA
GE-CH de l’étranger NA
AI-Korrespondenzweg NA
AI-Anderes NA
TI-voto per corrispondenza NA
TI-altri NA
ZH-Anderes NA
BE-Anderes NA
SZ-Anderes NA
SO-Anderes NA
GR-Anderes NA
VD-autres NA
VS-autres NA
GE-autres NA

The only entries thate are missing a bfs_id are now entries from places not in Switzerland. And Niederösch and Oberösch, but we’ll just ignore these as their values are NA anyway. (Since 2016 they belong to Ersigen). Per mail we got confirmed that these two entries should not be in the data at all.

Let’s check if there are municipalities on our map that are missing from the data:

knitr::kable(
  municipality_geo %>%
    anti_join(
      data,
      by = c("GMDNAME" = "municipality")
    ) %>%
    select(GMDNR, GMDNAME, BZNR, KTNR) %>%
    st_drop_geometry()
)

GMDNR GMDNAME BZNR KTNR —— ——– —– —–

That’s not the case, that’s good.

Data Analysis

First: let’s try to find a meaningful cut off for elections. Those cantons that only have 1 national council will yield some extreme results. To answer the questions below we’ll need to exclude them from some of the analysis.

Parties per canton

What’s the mean number of parties running for seats per canton?

# calculate number of parties per year and canton and election
parties_per_canton_and_year <- data %>%
  filter(!is.na(support) & !is.na(canton)) %>%
  group_by(canton, year, municipality) %>%
  tally(name = "n_parties") %>%
  select(-municipality) %>%
  slice(1) %>%
  ungroup()

# view min, max, mean, median
parties_per_canton <- parties_per_canton_and_year %>%
  group_by(canton) %>%
  summarise(
    min = min(n_parties),
    max = max(n_parties),
    median = median(n_parties),
    mean = mean(n_parties)
  ) %>%
  arrange(desc(mean)) %>%
  ungroup()

# output
knitr::kable(
  parties_per_canton
)
canton min max median mean
ZH 11 14 13.0 12.909091
JU 11 14 13.0 12.818182
BE 10 14 12.0 12.454546
VD 9 15 12.0 11.636364
BS 9 13 10.0 10.636364
AG 8 12 10.0 10.181818
GE 8 12 9.0 9.636364
BL 8 11 9.0 9.090909
TG 7 10 9.0 9.000000
SG 5 11 9.0 8.727273
FR 4 11 8.0 7.727273
TI 6 9 8.0 7.727273
LU 5 10 7.0 7.454546
NE 5 9 7.0 7.272727
SO 5 10 7.0 7.272727
GR 4 8 6.0 6.000000
VS 4 8 5.0 5.727273
SZ 3 8 5.0 5.272727
ZG 4 7 5.0 5.181818
SH 3 7 5.0 4.909091
AR 2 6 4.0 3.888889
UR 2 4 3.0 2.727273
NW 2 4 2.5 2.600000
OW 2 4 2.0 2.500000
GL 2 3 2.0 2.363636
AI 2 3 2.0 2.272727
top_20_cantons <- parties_per_canton %>%
  top_n(20, wt = median) %>%
  pull(canton)

If we would for example look at the 20 “biggest” cantons when looking at the numbers of parties that run, the followin 6 would drop out: AR, UR, NW, OW, GL, AI These are also the cantons that currently have only one seat in the national council. So this selection seems to make sense.

Classification: left / center / right

To analyze political shifts from left to right and vice-versa, we’ll classify the 12 parties into 3 simple blocs: left, center, right.

if (!"bloc" %in% colnames(parties)) {
  parties %<>%
    mutate(bloc = case_when(
      group_id <= 3 ~ "left",
      group_id <= 8 ~ "center",
      TRUE ~ "right"
    )) %>%
    # remove others from classifications
    mutate(bloc = ifelse(abbr == "Übrige/Autres", NA, bloc)) %>%
    # and also the separatists in BE/JU/BL
    mutate(bloc = ifelse(abbr == "Sep./Sép.", NA, bloc)) %>%
    mutate(bloc = factor(bloc, levels = c("left", "center", "right")))
}

# output
knitr::kable(
  parties %>%
    group_by(bloc) %>%
    summarise(parties = glue_collapse(abbr, ", "))
)
## Warning: Factor `bloc` contains implicit NA, consider using
## `forcats::fct_explicit_na`
bloc parties
left FGA/AVF, PdA/PST, POCH, PSA, Sol., SP/PS, GPS/PES
center EVP/PEV, GLP/PVL, BDP/PBD, CSP/PCS, LdU/AdI, CVP/PDC
right FDP/PLR (PRD), LPS/PLS, Lega, SVP/UDC, EDU/UDF, FPS/PSL, MCR, Rep./Rép., SD/DS
NA Sep./Sép., Übrige/Autres
From left to right

Now we can list the municipalities where left parties lost most and right parties won most. By subracting the support of the left bloc from the support of the right bloc we get negative values where the left is bigger than the right. This model is potentially oversimplifying the political system of Switzerland where the centrist parties have quite an important role.

Let’s look at two examples how the position would look like for a municipality: * Left: 15% and Right: 40% equals position = 0.25 * Left: 30% and Right: 25% equals position = -0.05

left_right_positioned_municipalities <- data %>%
  # filter out smallest cantons
  # temporarily disabled
  # filter(canton %in% top_20_cantons) %>%
  # join blocs
  left_join(
    parties %>%
      select(abbr, bloc),
    by = c("party" = "abbr")
  ) %>%
  # filter out others and those that vote in majoritarian style
  filter(!is.na(bloc) & canton %in% top_20_cantons) %>%
  group_by(municipality, canton, bfs_id, year, bloc) %>%
  summarise(support = sum(support, na.rm = TRUE)) %>%
  ungroup() %>%
  # disabled: keep only left and right
  # filter(bloc != "center") %>%
  spread(bloc, support) %>%
  mutate(position = right - left)

Top 10: Left and right

The most right (first 10) and most left (last 10) towns in the latest year:

# output, try to get a feeling for this classification
knitr::kable(
  bind_rows(
    left_right_positioned_municipalities %>%
      filter(year == max(year)) %>%
      top_n(10, wt = position) %>%
      arrange(desc(position)),
    # inser empty row for clearer distinction
    c("municipality" = "---"),
    left_right_positioned_municipalities %>%
      filter(year == max(year)) %>%
      top_n(-10, wt = position) %>%
      arrange(desc(position))
  )
)
municipality canton bfs_id year left center right position
Horrenbach-Buchen BE 932 2015 0.021 0.072 0.902 0.881
Riemenstalden SZ 1369 2015 0.027 0.125 0.849 0.822
Ederswiler JU 6713 2015 0.000 0.200 0.800 0.800
Alpthal SZ 1361 2015 0.034 0.136 0.830 0.796
Unteriberg SZ 1375 2015 0.040 0.144 0.815 0.775
Teuffenthal (BE) BE 940 2015 0.063 0.102 0.828 0.765
Schangnau BE 906 2015 0.046 0.136 0.805 0.759
St. Stephan BE 793 2015 0.062 0.102 0.820 0.758
Rossenges VD 5684 2015 0.099 0.051 0.849 0.750
Habkern BE 579 2015 0.067 0.103 0.816 0.749
NA NA NA NA NA NA NA
Lausanne VD 5586 2015 0.537 0.089 0.345 -0.192
Courchapoix JU 6706 2015 0.382 0.403 0.184 -0.198
Le Locle NE 6436 2015 0.577 0.048 0.354 -0.223
Fribourg FR 2196 2015 0.461 0.311 0.228 -0.233
La Chaux-de-Fonds NE 6421 2015 0.581 0.050 0.340 -0.241
Vellerat JU 6728 2015 0.522 0.227 0.239 -0.283
Delémont JU 6711 2015 0.507 0.261 0.216 -0.291
Bern BE 351 2015 0.531 0.217 0.233 -0.298
Lajoux (JU) JU 6750 2015 0.543 0.254 0.174 -0.369
Fontenais JU 6790 2015 0.557 0.261 0.180 -0.377

Let’s further look at how the pricipal towns evolved over time:

principal_towns <- read_csv("input/principal_towns.csv")
## Parsed with column specification:
## cols(
##   canton = col_character(),
##   value = col_double(),
##   name = col_character()
## )
ggplot(
  left_right_positioned_municipalities %>%
    filter(municipality %in% principal_towns$name),
  aes(
    x = year,
    y = position,
    color = municipality,
    label = municipality
  )
) +
  geom_hline(yintercept = 0, size = 0.2) +
  geom_line() +
  geom_text_repel(
    data = left_right_positioned_municipalities %>%
      filter(municipality %in% principal_towns$name) %>%
      filter(year == max(year)),
    direction = "y",
    nudge_x = 1,
    hjust = 0,
    segment.size = 0.2
  ) +
  xlim(min(data$year), max(data$year) + 4) +
  theme_minimal() +
  theme(legend.position = "none") +
  labs(
    title = "Principal Towns and their political position from right (top) to left (bottom)",
    subtitle = "The value on the y axis can be read as \"how much bigger is the right bloc as the left bloc.\"",
    x = NULL,
    y = NULL
  )

There are several observations which reveal the weaknesses of this system:

  • Geneva moved a lot to the “right” and is now apparently about as “left” as the city of St.Gallen (probably because of the MCG)
  • Basel is much less “left” than in earlier elections
  • Delémont has a huge spike to the “right” in 1979

Top 10: Swings

Never the less, let’s inspect which municipalities moved from left to right the strongest and vice-versa. At first let’s just look at the first and last election:

left_right_swings_municipalities <- left_right_positioned_municipalities %>%
  ungroup() %>%
  # filter out silent elections
  filter(right > 0 | left > 0) %>%
  # keep only first and last year
  filter(year == min(year) | year == max(year)) %>%
  # remove left and right numbers
  select(-left, -right, -center) %>%
  spread(year, position) %>%
  # replace years in column names with start and end
  rename(
    start = as.character(min(data$year)),
    end = as.character(max(data$year))
  ) %>%
  mutate(delta = end - start)

# output extremes (10 each)
knitr::kable(
  bind_rows(
    left_right_swings_municipalities %>%
      top_n(10, wt = delta) %>%
      arrange(desc(delta)),
    # inser empty row for clearer distinction
    c("municipality" = "---"),
    left_right_swings_municipalities %>%
      top_n(-10, wt = delta) %>%
      arrange(delta)
  )
)
municipality canton bfs_id start end delta
Vorderthal SZ 1348 -0.487 0.676 1.163
Schübelbach SZ 1346 -0.569 0.483 1.052
Dorénaz VS 6212 -0.510 0.517 1.027
Reichenburg SZ 1345 -0.521 0.487 1.008
Wangen (SZ) SZ 1349 -0.585 0.422 1.007
Galgenen SZ 1342 -0.454 0.549 1.003
Illgau SZ 1363 -0.222 0.626 0.848
Alpthal SZ 1361 -0.048 0.796 0.844
Riemenstalden SZ 1369 -0.017 0.822 0.839
Innerthal SZ 1343 -0.333 0.478 0.811
NA NA NA NA NA
Dizy VD 5481 0.937 0.034 -0.903
Lohn (GR) GR 3707 0.943 0.050 -0.893
Tschappina GR 3669 0.973 0.170 -0.803
Mathon GR 3708 0.924 0.159 -0.765
Safiental GR 3672 0.873 0.120 -0.753
Chavannes-le-Veyron VD 5475 0.874 0.129 -0.745
Kilchberg (BL) BL 2851 0.713 0.005 -0.708
Masein GR 3663 0.770 0.066 -0.704
Urmein GR 3670 0.990 0.292 -0.698
Schelten BE 708 0.844 0.180 -0.664

Not very surprisingly these municipalities are all pretty small - nothing unusual when you look at the most extreme changes.

Arrow Map

Let’s visualize the swing data on a map with arrows. Blue will be used for arrows leaning to the right, red for the ones leaning to the left.

# get country borders for quicker display of background
cantons_geo <- read_sf(
  "input/gd-b-00.03-875-gg17/ggg_2017/shp/LV95/g2k17.shp",
  options = "ENCODING=ISO-8859-1"
)

ggplot() +
  # show map of switzerland as a light grey background
  geom_sf(
   data = cantons_geo,
   aes(),
   fill = "#f2f2f2",
   color = "#FFFFFF"
  ) +
  # add one small line for every municipality at it's center
  geom_sf_text(
    data = municipality_geo %>%
      left_join(
        left_right_swings_municipalities,
        by = c("GMDNR" = "bfs_id")
      ) %>%
      # filter out irrelevant cantons
      filter(!is.na(delta)),
    aes(
      x = X_CNTR,
      y = Y_CNTR,
      angle = round(delta * -100),
      color = round(delta * -100)
    ),
    label = "↑",
    size = 6
  ) +
  scale_colour_gradient2(low = "#1a7ac5", mid = "#959595", high = "#e31f2b") +
  theme_void() +
  theme(
    panel.grid.major = element_line("transparent"),
    legend.position = "none"
  )

This approach is interesting, but it has some weaknesses:

  • The selection of the start and end year are pretty arbitrary. Depending on the years you take, you can get very different results.
  • 40 years is a huge timespan. Parties changed their positions during that time, this system does not show this.

That’s why we focus on other approaches.

Heartlands

Let’s try different approaches to find out where the parties have their heartlands. First: Where over all years parties got the most number of votes. (in percent values summed)

# TO DO: ADD COLORS
big_7 <- tibble(
  "2" = "SPS",
  "3" = "GPS",
  "5" = "GLP",
  "6" = "BDP",
  "8" = "CVP",
  "9" = "FDP",
  "11" = "SVP"
) %>% gather(id, abbr) %>%
  mutate(id = as.numeric(id))

# reduce to biggest 7 parties and add LPS to FDP
data_big_7 <- data %>%
  left_join(
    parties %>%
      select(abbr, group_id),
    by = c("party" = "abbr")
  ) %>%
  # remove non-municipalities (people abroad)
  filter(!is.na(bfs_id)) %>%
  # keep only big 7
  left_join(big_7, by = c("group_id" = "id")) %>%
  filter(!is.na(abbr)) %>%
  # remove old notation (where FDP and LPS were separate)
  select(-party) %>%
  # convert to factors
  mutate(
    group_id = factor(group_id, levels = big_7$id),
    abbr = factor(abbr, levels = big_7$abbr)
  ) %>%
  rename(party = abbr)

# get number of elections in data
n_elections <- data %>%
  distinct(year) %>%
  nrow()

support_per_municipality_and_party <- data_big_7 %>%
  # remove smallest 6 cantons
  filter(canton %in% top_20_cantons) %>%
  # calculate mean per party and municipality
  group_by(municipality, bfs_id, canton, party) %>%
  # instead of using mean, we'll divide ourselves by the number of
  # elections because otherwise if a party runs once, this value is used
  # which is not what we want, we want a real mean over all 40 years
  summarise(mean_support = sum(support, na.rm = TRUE) / n_elections) %>%
  # assign each municipality a rank per party
  group_by(party) %>%
  arrange(desc(mean_support)) %>%
  mutate(rank = row_number())

# output top 10 per party
knitr::kable(
  support_per_municipality_and_party %>%
    # get top 10 municipalities per party
    group_by(party) %>%
    top_n(10, wt = mean_support) %>%
    arrange(desc(mean_support)) %>%
    ungroup() %>%
    mutate(
      name_with_value = glue("{municipality} ({scales::percent(mean_support)})")
    ) %>%
    group_by(party) %>%
    summarise(top_10 = glue_collapse(name_with_value, ", "))
)
party top_10
SPS Schaffhausen (40.9%), Delémont (39.4%), Neuhausen am Rheinfall (39.1%), Obergerlafingen (38.7%), Lajoux (JU) (38.4%), Recherswil (38.0%), Roche (VD) (38.0%), Sils im Domleschg (37.5%), Gerlafingen (37.5%), Chavannes-près-Renens (37.1%)
GPS Nusshof (14.3%), Romainmôtier-Envy (13.4%), Sissach (13.3%), Fresens (12.7%), Tenniken (12.5%), Arlesheim (12.5%), Jouxtens-Mézery (12.4%), Oltingen (12.4%), Hersberg (12.3%), Ziefen (12.2%)
GLP Dübendorf (3.8%), Schwerzenbach (3.6%), Haldenstein (3.5%), Bonstetten (3.4%), Hettlingen (3.4%), Fällanden (3.2%), Greifensee (3.1%), Schleinikon (3.1%), Hirzel (3.0%), Madulain (3.0%)
BDP Hinterrhein (13.7%), Mathon (9.9%), Sufers (9.1%), Tschappina (8.8%), Urmein (8.0%), Donat (7.9%), Safiental (7.6%), Rüti bei Büren (7.6%), Ferrera (6.4%), Nufenen (6.4%), Zillis-Reischen (6.4%)
CVP Törbel (88.4%), Niedergesteln (85.0%), Eischoll (84.7%), Saas-Balen (83.5%), Blatten (83.4%), Wiler (Lötschen) (83.0%), Kippel (83.0%), Saas-Fee (82.6%), Eisten (82.5%), Zeneggen (82.5%)
FDP Yvorne (63.3%), Montalchez (63.3%), Arnex-sur-Nyon (62.1%), Brot-Plamboz (60.7%), Bourg-Saint-Pierre (60.1%), Vouvry (57.8%), Saint-Blaise (57.3%), Cologny (56.8%), La Côte-aux-Fées (56.8%), La Brévine (56.6%)
SVP Schangnau (86.0%), Eriz (82.5%), Berken (82.0%), Horrenbach-Buchen (79.6%), Rumendingen (79.0%), Wachseldorn (78.6%), Eggiwil (76.7%), Landiswil (76.6%), Trub (76.3%), Treiten (74.0%)

Let’s visualize 100 municipalities per party where they made (over the last 40 years) the highest support (average per election that they put up candidates).

The rank is used for the opacity. Some municipalities might share multiple colors for multiple parties:

party_colors <- c(
  "SPS" = "#F03E35",
  "GPS" = "#7BB535",
  "GLP" = "#C4C429",
  "BDP" = "#E6C409",
  "CVP" = "#D67C15",
  "FDP" = "#2669B5",
  "SVP" = "#3F8A30"
)

ggplot() +
  # show map of switzerland as a light grey background
  geom_sf(
    data = cantons_geo,
    aes(),
    fill = "#FCFCFC",
    color = "#666666",
    size = 0.1
  ) +
  geom_sf(
    data = municipality_geo %>%
      left_join(
        support_per_municipality_and_party %>%
          filter(rank <= 100),
        by = c("GMDNR" = "bfs_id")
      ) %>%
      filter(!is.na(party)),
    aes(
      fill = party,
      alpha = rank
    ),
    color = "white",
    size = 0.1
  ) +
  scale_fill_manual(values = party_colors) +
  scale_alpha_continuous(range = c(1, 0.2)) +
  theme_void() +
  theme(
    panel.grid.major = element_line("transparent"),
    legend.position = "none"
  )

Left / Right Heartlands

# create new data frame summing all 11 elections
left_right_muns_over_all_years <- left_right_positioned_municipalities %>%
  group_by(municipality, bfs_id, canton) %>%
  summarise(
    left = sum(left, na.rm = TRUE),
    center = sum(center, na.rm = TRUE),
    right = sum(right, na.rm = TRUE)
  ) %>%
  gather(bloc, support, left:right)

ggplot(
  left_right_muns_over_all_years %>%
    left_join(
      municipality_geo,
      by = c("bfs_id" = "GMDNR")
    ),
  aes(
    fill = support
  )
) +
  geom_sf(color = "transparent") +
  facet_wrap(~ bloc) +
  scale_fill_distiller(palette = "Spectral") +
  theme_void() +
  theme(
    panel.grid.major = element_line("transparent"),
    legend.position = "none"
  )

The above map shows where the three blocs had their strongest support over all 40 years (11 elections). Below you see one map where we only show the dominant bloc:

bloc_palette <- c(
  "left" = "#c54a52",
  "center" = "#feb24c",
  "right" = "#81acaa"
)

ggplot(
  left_right_muns_over_all_years %>%
    filter(support == max(support)) %>%
    left_join(
      municipality_geo,
      by = c("bfs_id" = "GMDNR")
    ),
  aes(
    fill = bloc
  )
) +
  geom_sf(color = "transparent") +
  scale_fill_manual(values = bloc_palette) +
  theme_void() +
  theme(
    panel.grid.major = element_line("transparent")
  ) +
  labs(
    title = "Left / Center / Right: which was the strongest over 11 elections?",
    fill = NULL
  )

Municipalities close to average

It could be interesting to see whether we can find a municipality which is closest to the mean of the country. Instead of calculating the mean ourselves, let’s get the values for Switzerland from the BFS and read it in. In the second data set there is an entry with canton = Schweiz. From there we get the values for the whole nation state.

# read cantonal and national data
cantonal_data <- read_csv(
  "input/px-x-1702020000_104.csv"
) %>%
  rename(
    canton = 1,
    year = 2,
    party = 3,
    support = 4
  ) %>%
  # convert do number (decimals)
  mutate(support = ifelse(support == "...", NA, as.numeric(support) / 100))
## Parsed with column specification:
## cols(
##   Kanton = col_character(),
##   Jahr = col_double(),
##   Partei = col_character(),
##   `Parteistärke in %` = col_character()
## )
## Warning in ifelse(support == "...", NA, as.numeric(support)/100): NAs durch
## Umwandlung erzeugt
national_data <- cantonal_data %>%
  # keep only the sum of all of Switzerland
  filter(canton == "Schweiz") %>%
  select(-canton)

# country from cantonal data
cantonal_data %<>%
  filter(canton != "Schweiz")

# read values per language region provided by bfs
language_data <- readxl::read_excel(
  "input/su-d-17.02.02.03.02_SPR_SRF-Zehr.xlsx",
  range = "A4:M69"
) %>%
  rename(party = 1) %>%
  # remove `=> FDP` from the last two columns and convert all but party to num
  mutate_at(vars(-one_of("party")), as.numeric) %>%
  mutate(
    # pull out language region into separate column
    lang = str_extract(party, "(\\w+)sprachige Schweiz"),
    # and abbreviate to D, F, I
    lang = factor(str_sub(lang, 0, 1))
  ) %>%
  # fill up values downwards for all parties
  fill(lang) %>%
  # and remove the original column of the language region
  filter(!str_detect(party, "(\\w+)sprachige Schweiz")) %>%
  # replace with party names equal to the other data
  mutate(party = str_replace_all(party, c(
    "^BDP$" = "BDP/PBD",
    "^CSP$" = "CSP/PCS",
    "^CVP$" = "CVP/PDC",
    "^EDU$" = "EDU/UDF",
    "^EVP$" = "EVP/PEV",
    "^FDP 2\\)$" = "FDP/PLR (PRD)",
    "^FGA$" = "FGA/AVF",
    "^FPS$" = "FPS/PSL",
    "^GLP$" = "GLP/PVL",
    "^GPS$" = "GPS/PES",
    "^LdU$" = "LdU/AdI",
    "^LPS 2\\)$" = "LPS/PLS",
    "^PdA$" = "PdA/PST",
    "^POCH$" = "POCH",
    "^Rep\\.$" = "Rep./Rép.",
    "^SD$" = "SD/DS",
    "^SP$" = "SP/PS",
    "^SVP$" = "SVP/UDC",
    "^Übrige$" = "Übrige/Autres"
  ))) %>%
  # make tidy
  gather(year, support, -one_of(c("party", "lang"))) %>%
  mutate(
    # remove footnote and convert to numeric
    year = as.numeric(str_sub(year, 0, 4)),
    # and make decimal number out of support
    support = support / 100
  )
## Warning: NAs durch Umwandlung erzeugt
## Warning: NAs durch Umwandlung erzeugt
# calculate distance between each municipality and the nation state
data_with_deltas <- data %>%
  # filter out non-municipalities (Swiss livign abroad)
  filter(!is.na(bfs_id)) %>%
  left_join(
    national_data %>%
      rename(support_national = support),
    by = c("year", "party")
  ) %>%
  # add language region from Raumgliederungen
  left_join(
    readxl::read_excel(
      "input/Raumgliederungen.xlsx",
      skip = 3,
      col_names = FALSE
      ) %>%
      select(bfs_id = 1, lang = 6),
    by = "bfs_id"
  ) %>%
  mutate(lang = case_when(
    lang == 2 ~ "F",
    lang == 3 ~ "I",
    TRUE ~ "D" # 1 = German, 4 = Rhaeto-roman
  )) %>%
  # join language region data from above
  left_join(
    language_data %>%
      rename(support_lang = support),
    by = c("year", "party", "lang")
  ) %>%
  # calculate absolute distances
  mutate(
    delta_national = abs(support - support_national),
    delta_lang = abs(support - support_lang)
  )
## New names:
## * `` -> `..1`
## * `` -> `..2`
## * `` -> `..3`
## * `` -> `..4`
## * `` -> `..5`
## * … and 1 more
## Warning: Column `lang` joining character vector and factor, coercing into
## character vector
knitr::kable(
  data_with_deltas %>%
    group_by(municipality, bfs_id, canton) %>%
    summarise(mean_delta = mean(delta_national, na.rm = TRUE)) %>%
    # filter out zeros that get introduced by shared urns
    filter(mean_delta > 0) %>%
    arrange(mean_delta) %>%
    head(20)
)
municipality bfs_id canton mean_delta
Frauenfeld 4566 TG 0.0202465
Obersiggenthal 4038 AG 0.0205429
Langnau am Albis 136 ZH 0.0216070
Gebenstorf 4029 AG 0.0223357
Brugg 4095 AG 0.0223839
Stein (AG) 4260 AG 0.0229214
Richterswil 138 ZH 0.0230803
Klingnau 4309 AG 0.0232500
Horgen 133 ZH 0.0233042
Bad Zurzach 4323 AG 0.0233786
Oberengstringen 245 ZH 0.0233887
Baden 4021 AG 0.0235839
Adliswil 131 ZH 0.0235845
Riniken 4111 AG 0.0238875
Untersiggenthal 4044 AG 0.0239036
Flurlingen 29 ZH 0.0239042
Wädenswil 142 ZH 0.0240831
Steckborn 4864 TG 0.0242444
Thalwil 141 ZH 0.0243831
Reinach (BL) 2773 BL 0.0250250

Above you see the municipalities which are closest to the national average. Below you find 5 municipalities per language region that are closest to it’s (langauge region) average:

knitr::kable(
  data_with_deltas %>%
    group_by(municipality, bfs_id, canton, lang) %>%
    summarise(mean_delta = mean(delta_lang, na.rm = TRUE)) %>%
    # filter out zeros that get introduced by shared urns
    filter(mean_delta > 0) %>%
    group_by(lang) %>%
    top_n(-5, wt = mean_delta) %>%
    arrange(lang, mean_delta)
)
municipality bfs_id canton lang mean_delta
Richterswil 138 ZH D 0.0184225
Langnau am Albis 136 ZH D 0.0184648
Wädenswil 142 ZH D 0.0187746
Gebenstorf 4029 AG D 0.0188750
Frauenfeld 4566 TG D 0.0189899
Echallens 5518 VD F 0.0214567
Montreux 5886 VD F 0.0261654
Crissier 5583 VD F 0.0264252
Cheseaux-sur-Lausanne 5582 VD F 0.0272598
La Tour-de-Peilz 5889 VD F 0.0280472
Gambarogno 5398 TI I 0.0152824
Locarno 5113 TI I 0.0156588
Caslano 5171 TI I 0.0163647
Bioggio 5151 TI I 0.0171765
Maggia 5317 TI I 0.0173412
Cantons close to average

We can apply the same logic to cantons

cantons_with_deltas <- cantonal_data %>%
  left_join(
    national_data %>%
      rename(support_national = support),
    by = c("year", "party")
  ) %>%
  # calculate absolute distances
  mutate(
    delta_national = abs(support - support_national)
  )

knitr::kable(
  cantons_with_deltas %>%
    group_by(canton) %>%
    summarise(mean_delta = mean(delta_national, na.rm = TRUE)) %>%
    arrange(mean_delta)
)
canton mean_delta
Aargau 0.0209866
Zürich 0.0248528
Basel-Landschaft 0.0279490
Solothurn 0.0328662
Thurgau 0.0355525
Bern / Berne 0.0374723
St. Gallen 0.0386990
Vaud 0.0414795
Graubünden / Grigioni / Grischun 0.0499606
Basel-Stadt 0.0503379
Fribourg / Freiburg 0.0509788
Genève 0.0548774
Luzern 0.0593098
Zug 0.0627684
Neuchâtel 0.0645263
Schwyz 0.0647017
Ticino 0.0792612
Schaffhausen 0.0829315
Valais / Wallis 0.0924079
Jura 0.1058978
Appenzell Ausserrhoden 0.1193371
Uri 0.2453367
Nidwalden 0.2722346
Obwalden 0.2779680
Glarus 0.3173654
Appenzell Innerrhoden 0.3355120

Export

National data

As an integrity check we sum up all the values per year and check whether it is close to one:

# regroup by party id (collect small parties and FDP/LPS into one group)
national_export <- national_data %>%
  # join and group by id
  left_join(
    parties,
    by = c("party" = "abbr")
  ) %>%
  group_by(year, party = group_id) %>%
  summarise(support = sum(support, na.rm = TRUE)) %>%
  ungroup()

knitr::kable(
  national_export %>%
    mutate(support = round(support, 3)) %>%
    group_by(year) %>%
    summarise(support = sum(support))
)
year support
1975 1.001
1979 0.999
1983 0.998
1987 0.999
1991 1.000
1995 1.000
1999 1.000
2003 0.998
2007 1.000
2011 1.000
2015 1.000

This seems to be the case.

write_csv(
  national_export %>%
    mutate(support = round(support, 3)),
  "output/national.csv"
)

Dominant Party

# suppress warnings thrown by max function:
# no non-missing arguments to max; returning -Inf
# because max() sometimes returns integers, sometimes doubles
# https://github.com/tidyverse/dplyr/issues/489

dominant_party <- data %>%
  group_by(municipality, year) %>%
  filter(support == max(support, na.rm = TRUE)) %>%
  # make sure it's not two parties with the same values left
  slice(1) %>%
  ungroup() %>%
  # join and group by id
  left_join(
    parties,
    by = c("party" = "abbr")
  )

dominant_party_export <- dominant_party %>%
  # replace party with group id in export
  select(-party, party = group_id) %>%
  # and municipality with municipality id
  select(-municipality, municipality = bfs_id, -bloc) %>%
  mutate(support = round(support, 3))

write_csv(
  dominant_party_export,
  "output/dominant_party.csv"
)

By party

data_parties_grouped <- data %>%
  # join and group by id
  left_join(
    parties,
    by = c("party" = "abbr")
  ) %>%
  group_by(year, bfs_id, party = group_id) %>%
  summarise(support = sum(support, na.rm = TRUE)) %>%
  ungroup()

# iterate over parties and export one file per party
data_parties_grouped %>%
  distinct(party) %>%
  pwalk(function(...) {
    # save all arguments (columns of current iteration) into variable
    current <- tibble(...)
  
    # filter and keep only entries with current party
    export_selection <- data_parties_grouped %>%
      filter(party == current$party) %>%
      # rename bfs_id to municipality
      rename(municipality = bfs_id) %>%
      mutate(support = round(support, 3))
  
    # save as csv
    write_csv(
      export_selection,
      glue("output/by_party/party_{current$party}.csv")
    )

  })

By municipality

municipality_geo %>%
  st_drop_geometry() %>%
  pwalk(function(...) {
    # save all arguments (columns of current iteration) into variable
    current <- tibble(...)
    
    # filter and keep only entries with current municipality
    export_selection <- data %>%
      filter(bfs_id == current$GMDNR) %>%
      select(-bfs_id) %>%
      # join and group by id
      left_join(
        parties,
        by = c("party" = "abbr")
      ) %>%
      group_by(year, party = group_id) %>%
      summarise(support = sum(support, na.rm = TRUE)) %>%
      ungroup() %>%
      mutate(support = round(support, 3))
    
    if (nrow(export_selection) > 0) {
      # save as csv
      write_csv(
        export_selection,
        glue("output/by_municipality/municipality_{current$GMDNR}.csv")
      )
    }
  })

List of Municipalities

export_municipalities <- municipality_geo %>%
  st_drop_geometry() %>%
  select(id = GMDNR, name = GMDNAME)

write_csv(
  export_municipalities %>%
    # join translations of municipalities
    # they might be useful for someone
    left_join(
      read_csv("input/municipality_translations.csv"),
      by = c("name" = "original_name")
    ),
  "output/municipalities.csv",
  na = ""
)
## Parsed with column specification:
## cols(
##   original_name = col_character(),
##   name_de = col_character(),
##   name_fr = col_character(),
##   name_it = col_character()
## )
# clean up
rm(export_municipalities)

List of Parties

write_csv(
  parties %>%
    mutate(group_name = case_when(
      group_id == 1 ~ "Small left",
      group_id == 7 ~ "others",
      group_id == 12 ~ "Small right",
      TRUE ~ ""
    )),
  "output/parties.csv"
)