Les résultats du deuxième tour de l'élection législative 2017 : description et modélisation

Ce billet a été écrit à l'aide d'un notebook Jupyter. Son contenu est sous licence BSD. Une vue statique de ce notebook peut être consultée et téléchargée ici : 20170625_Legislatives.ipynb.

Dans ce billet, écrit après les résultats du deuxième tour des élections législatives 2017, nous allons faire les choses suivantes. Tout d'abord, nous allons produire quelques graphiques relatifs aux résultats du deuxième tour, dans une approche descriptive. Dans un deuxième temps, nous évaluerons plusieurs modèles de report des voix pour voir si l'utilisation de modèles de comportement apporte une compréhension des résultats réels ou pas.

Téléchargement des données depuis le site du ministère de l'intérieur

Pour commencer notre étude, nous allons nous appuyer sur les routines du billet précédent.

In [1]:
# import des packages 
from bs4 import BeautifulSoup
import requests
%matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn')
import pandas as pd
from collections import OrderedDict
from scipy import stats
import numpy as np

Tout d'abord, nous extrayons les liens vers les circonscriptions par département.

In [2]:
url = 'http://elections.interieur.gouv.fr/legislatives-2017/'
soup = BeautifulSoup(requests.get(url).text, 'html.parser')
links = [url + tag.attrs['href'][2:] for tag in soup.find_all('a', class_='Style6')]
links += list(set([url + tag.attrs['href'] for tag in soup.find_all('area') if 'href' in tag.attrs]))
In [3]:
len(links)
Out[3]:
107

A partir de ces liens, nous allons chercher les liens vers les pages individuelles des circonscriptions.

In [4]:
from functools import lru_cache
In [5]:
@lru_cache(maxsize=None)
def fetch_page(url):
    "Fetches url for webpage."
    r = requests.get(url)
    return r
In [6]:
circo_links = []
for link in links:
    soup = BeautifulSoup(fetch_page(link).text, 'html.parser')
    circo_links += [url + tag.attrs['href'][3:] for tag in soup.find_all('a') if 'circonscription' in tag.text.lower()]
In [7]:
len(circo_links)
Out[7]:
577

On peut maintenant extraire les informations à partir de chaque circonscription. On trouve des tables pour le premier ainsi que pour le deuxième tour sur chaque page de circonscription. Sauf pour les circonscriptions où il n'y avait pas de deuxième tour. Ceci complique légèrement les extractions.

In [8]:
link = circo_links[1]
soup = BeautifulSoup(fetch_page(link).text, 'html.parser')
In [9]:
link
Out[9]:
'http://elections.interieur.gouv.fr/legislatives-2017/066/06601.html'
In [10]:
len(soup.find_all('h3'))
Out[10]:
3
In [11]:
def extract_circo_data(url):
    "Returns data extracted from url: name of circonscription, candidates_turn1, stats_turn1."
    r = fetch_page(url)
    soup = BeautifulSoup(r.text, 'html.parser')
    circo_name = soup.find('h3').text.replace('\n', '').replace('\t', '').split(' circonscription')[0]
    
    h3_links = soup.find_all('h3')
    assert len(h3_links) in [2, 3]
    if len(h3_links) >= 2:
        table = soup.find_all('tbody')[0]
        candidates_turn1 = OrderedDict()
        for row in table.find_all('tr'):
            candidates_turn1[row.td.text] = []
            for td in row.find_all('td')[1:]:
                stripped = td.text.strip().replace(',', '.').replace(' ', '')
                candidates_turn1[row.td.text].append(stripped)
        candidates_turn1 = pd.DataFrame(candidates_turn1).transpose()
        candidates_turn1.columns = ['nuance', 'voix', '% Inscrits', '% Exprimés', 'Elu(e)'] 
        table = soup.find_all('tbody')[1]
        stats_turn1 = OrderedDict()
        for row in table.find_all('tr'):
            stats_turn1[row.td.text] = int(row.td.next_sibling.next_sibling.text.replace(' ', ''))
        stats_turn1 = pd.Series(stats_turn1).to_frame()
        stats_turn1.columns = [circo_name]
    if len(h3_links) == 2:
        return circo_name, candidates_turn1, stats_turn1
    if len(h3_links) == 3:
        table = soup.find_all('tbody')[2]
        candidates_turn2 = OrderedDict()
        for row in table.find_all('tr'):
            candidates_turn2[row.td.text] = []
            for td in row.find_all('td')[1:]:
                stripped = td.text.strip().replace(',', '.').replace(' ', '')
                candidates_turn2[row.td.text].append(stripped)
        candidates_turn2 = pd.DataFrame(candidates_turn2).transpose()
        candidates_turn2.columns = ['nuance', 'voix', '% Inscrits', '% Exprimés', 'Elu(e)']
        table = soup.find_all('tbody')[3]
        stats_turn2 = OrderedDict()
        for row in table.find_all('tr'):
            stats_turn2[row.td.text] = int(row.td.next_sibling.next_sibling.text.replace(' ', ''))
        stats_turn2 = pd.Series(stats_turn2).to_frame()
        stats_turn2.columns = [circo_name]
    # reorder vars
    candidates_turn1, candidates_turn2 = candidates_turn2, candidates_turn1
    stats_turn1, stats_turn2 = stats_turn2, stats_turn1
    return (circo_name, candidates_turn1, stats_turn1, 
                        candidates_turn2, stats_turn2)

On peut vérifier que la fonction retourne les bons résultats pour deux circonscriptions. L'une, Wallis et Futuna, avec seulement un premier tour et l'autre, la 1ère circonscription de l'Essonne, avec deux tours.

In [12]:
extract_circo_data(circo_links[0])
Out[12]:
('Wallis et Futuna (986) - 1ère',
                        nuance  voix % Inscrits % Exprimés Elu(e)
 M. Napole POLUTELE        DVG  3436      40.52      50.24    Oui
 M. Sylvain BRIAL          DVG  3159      37.25      46.19    Non
 M. Hervé Michel DELORD     LR   244       2.88       3.57    Non,
              Wallis et Futuna (986) - 1ère
 Inscrits                              8480
 Abstentions                           1588
 Votants                               6892
 Blancs                                  31
 Nuls                                    22
 Exprimés                              6839)
In [13]:
extract_circo_data(circo_links[1])
Out[13]:
('Pyrénées-Orientales (66) - 1ère',
                        nuance   voix % Inscrits % Exprimés      Elu(e)
 M. Romain GRAU            REM  10354      14.59      31.75  Ballotage*
 M. Alexandre BOLO          FN   6606       9.31      20.26  Ballotage*
 M. Daniel MACH             LR   6312       8.89      19.36         Non
 M. Alain MIH               FI   3771       5.31      11.56         Non
 M. Jean CODOGNÈS          DVG   1867       2.63       5.73         Non
 Mme Françoise FITER       COM   1402       1.98       4.30         Non
 Mme Fabienne MEYER        REG    957       1.35       2.93         Non
 M. Philippe SYMPHORIEN    DLF    398       0.56       1.22         Non
 M. Emmanuel COUSTY        DIV    373       0.53       1.14         Non
 Mme Pascale ADVENARD      EXG    246       0.35       0.75         Non
 M. Nicolas PEREZ          DIV    181       0.26       0.56         Non
 M. Lionel MONACO          DVG     74       0.10       0.23         Non
 M. Anthony RHIGHI         DIV     68       0.10       0.21         Non,
              Pyrénées-Orientales (66) - 1ère
 Inscrits                               70970
 Abstentions                            37600
 Votants                                33370
 Blancs                                   487
 Nuls                                     274
 Exprimés                               32609,
                   nuance   voix % Inscrits % Exprimés Elu(e)
 M. Romain GRAU       REM  14720      20.74      57.21    Oui
 M. Alexandre BOLO     FN  11008      15.51      42.79    Non,
              Pyrénées-Orientales (66) - 1ère
 Inscrits                               70972
 Abstentions                            42116
 Votants                                28856
 Blancs                                  2127
 Nuls                                    1001
 Exprimés                               25728)

Maintenant que ces fonctions sont en place, on peut commencer à travailler.

Le deuxième tour en graphiques

Abstention

Faisons un graphique de l'abstention entre premier et deuxième tour dans les circonscriptions qui ont voté deux fois.

In [14]:
all_stats = []
for link in circo_links:
    data = extract_circo_data(link)
    if len(data) == 5:
        circo_name, candidates_turn1, stats_turn1, candidates_turn2, stats_turn2 = data
        all_stats.append((stats_turn1, stats_turn2))
In [15]:
len(all_stats)
Out[15]:
573

Prenons un exemple pour l'analyse des données : la première circonscription dans la liste de celles trouvées.

In [16]:
stats_turn1, stats_turn2 = all_stats[0][0].copy(), all_stats[0][1].copy()
stats_turn1.columns = [stats_turn1.columns[0] + ' - 1er tour']
stats_turn2.columns = [stats_turn2.columns[0] + ' - 2eme tour']
In [17]:
pd.concat((stats_turn1, stats_turn2), axis=1)
Out[17]:
Pyrénées-Orientales (66) - 1ère - 1er tour Pyrénées-Orientales (66) - 1ère - 2eme tour
Inscrits 70970 70972
Abstentions 37600 42116
Votants 33370 28856
Blancs 487 2127
Nuls 274 1001
Exprimés 32609 25728
In [18]:
pd.concat((stats_turn1, stats_turn2), axis=1).plot.bar()
Out[18]:
<matplotlib.axes._subplots.AxesSubplot at 0x1134a7be0>

Comme on peut le voir sur le graphique, les électeurs se sont démobilisés pour le deuxième tour. Au lieu de regarder deux chiffres, on peut également s'intéresser à la différence du nombre de voix pour chaque catégorie.

In [19]:
pd.DataFrame(data=stats_turn2.values - stats_turn1.values, index=stats_turn1.index, columns=all_stats[0][0].columns)
Out[19]:
Pyrénées-Orientales (66) - 1ère
Inscrits 2
Abstentions 4516
Votants -4514
Blancs 1640
Nuls 727
Exprimés -6881
In [20]:
pd.DataFrame(data=stats_turn2.values - stats_turn1.values, index=stats_turn1.index, columns=all_stats[0][0].columns).plot.bar()
Out[20]:
<matplotlib.axes._subplots.AxesSubplot at 0x1134ced68>

Représentons maintenant les différences en terme d'abstention pour toutes les circonscriptions ayant votées deux fois.

In [21]:
deltas = []
for stats in all_stats:
    stats_turn1, stats_turn2 = stats
    delta = pd.DataFrame(data=stats_turn2.values - stats_turn1.values, index=stats_turn1.index, columns=stats_turn1.columns)
    deltas.append(delta.transpose()['Abstentions'])
abstention = pd.concat(deltas)
In [22]:
fig, ax = plt.subplots(figsize=(8, 75), dpi=100)
abstention.plot.barh(stacked=True, ax=ax, width=1.0)
plt.title("différence d'abstention entre second et premier tour")
Out[22]:
<matplotlib.text.Text at 0x112337b00>