Elections présidentielles 2022: estimation du transfert de voix
tl;dr : On essaie ici de deviner le transfert des voix entre les choix effectués entre deux scrutins d'un vote (ici les élections présidentielles 2022 en France) par une méthode d'apprentissage automatique.
Afin d'analyser les résultats des élections, par exemple les dernières élections présidentielles de 2022 en France, et de mieux comprendre la dynamique des choix de vote entre les différents groupes de population, il peut être utile d'utiliser des outils d'apprentissage automatique pour inférer des structures à première vue cachées dans la masse des données. En particulier, inspiré par cet article du Monde, on peut se poser la question de savoir si on peut extraire depuis les données brutes des élections une estimation du report de voix entre les choix de vote au premier tour et ceux qui sont effectués au deuxième tour.
Pour cela, parmi les outils mathématiques de l'apprentissage automatique, nous allons utiliser des probabilités. Cette théorie va nous permettre d'exprimer le fait que les résultats tels qu'ils sont obtenus peuvent présenter une variabilité mais que celle-ci réelle résulte de préférences de chaque individu dans la population votante. En particulier, on peut considérer que chaque individu va avoir une préférence, graduée entre $0=0\%$ et $1=100\%$ pour chacun des choix (candidats, nul, blanc, abstention) au premier et second tour. Ainsi, les votes effectués vont correspondre à la réalisation de ces préférences.
Bien sûr, le vote reste secret et on n'a pas accès au vote de chaque individu et encore moins à ses préférences. Mais comme chaque bureau de vote présente des variabilités liées au contexte local et qui fait que cette population locale a une préférence pour certains choix plutôt que d'autres, on peut considérer chaque bureau de vote comme une population individuelle pour lequel nous allons essayer de prédire les résultats du vote au deuxième tour. En exploitant les irrégularités locales, bureau de vote par bureau de vote, nous allons pouvoir prédire (le mieux possible) le report des votes individuel (de chaque individu tel qu'il passe d'un vote à un autre, par exemple de "Hidalgo" à "Macron"). Nous allons en particulier montrer une prédiction très correcte des données du second tour à partir de ceux du premier, montrant la plausibilité d'une telle hypothèse :
C'est à ma connaissance une contribution originale (jusqu'à ce qu'une bonne âme veuille bien me donner un lien vers une méthode existante similaire qui me permette de correctement la citer...) que nous allons exploiter ici. Cette prédiction, si elle est efficace (et on va montrer qu'elle est en moyenne correctement prédite avec moins de 2 points de pourcentages d'erreur près), peut donner une idée du transfert de vote entre les deux tours qui a lieu en fonction des préférences des votes de chaque individu.
Nous allons dans la suite montrer comment on peut estimer le pourcentage de chances d'exprimer une voix pour un candidat ou pour l'autre en fonction du choix qu'on a exprimé au premier tour:
Comme on le verra plus bas, ce tableau montre des tendances claires, par exemple que si on a voté "Macron", "Jadot", "Hidalgo" ou "Pécresse" au premier tour, alors on va certainement voter "Macron" au deuxième tour. Ces électeurs se montrent particulièrement consensuel et suivent le « pacte républicain » mise en place pour faire un "barrage" au Front National (en suivant le terme consacré). Il montre aussi que si on a voté "Le Pen" ou "Dupont-Aignan" au premier tour alors on va voter Le Pen au deuxième, un clair vote de suivi.
Connaissant les couleurs politiques d'autres candidats du premier tour, on peut être surpris que les électeurs de "Arthaud", "Roussel", "Lassalle" ou "Poutou" ont majoritairement choisi "Le Pen" au deuxième tour, signifiant alors un rejet du candidat Macron. Les électeurs de Zemmour sont aussi partagés, signifiant un rejet des deux alternatives. Ce résultat est à prendre avec des pincettes car ces derniers candidats ont obtenu moins de votes et donc que le processus d'inférence est forcément moins précis car il y a moins de données disponibles.
En résumé, cette analyse donne des tendances en fonction des choix exprimés au premier tour: qui montre une nette séparation des groupes de vote.
Grâce à l'ouverture des données sur https://www.data.gouv.fr/ (notamment utilisées pour la recherche), on peut obtenir librement les résultats des premier et second tours. Il est intéressant de noter que les résultats donnés sont indiqués pour chacun des bureaux de vote.
Nous allons faire deux hypothèses que nous allons expliciter mathématiquement :
- Tout d'abord, nous allons estimer que pour chaque individu, il existe une préférence pour les choix du premier tour ainsi que pour les choix du deuxième tour.
-
On note par exemple les différentes alternatives au premier tour comme $i \in \{ \text{'Nul'}, \text{'Abstention'}, \ldots, \text{'Macron'}, \text{'Poutou'} \}$ et au deuxième tour $j \in \{ \text{'Nul'}, \text{'Abstention'}, \ldots, \text{'Macron'}, \text{'Le Pen'} \}$.
-
Le vote de chaque individu se modélise mathématiquement par un processus de Bernoulli relatif à ces préférences : on peut écrire pour chaque individu $k$ les probabilités de vote $p^k_i$ et $q^k_j$ (chacune de ces valeurs étant comprises entre $0$ et $1$ représentant un biais de probabilité pour chacune des alternatives). On pourra vérifier que $\forall k$ (pour tout individu), alors $\sum_i p^k_i = 1$ et $\sum_j q^k_j = 1$, ce qui revient à dire qu'à chaque scrutin un individu effectue un et un seul choix.
-
Avec une telle modélisation, on peut prédire les résultats du vote, car les préférences de chaque individu pour tel ou tel choix doit se révèle nécessairement au niveau de la population totale. Plus précisément, le théorème central limite indique que la moyenne observée (c'est-à-dire les moyennes de vote observées pour les différents choix) tend vers ces probabilités avec une précision (inverse de la variance) qui augmente linéairement avec le nombre d'observations. En particulier, les résultats des votes au premier et second tour seront donnés par respectivement $\frac 1 K \cdot \sum_k p^k_i$ et $\frac 1 K \cdot \sum_k q^k_j$ avec $K$ la taille de la population (nous vérifierons ce point plus bas).
- Une deuxième hypothèse que nous allons faire et que si on considère la transition entre les préférences qui sont faites au premier tour et celles qui sont faites au second tour.
-
En effet, les préférences peuvent évoluer avec chaque invidu mais que la transition est homogène au sein de la population (par exemple "une personne qui choisit de s'abstenir s'abstiendra"). C'est une hypothèse a priori grossière, mais assez générale pour refléter les tendances au niveau de la population globale, et nous allons la tester.
-
Cette hypothèse est basée sur la modélisation d'une séquence de deux événements aléatoires comme dans un processus de Markov. En particulier, nous allons formaliser cette hypothèse en faisant l'hypothèse de l'existence d'une matrice de transition $M$ qui permet de prédire la préférence $\hat{q}^k_j$ d'un individu au second tour à partir de ses préférences au premier tour. Mathématiquement, cette hypothèse peut être formulée comme un simple produit matriciel :
En termes plus simples, cette formule exprime que la préférence d'un individu au second tour et le mélange de ses préférences individuelles au premier tour avec des poids indiquant les affinités entre les différentes alternatives aux deux tours.
- Il est important de noter qu'il existe une contrainte pour chaque colonne de cette matrice de transition, de sorte que la somme des différents éléments sur les différentes lignes de la matrice pour chaque colonne est égale à $1$ : $\forall j$, $ \sum_i M_{i, j} = 1$. Cette propriété découle des contraintes de représentation des préférences au premier et au deuxième tour que nous avons évoquées plus haut ($\sum_i p^k_i = 1$ et $\sum_j q^k_j = 1$).
D'une certaine façon, cette matrice de transition décrit exactement les affinités de chacun des individus pour les différents choix de vote au niveau de la population globale. C'est donc un indicateur quantitatif des reports de vote qui vont être effectivement effectués entre les deux tours.
Méthodes¶
Calcul libre¶
Ce post est une exploration, un travail de recherche dont les méthodes essaient de suivre la méthode scientifique le plus fidélement possible:
- À ce titre, il est écrit dans un format qui permet de reproduire entièrement les résultats: les données et les algorithmes sont librement disponibles: le format choisi est un notebook jupyter avec le language python. Aussi, les librairies utilisées sont toutes open-source et donc utilisables librement.
- De plus, nous avons consigné les différentes versions de ce post dans une "archive" github : https://github.com/laurentperrinet/2022-05-04_transfert-des-voix. Ceci permet de vérifier le cheminement (parfois tortueux) suivi pour arriver à des résultats qui semblent valides.
Collecte des données¶
La première partie de ce travail consiste à collecter les données et elle est représentée d'une façon utile. On va utiliser les données disponibles sur https://www.data.gouv.fr en se concentrant sur les résultats définitifs par bureau de vote. Commençons notre procédure avec le traitement des données du premier tour. Une fois que nous aurons décortiqué cette méthode, nous passerons au deuxième tour.
Données du premier tour¶
Pour cela nous allons utiliser deux premières librairies python : numpy
pour le traitement de données numériques puis pandas
pour la représentation de ces données sous forme de tableau tableaux.
datetime = '2022-06-08'
import numpy as np
import pandas as pd
On peut directement enregistrer à partir de l'adresse des données puis extraire ses données numériques depuis le tableau (en format "Excel (xlsx)") grâce a la fonction suivante:
import os
fname = '/tmp/T1.xlsx'
if not os.path.isfile(fname):
url = 'https://static.data.gouv.fr/resources/election-presidentielle-des-10-et-24-avril-2022-resultats-definitifs-du-1er-tour/20220414-152612/resultats-par-niveau-burvot-t1-france-entiere.xlsx' # XLSX
import urllib.request
urllib.request.urlretrieve(url, fname)
T1 = pd.read_excel(fname)
On peut avoir une première idée de ces données et du nombre total de bureaux de vote :
T1.tail()
Les données sont organisées suivant des colonnes qui vont représenter les différents choix et aussi d'autres métadonnées. Il va falloir faire quelques recherches simples pour récupérer ces données utiles…
T1.columns
Ainsi, la première colonne concerne les nuls, blancs et abstention, que l'on peut enregistrer dans un nouveau tableau :
df_1 = T1[['Nuls', 'Blancs', 'Abstentions']].copy()
df_1.head()
df_1
Extraction des résultats de vote¶
Les 23 premières colonnes correspondent aux métadonnées :
T1.columns[:23]
Les colonnes suivant la colonne numéro 23 vont concerner les résultats candidat par candidat :
col_start = 23
col_par_cdt = 7
On peut extraire les noms des candidats présents au premier tour :
candidats = T1.iloc[0][col_start::col_par_cdt]
candidats
On peut par exemple extraire les résultats pour le premier bureau de vote et donner le nombre de suffrages exprimés pour chaque candidat :
résultats = T1.iloc[0][(col_start+2)::col_par_cdt]
résultats
Grâce à ces connaissances, nous pouvons maintenant récolter les données pour chaque candidat et pour tous les bureaux de vote en utilisant la fonction suivante :
for i_candidat, candidat in enumerate(candidats):
i_col = col_start + i_candidat*col_par_cdt + 2
print('# colonne', i_col, ' résultats=', T1.iloc[:, i_col].values)
df_1[candidat] = T1.iloc[:, i_col].values
Nous avons récolté les données utiles dans un nouveau tableau :
print(df_1.info())
Une fois ce nouveau tableau comilé, ceci nous permet par exemple d'extraire les résultats pour un candidat donné et pour chacun des bureaux de vote (indexé dans l'ordre du fichier) :
df_1['POUTOU']
En particulier, on a le nombre suivant de bureaux de vote :
len(df_1)
Et on peut calculer pour chaque alternative le nombre total de choix ainsi que le nombre total de choix dans les data:
df_1.sum(), df_1.sum().sum()
Par exemple, on note qu'environ 13 millions de personnes se sont abstenues, alors que environ 10 millions de personnes ont voté pour Macron.
Dans le reste, nous pourrions aussi complétement ignorer les votes 'Nuls' et 'Abstentions'. En effet, durant nos expériences (cf github) nous avons pu obtenir des modèles pour lesquelles ces valeurs étaient moins prédictibles. Dans le cadre d'un "débogage", on peut vouloir enlever certaines colonnes. Pour cela, nous utilisons la fonction DataFrame.drop
:
df_1.columns
Dans ce post, nous ne l'avons pas fait, mais il suffit de décommenter la ligne suivante pour impacter le reste des calculs:
# df_1 = df_1.drop(columns=['Nuls', 'Blancs'])#, 'Abstentions'])
df_1.columns
Sous un format graphique on peut représenter ainsi les résultats du vote au premier tour et pour cela nous allons utiliser la librairie matplotlib
:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(13, 5))
k = df_1.sum()/df_1.sum().sum()
ax = k.plot.bar(ax=ax)
ax.set_xlabel('Choix 1er tour')
ax.set_ylabel('Pourcentage');
On remarque :
- le fort taux d'abstention qui a été observé au premier tour,
- ainsi que les deux candidats ("Macron", "Le Pen") qui se distinguent par le plus grand nombre de voix et qui sont sélectionnés pour le second tour.
Données du 2ème tour¶
Nous allons maintenant répéter la même opération pour les données obtenues au deuxième tour :
fname = '/tmp/T2.xlsx'
if not os.path.isfile(fname):
url = 'https://static.data.gouv.fr/resources/election-presidentielle-des-10-et-24-avril-2022-resultats-definitifs-du-2nd-tour/20220428-142301/resultats-par-niveau-burvot-t2-france-entiere.xlsx' # XLSX
import urllib.request
urllib.request.urlretrieve(url, fname)
T2 = pd.read_excel(fname)
T2.tail()
On vérifie que les données sont une nouvelle fois organisées suivant une structure similaire :
T2.columns
T2.columns[:23]
T2.columns[23:]
T2.iloc[0, 23:]
col_start = 23
col_par_cdt = 7
candidats = T2.iloc[0][col_start::col_par_cdt]
candidats
Une fois cette vérification faite, nous pouvons extraire les données dans un nouveau tableau :
df_2 = T2[['Nuls', 'Blancs', 'Abstentions']].copy()
df_2.head()
Nous vérifions aussi que nous avons le même nombre de bureaux de vote :
len(df_2)
for i_candidat, candidat in enumerate(candidats):
i_col = col_start + i_candidat*col_par_cdt + 2
print(i_col, T2.iloc[:, i_col].values)
df_2[candidat] = T2.iloc[:, i_col].values
df_2
De la même façon que pour le premier tour, nous pouvons représenter les résultats totaux obtenus au second tour de façon graphique :
fig, ax = plt.subplots(figsize=(13, 5))
k = df_2.sum()/df_2.sum().sum()
ax = k.plot.bar(ax=ax)
ax.set_xlabel('Candidat')
#ax.set_xlim(1, 10)
#ax.set_xticks(np.arange(1, 10)+.5)
#ax.set_xticklabels(np.arange(1, 10)) , rotation=45
ax.set_ylabel('pourcentage');
On vérifie :
- le fort taux d'abstention observé au second tour,
- le candidat "Macron" résolte le plus de suffrages exprimés (mais aussi au total).
# à décommenter si on veut tester des prédictions sur les choix exprimés
# df_2 = df_2.drop(columns=['Nuls', 'Blancs']])
# df_2 = df_2.drop(columns=['Abstentions']) (en plus, sans les abstentions)
Nettoyage des données (non aux nans)¶
Certains bureaux de vote n'ont pas de votants au premier ou au deuxieme. Ceci peut engendrer des problèmes numériques en générant des divisions par zéro, des Not a Number (NaN)
dans le jargon informatique. Comme ceux-ci représentent un nombre très faible d'électeurs nous allons les ignorer par rapport au reste de la population.
Nous pouvons d'abord compter le nombre de bureaux de vote qui n'ont aucun suffrage enregistré:
(df_1.sum(axis=1)==0).sum(), (df_2.sum(axis=1)==0).sum()
Nous pouvons "effacer" ces bureaux du vote en commençant par filtrer ceux qui n'ont aucun suffrage enregistré au premier tour :
df_2.drop(df_2.loc[df_1.sum(axis=1)==0].index, inplace=True)
df_1.drop(df_1.loc[df_1.sum(axis=1)==0].index, inplace=True)
(df_1.sum(axis=1)==0).sum(), (df_2.sum(axis=1)==0).sum()
Et maintenant répéter la même procédure sur les bureaux de vote qui n'ont aucun suffrage enregistré au second tour :
df_1.drop(df_1.loc[df_2.sum(axis=1)==0].index, inplace=True)
df_2.drop(df_2.loc[df_2.sum(axis=1)==0].index, inplace=True)
(df_1.sum(axis=1)==0).sum(), (df_2.sum(axis=1)==0).sum()
Statistiques de second ordre¶
Comme cela est montré dans l'article du Monde on peut montrer la dépendance entre les choix qui sont effectués au premier tour et ceux qui sont effectués au deuxième tour. On va utiliser des représentations graphiques similaires à ceux de l'article pour d'une première part les répliquer et vérifier que la méthode est correct et d'un autre côté pour mieux comprendre comment nous pouvons tirer dans ses enseignements depuis ces observations.
df_12 = pd.DataFrame()
df_12['1_MÉLENCHON'] = df_1['MÉLENCHON'].copy()
df_12['MACRON'] = df_2['MACRON'].copy()
df_12.info()
df_12['1_MÉLENCHON'] = df_12['1_MÉLENCHON']/df_1.sum(axis=1)
df_12['MACRON'] = df_12['MACRON']/df_2.sum(axis=1)
import seaborn as sns
sns.jointplot(x=df_12['1_MÉLENCHON'], y=df_12['MACRON'], kind='hist', height=8);
On remarque effectivement une dépendance entre le choix. Un premier candidat effectué au premier tour et celui qui est effectué au second tour. Nous allons essayer d'inférer de façon plus précise cette dépendance grâce au modèle de transition que nous avons exposé au début de cet article.
Coté esthétique, on montre aussi :
- qu'un nuage de points est souvent trompeur, car il donne trop "d'importance visuelle" aux points qui sont en dehors du gros de la distribution. Ici, nous avons utilisé un histogramme qui donne une "image" qui semble mieux équilibrée par rapport à l'ensemble des votes (ça reste subjectif, "dans l'œil de l'observateur"),
- que la forme de "banane" est liée au gauchissement de certaines distributions des préférences (par exemple le fait que la statistique de premier ordre de "Mélenchon" soit plus "tassée" vers $0$).
Modèle de prédiction du transfert des voix¶
Maintenant que nous avons récolté les données pour chacun des deux tours, et que nous avons une idée qu'il existe une dépendance entre les choix qui sont faits entre un tour et le suivant, nous allons pouvoir utiliser des librairies de l'apprentissage automatique (machine learning en anglais) pour pouvoir inférer le report de voix entre les deux tours : Pour cela nous allons utiliser un travail précédemment effectué appliqué à l'exploration du comportement humain ou alors pour l'épidémiologie de la Covid.
Formatage des données au format de la librairie torch
¶
Pendant un aperçu des résultats au premier tour :
df_1.head()
Nous avons donc comptabilisé ces différentes alternatives au premier tour :
len(df_1.columns)
De sorte que sur les bureaux de vote que nous avons validé nous avons les deux tableaux suivants :
df_1.values.shape, df_2.values.shape
Nous allons maintenant utiliser la librairie torch
pour enregistrer ses données sous la forme d'une matrice (ou tenseur dans le jargon de cette librairie) :
import torch
X_1, X_2 = df_1.values, df_2.values
x_1, x_2 = torch.Tensor(X_1), torch.Tensor(X_2)
x_1.shape
Ainsi, nous allons très facilement pouvoir représenter les données pour pouvoir les apprendre. Une pratique extrêmement importante dans l'apprentissage automatique et de séparer les données qui sont utilisées pour apprendre le modèle (base d'apprentissage), avec celles qui sont utilisées pour tester ce modèle (base de test) :
from torch.utils.data import TensorDataset, DataLoader
dataset = TensorDataset(x_1, x_2)
# Random split
train_set_size = int(len(dataset) * 0.9)
test_set_size = len(dataset) - train_set_size
train_set, test_set = torch.utils.data.random_split(dataset, [train_set_size, test_set_size])
Ainsi nous pourrons utiliser l'ensemble d'apprentissage au cours des différentes époques d'apprentissage utilisé plus bas :
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
for n_1, n_2 in train_loader:
break
Durant l'apprentissage, nous allons utiliser la méthode classique de « séparation par paquets ». Ceci consiste à séparer les données de façon aléatoire sous forme de différents « paquets » de bureaux de vote dont la taille est fixée ici à $32$. Nous vérifierons plus tard que si cette procédure permet d'accélérer l'apprentissage - la taille du paquet n'ayant qu'une influence sur la vitesse pour obtenir le résultat final (et non sur le résultat).
n_1.shape, n_2.shape
Dans chaque paquet (de bureaux de votes tirés au hasard sur la population française), nous pouvons compter le nombre de votes pour les différentes alternatives :
sum_1, sum_2 = n_1.sum(axis=1), n_2.sum(axis=1)
sum_1, sum_2
Nous pouvons aussi vérifier que parmi toutes les alternatives, on peut calculer des fréquences d'occurrence, et que comme chaque individu peut faire un seul choix et un seul, la somme de ses fréquences d'occurrence pour chacun des paquets est égale à $1$.
(n_1/sum_1[:, None]).sum(axis=1)
Nous pourrons aussi aisément utiliser les données qui sont représentés dans l'ensemble de test :
n_1, n_2 = dataset[test_set.indices]
n_1.shape, n_2.shape
Modèle torch
de transition de probabilités¶
Maintenant que nous avons correctement formaté les données, nous allons exprimer dans le langage de la librairie torch
le modèle qui nous permet d'exprimer la transition entre les préférences au premier tour et les préférences au premier second tour. En particulier, la matrice de transition sera définie par une matrice de poids self.lin.weight
. Ces poids sont des valeurs réelles (sous format d'un logit
) et sont utilisées de telle sorte que la contrainte qui fait que la somme des éléments sur chaque ligne est bien égale à $1$ (grâce à l'utilisation de torch.softmax(self.lin.weight, axis=1)
avant chaque codage) :
import torch
from torch.utils.data import TensorDataset, DataLoader, random_split
import torch.nn.functional as F
torch.set_default_tensor_type("torch.FloatTensor")
class TransfertVoix(torch.nn.Module):
def __init__(self, N_1er, N_2eme):#, device=None):
super(TransfertVoix, self).__init__()
self.lin = torch.nn.Linear(N_2eme, N_1er, bias=False)
def forward(self, p_1):
M = torch.softmax(self.lin.weight, axis=1)
p_2_pred = torch.matmul(p_1, M)
return p_2_pred
Ce modèle va utiliser comme dimension le nombre de différentes alternatives au premier et deuxième tour :
N_1er, N_2eme = len(df_1.columns), len(df_2.columns)
N_1er, N_2eme
De telle sorte que nous allons pouvoir instancier un tel modèle, sachant que la matrice de transition sera initialement choisie de façon totalement aléatoire et donc déconnecté des données à la première époque de cet apprentissage :
trans = TransfertVoix(N_1er, N_2eme)
for p in trans.parameters():print(p)
trans.lin.weight.shape
torch.softmax(trans.lin.weight, axis=1)
Nous vérifions que la contrainte sur chaque ligne de la matrice de transition et bien vérifiée :
torch.softmax(trans.lin.weight, axis=1).sum(axis=1)
Exprimons maintenant pour chacun des bureaux de vote les probabilités de préférence qui sont exprimées localement :
sum_1, sum_2 = n_1.sum(axis=1), n_2.sum(axis=1)
p_1 = n_1/sum_1[:, None]
Cette probabilité va pouvoir être multipliée par la matrice de transition de probabilité et nous vérifions dans les lignes suivantes la compatibilité entre les différentes dimensions des données représentées :
- entrée (et sa somme sur les différents choix au 1er tour):
p_1.shape, p_1.sum(axis=1)
- transition:
trans.lin.weight.shape
- sortie:
torch.matmul(p_1, torch.softmax(trans.lin.weight, axis=1)).shape
Ces différentes vérifications nous permettent de valider l'utilisation directe du modèle pour prédire la probabilité de préférence dans ce bureau de vote au second tour à partir de celle observée au premier tour :
p_2_pred = trans(n_1/sum_1[:, None])
p_2_pred.mean(axis=0), p_2_pred.mean(axis=0).sum()
(Ce premier résultat est sûrement faux car les poids sont initialement fixés aléatoirement).
Au cours de l'apprentissage, nous allons pouvoir comparer cette probabilité prédit avec celle qui a été effectivement observée :
p_2 = n_2/sum_2[:, None]
p_2.mean(axis=0), p_2.mean(axis=0).sum()
Nous allons aussi vérifier graphiquement que les résultats moyen des votes au second tour peuvent être inférer à partir des données de probabilité multiplié par la taille de chacun des bureaux de vote :
fig, ax = plt.subplots(figsize=(13, 5))
k = df_2.sum()/df_2.sum().sum()
ax = k.plot.bar(ax=ax)
p_2_average = (p_2*sum_2[:, None]).sum(axis=0)/sum_2.sum()
ax.plot(p_2_average, 'r')
ax.set_xlabel('Candidat')
ax.set_ylabel('pourcentage');
Ainsi que la concordance entre les résultats finaux obtenu est ce que nous représentons dans notre modèle :
k*100, p_2_average*100
F.binary_cross_entropy(p_2_average, p_2_average, reduction="sum")
Modèle torch
d'apprentissage¶
Maintenant que nous avons défini le modèle de transition des probabilités entre les préférences au premier tour et au second tour, nous pouvons maintenant écrire un algorithme d'apprentissage qui permet d'optimiser la concordance entre la prédiction et les observations. C'est donc un algorithme supervisé par les observations (sur les données de la base d'apprentissage) et que nous allons pouvoir tester sur la base de test.
Nous allons utiliser les distributions du second tour observées $q$ et prédites $\hat{q}$ (celle-ci dépendant de $M$) pour calculer un coût à minimiser $$ \mathcal{L} = KL(q, \hat{q}) $$
où la divergence de Kullback-Leibler entre deux distributions $P$ et $Q$ de support non-nul $k \in \Omega$ est calculée comme
$$ KL(P, Q) = \sum_{k \in \Omega} P_k \cdot \log \frac {P_k}{Q_k} $$Cette divergence est l'équivalent d'une distance dans les espaces de probabilité. Mathématiquement, c'est une semi-norme, car elle obéit à deux propriétés fondamentales : elle est toujours positive et est égale à zéro quand elle est appliquée à distributions identiques.
Pour généraliser cette méthode, nous allons aussi utiliser deux autres mesures et montrer ensuite que les résultats quantitatifs sont similaires :
- L1: la somme des valeurs absolues des différences entre les valeurs prédites et observées,
- BCE: la norme traditionnellement utilisée en régression multinomiale.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
import torch.nn.functional as F
learning_rate = 10.0
beta1, beta2 = 0.99, 0
# beta1, beta2 = 0.9, 0.999 # décommenter pour utiliser Adam
num_epochs = 2 ** 5 + 1
num_epochs = 2 ** 10 + 1
batch_size = 256
do_optim = False
do_optim = True
def pdf_loss(p_pred, p, weight, loss_type):
if loss_type=='kl':
ind_nonzero = (p==0) + (p_pred==0)
p[ind_nonzero] = 1.
p_pred[ind_nonzero] = 1.
kl_div = p * (p.log() - p_pred.log())
loss_train = (kl_div * weight[:, None]).sum()
elif loss_type=='l1':
N_2eme = p.shape[1]
div = torch.absolute(p - p_pred)
loss_train = (div * weight[:, None]).sum() / N_2eme
else: # BCE
loss_train = F.binary_cross_entropy(p_pred, p, reduction="sum", weight=weight[:, None])
loss_train -= F.binary_cross_entropy(p, p, reduction="sum", weight=weight[:, None])
return loss_train
def fit_data(
df_1,
df_2,
learning_rate=learning_rate,
batch_size=batch_size,
num_epochs=num_epochs,
loss_type='bce',
beta1=beta1,
beta2=beta2,
do_optim=do_optim,
split_ratio=.9,
seed=2022, # graine du générateur de nombre aléatoires utilisé dans le split test vs train
verbose=False
):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
N_1er, N_2eme = len(df_1.columns), len(df_2.columns)
trans = TransfertVoix(N_1er, N_2eme)
trans = trans.to(device)
trans.train()
# apprentissage
if beta2==0:
optimizer = torch.optim.SGD(trans.parameters(), lr=learning_rate, momentum=beta1, nesterov=do_optim)
else:
optimizer = torch.optim.Adam(trans.parameters(), lr=learning_rate, betas=(beta1, beta2), amsgrad=do_optim)
if torch.cuda.is_available(): torch.cuda.empty_cache()
# the data
X_1, X_2 = df_1.values, df_2.values
x_1, x_2 = torch.Tensor(X_1), torch.Tensor(X_2)
# split train and test
dataset = TensorDataset(x_1, x_2)
train_set_size = int(len(dataset) * split_ratio)
test_set_size = len(dataset) - train_set_size
train_set, test_set = random_split(dataset, [train_set_size, test_set_size], generator=torch.Generator().manual_seed(seed))
train_loader = DataLoader(train_set, batch_size=int(batch_size), shuffle=True)
n_1, n_2 = dataset[train_set.indices]
sum_sum_2 = n_2.sum()
for epoch in range(int(num_epochs)):
losses = []
for n_1, n_2 in train_loader:
n_1, n_2 = n_1.to(device), n_2.to(device)
sum_1, sum_2 = n_1.sum(axis=1), n_2.sum(axis=1)
p_1 = n_1/sum_1[:, None]
p_2 = n_2/sum_2[:, None]
p_2_pred = trans(p_1)
weight = sum_2 / sum_sum_2 # donne un poids à chaque bureau de vote proportionnel à sa taille
loss_train = pdf_loss(p_2_pred, p_2, weight, loss_type=loss_type)
optimizer.zero_grad()
loss_train.backward()
optimizer.step()
losses.append(loss_train.item())
if verbose and (epoch % (num_epochs // 32) == 0):
print(f"Iteration: {epoch} / {num_epochs} - Loss: {np.sum(losses):.5e}")
loss_train = np.sum(losses) # somme des loss à la dernière époque
loss_train = loss_train/split_ratio # normalise par rapport à la taille du train dataset
# Test
with torch.no_grad():
n_1, n_2 = dataset[test_set.indices]
sum_1, sum_2 = n_1.sum(axis=1), n_2.sum(axis=1)
p_2 = n_2/sum_2[:, None]
p_1 = n_1/sum_1[:, None]
p_2_pred = trans(p_1)
weight = sum_2 / sum_2.sum() # normalise par rapport à la taille du dataset de test
loss_test = pdf_loss(p_2_pred, p_2, weight, loss_type=loss_type)
loss_test_l1 = pdf_loss(p_2_pred, p_2, weight, loss_type='l1')
if verbose: print(f'Erreur ~ {loss_test_l1.item()*100:.1f}%')
return trans, loss_train, loss_test, loss_test_l1, p_1.detach().numpy(), p_2.detach().numpy(), p_2_pred.detach().numpy()
for loss_type in ['l1', 'kl', 'bce',]:
print(f'{loss_type=}')
trans, loss_train, loss_test, loss_test_l1, p_1, p_2, p_2_pred = fit_data(df_1, df_2, verbose=True, loss_type=loss_type)
fig, axs = plt.subplots(1, N_2eme, figsize=(13, 5))
for i_col, candidat in enumerate(df_2.columns):
#axs[i_col].scatter(p_2[:,i_col], p_2_pred[:,i_col], alpha=.005)
axs[i_col].plot([0, 1], [0, 1], 'r--')
sns.histplot(x=p_2[:, i_col], y=p_2_pred[:, i_col], ax=axs[i_col])
axs[i_col].set_xlabel("Résultat " + candidat)
axs[i_col].set_xlim(0, 1)
axs[i_col].set_ylim(0, 1)
axs[0].set_ylabel("Prédiction ")
plt.suptitle("Prédiction des résultats pour chaque choix au 2ème tour", fontsize=16)
plt.subplots_adjust(left=0.1, bottom=0.1, right=0.95, top=0.9)
plt.savefig(datetime + '_prediction_transfert-des-voix_' + loss_type +'.png');
plt.show()
Ces graphiques représentent en abscisse les probabilités observées et en ordonnée les probabilités prédites. L'intérieur du graphique représente en niveaux de bleu l'histogramme des différentes valeurs telles qu'elles sont observés sur la base de test, c'est-à-dire sur $10\%$ de l'ensemble des bureaux de vote (tirés au hasard, environ $7000$).
On remarque de suite une prédiction très correcte des données du second tour à partir des observations du premier. Ceci montre la validité de notre hypothèse de transition, et aussi de son homognénéité dans le teritoire. On note aussi que les trois méthodes donnent sensiblement des résultats similaires et nous allons utiliser bce
dans la suite.
Il faut noter que le nombre de paramètres libres de notre modèle est simplement celui de la matrice de transition stochastique soit $4 \times 15 = 60$.
Pour avoir une idée de l'erreur de prédiction, on peut estimer l'erreur absolue moyenne, c'est à dire la moyenne de l'écart observé sur la population:
print(f'Erreur ~ {loss_test_l1.item()*100:.1f}%')
L'erreur est relativement faible (inférieure à 2 points de pourcentage) mais il faut garder en tête qu'elle présente des variations important par bureau de vote.
analyse de le matrice de transition¶
On peut maintenant récupérer du modèle la matrice de transition qui a été inférée grâce a notre apprentissage automatique :
M = torch.softmax(trans.lin.weight, axis=1).detach().numpy()
M
On vérifie dans un premier temps que la contrainte est toujours bien respectée :
M.sum(axis=1), M.shape
Une particularité de cette matrice (prédite sur l'ensemble des bureaux de votes) est que l'on peut maintenant s'en servir pour prédire la préférence au second tour en fonction de celle au premier.
En effet, si la préférence pour le choix d'un individu se pose au premier tour entièrement sur le choix $i^\ast$, on peut le formaliser comme $p_i = 0$ pour $i \neq i^\ast$ et $p_{i^\ast}=1$ et on obtient la prédiction:
$$ \hat{q}_j = M_{i^\ast j} $$En language commun, la préférence d'une personne votant $i^\ast$ au premier tour est la $i^\ast$-ième ligne de la matrice de transition (de taille $15 \times 5$ dans notre cas).
Nous allons nous inspirer d'un graphique de la galerie matplotlib pour représenter visuellement ces préférences de report de voix en fonction de chacun des choix éfféctués au premier tour :
np.set_printoptions(precision=1)
np.set_printoptions(suppress=True)
M*100
fig, ax = plt.subplots(figsize=(25, 8))
columns = df_1.columns
rows = df_2.columns
# named colors: https://matplotlib.org/stable/gallery/color/named_colors.html
colors = ['thistle', 'violet', 'darkviolet', 'steelblue', 'brown' ]
n_rows = len(rows)
index = np.arange(len(columns)) + 0.3
bar_width = 0.4
# Initialize the vertical-offset for the stacked bar chart.
y_offset = np.zeros(len(columns))
# Plot bars and create text labels for the table
cell_text = []
for row in range(n_rows):
ax.bar(index, M.T[row]*100, bar_width, bottom=y_offset, color=colors[row], linewidth=1)
y_offset = y_offset + M.T[row]*100
cell_text.append([f'{x*100:1.1f}%' for x in M.T[row]])
ax.set_ylim(0, 100)
# Add a table at the bottom of the axes
the_table = ax.table(cellText=cell_text,
rowLabels=rows,
rowColours=colors,
colLabels=columns,
loc='bottom')
# Adjust layout to make room for the table:
plt.subplots_adjust(left=0.1, bottom=0.1, right=0.95, top=0.9)
plt.ylabel("Pourcentage de report des voix")
plt.yticks(np.linspace(0, 100, 6, endpoint=True))
plt.xticks([])
plt.suptitle("Report des voix par résultat du 1er tour", fontsize=16);
Ces données correspondent aux intuitions qu'on peut se faire quand au report des votes. Notamment on remarque que la plupart des individus qui s'abstiennent au premier tour se sont abstenus au second (avec une légère différence dans la proportion de "Mélenchon" qui se sont abstenus). Remarquez aussi que les électeurs de "Macron" ou "Le Pen" au premier tour ont massivement voté pour la même personne au deuxième tour ("vote d'adhésion"). Environ un quart des électeurs de "Le Pen" au second tour s'était abstenu au premier.
On observe aussi quelques points intéressants dans les votes nuls qui proviennent majoritairement de votant "nul" au premier tour ($40 \%$) et ensuite par des électeurs ayant voté pour des candidats marginaux au premier tour ("Arthaud", "Lassalle", "Roussel", "Poutou"). On observe à peu près la même structure pour le votant "blanc". On rappelle qu'en France, un vote nul est un bulletin qui n'a pas été validé, car il est par exemple raturé, alors qu'un vote blanc est exprimé par un bulletin de vote totalement blanc (et qu'il faut préparer en avance). On peut donc expliquer ces observations par rapport aux particularités du vote "militant" lié à ces candidats.
On peut aussi se restreindre aux votes exprimés :
shift = 3
MT = M[shift:, shift:].T
MT /= MT.sum(axis=0)
MT*100
fig, ax = plt.subplots(figsize=(16, 5))
columns = df_1.columns[shift:]
rows = df_2.columns[shift:]
# named colors: https://matplotlib.org/stable/gallery/color/named_colors.html
colors = ['steelblue', 'brown' ]
n_rows = len(rows)
index = np.arange(len(columns)) + 0.3
bar_width = 0.4
# Initialize the vertical-offset for the stacked bar chart.
y_offset = np.zeros(len(columns))
# Plot bars and create text labels for the table
cell_text = []
for row in range(n_rows):
ax.bar(index, MT[row]*100, bar_width, bottom=y_offset, color=colors[row], linewidth=1)
y_offset = y_offset + MT[row]*100
cell_text.append([f'{x*100:1.1f}%' for x in MT[row]])
ax.set_ylim(0, 100)
# Add a table at the bottom of the axes
the_table = ax.table(cellText=cell_text, colLoc='center',
rowLabels=rows, rowLoc='center',
rowColours=colors,
colLabels=columns,
loc='bottom')
the_table.auto_set_font_size(False)
the_table.set_fontsize(10)
# Adjust layout to make room for the table:
plt.subplots_adjust(left=0.05, bottom=0.15, right=0.97, top=.9)
plt.ylabel("Pourcentage de report des voix")
plt.yticks(np.linspace(0, 100, 6, endpoint=True))
plt.xticks([])
plt.suptitle("Report des voix exprimées connaissant le choix exprimé au 1er tour", fontsize=16)
plt.savefig(datetime + '_transfert-des-voix.png');
Ce tableau donne le pourcentage de chances d'exprimer une voix pour un candidat ou pour l'autre en fonction du choix qu'on a exprimé au premier tour.
Ce tableau montre des tendances claires, par exemple que si on a voté "Macron", "Jadot", "Hidalgo" ou "Pécresse" au premier tour, alors on va certainement voter "Macron" au deuxième tour. Ces électeurs se montrent particulièrement consensuel et suivent le « pacte républicain » mise en place pour faire un "barrage" au Front National (en suivant le terme consacré). Il montre aussi que si on a voté "Le Pen" ou "Dupont-Aignan" au premier tour, alors on va voter Le Pen au deuxième, un clair vote de suivi.
Connaissant les couleurs politiques d'autres candidats du premier tour, on peut être surpris que les électeurs de "Arthaud", "Roussel", "Lassalle" ou "Poutou" ont majoritairement choisi "Le Pen" au deuxième tour, signifiant alors un rejet du candidat Macron. Les électeurs de Zemmour sont aussi partagés, signifiant un rejet des deux alternatives. Ce résultat est à prendre avec des pincettes, car ces derniers candidats ont obtenu moins de votes et donc que le processus d'inférence est forcément moins précis parce qu'il y a moins de données disponibles.
Ces résultats permettent de placer les tendances pour chaque profil de choix du 1er tour en fonction des choix faits au second, un premier axe horizontal donnant la part des voix exprimées pour Macron, le deuxieme la part des voix exprimées.
n_1 = df_1.sum()
n_1
fig, ax = plt.subplots(figsize=(8, 8))
p_nonexpr = M[:, :shift].sum(axis=1) / M.sum(axis=1) * 100
p_macron = M[:, -2] / M[:, -2:].sum(axis=1) * 100
ax.scatter(p_macron, p_nonexpr, s=np.sqrt(n_1), c=np.arange(15), alpha=.5)
for i_column, column in enumerate(df_1.columns):
print(i_column, column)
textshift = -15 if ( (i_column ==5) or (i_column == 7)) else 6
ax.annotate(column,
xy=(p_macron[i_column], p_nonexpr[i_column]), xycoords='data',
xytext=(p_macron[i_column]+textshift,
p_nonexpr[i_column]+5), textcoords='data',
arrowprops=dict(arrowstyle="->",
connectionstyle="arc3"),
)
# Adjust layout to make room for the table:
plt.subplots_adjust(left=0.1, bottom=0.1, right=0.95, top=0.9)
# ax.set_xlim(0, 100)
# ax.set_ylim(0, 100)
plt.xlabel("Pourcentage de voix pour Macron")
plt.ylabel("Pourcentage de voix non exprimées")
plt.yticks(np.linspace(0, 100, 6, endpoint=True))
plt.xticks(np.linspace(0, 100, 6, endpoint=True))
plt.suptitle("Tendances électorales présidentielle 2022");
plt.savefig(datetime + '_transfert-des-voix_tendances.png');
Celà montre un paysage en trois groupes distincts dans cette présidentielle: les "abstentionnistes", les "extrèmes", les "républicains". Notons que ce résultat est nettement lié à la coalition lancée pour faire barrage à l'extrème droite et que les reports seront largement variables dans d'autres élections, comme des legislatives.
Pour se rendre compte de la variabilité des résultats qu'on obtient là, je conseille au lecteur (à l'électeur) de relancer ces notebook en utilisant différents « graines » pour le générateur de nombre aléatoire qui permet de séparer les données (le paramètre seed
).
Muni de ces outils, on peut aussi faire certaines estimations (encore à prendre avec des pincettes) :
print(f"""
Total des voix au 1er tour= {x_1.sum():.0f}, dont Macron = {x_1[:, 5].sum():.0f} (soit une pourcentage de {x_1[:, 5].sum()/x_1.sum()*100:.2f}%),
-> nombre de reports de Macron du 1er vers Le Pen au 2eme = {x_1[:, 5].sum()*MT[1, 2]:.0f} personnes.
""")
À noter qu'on pourrait aussi utiliser une procédure similaire pour prédire pour un électeur du second tour la distribution des préférences au premier tour. En effet, si la préférence pour le choix d'un individu se pose au second tour entièrement sur le choix $j^\ast$, on peut le formaliser comme $q_j = 0$ pour $j \neq j^\ast$ et $q_{j^\ast}=1$ et on obtient la prédiction:
$$ \hat{q}_j = M^{+}_{j^\ast i} $$où $M^{+}$ est la pseudo-inverse de la matrice de transition.
Influence des parametres¶
Finalement, et pour clore l'exercise, nous validons nos résultats en testant différentes paramétrisation de l'apprentissage et en donnant la valeur du loss calculé sur la base de test :
trans, loss_train_alt, loss_test_alt, loss_test_l1_alt, p_1, p_2, p_2_pred = fit_data(df_1, df_2, verbose=False, do_optim=not do_optim)
print(f'TRAIN: Loss avec {do_optim=} = {loss_train:.2e} / Loss avec alternative choice do_optim={not do_optim} = {loss_train_alt:.2e} ')
print(f'TEST: Loss avec {do_optim=} = {loss_test:.2e} / Loss avec alternative choice do_optim={not do_optim} = {loss_test_alt:.2e} ')
do_optim
import time
N_scan = 7
results_train, results_test = [], []
learning_rates = learning_rate * np.logspace(-1, 1, 7, base=10)
for learning_rate_ in learning_rates:
tic = time.time()
trans, loss_train, loss_test, loss_test_l1, p_1, p_2, p_2_pred = fit_data(df_1, df_2, learning_rate=learning_rate_, num_epochs=num_epochs, verbose=False)
print(f'Pour learning_rate= {learning_rate_:.2e}, {loss_train=:.2e} / {loss_test=:.2e}; le temps de calcul est {time.time()-tic:.3f} s')
results_train.append(loss_train)
results_test.append(loss_test)
fig, ax = plt.subplots(figsize = (13, 8))
ax.plot(learning_rates, results_train, '--')
ax.plot(learning_rates, results_test)
ax.set_xlabel = 'learning rate'
ax.set_ylabel = 'Loss'
ax.set_yscale('log');
ax.set_xscale('log');
results_train, results_test = [], []
batch_sizes = (batch_size * np.arange(1, N_scan) ** 2) // 8
print(batch_sizes)
for batch_size_ in batch_sizes:
tic = time.time()
trans, loss_train, loss_test, loss_test_l1, p_1, p_2, p_2_pred = fit_data(df_1, df_2, batch_size=batch_size_, num_epochs=num_epochs, verbose=False)
print(f'Pour batch_size= {batch_size_}, {loss_train=:.2e} / {loss_test=:.2e}; le temps de calcul est {time.time()-tic:.3f} s')
results_train.append(loss_train)
results_test.append(loss_test)
fig, ax = plt.subplots(figsize = (13, 8))
ax.plot(batch_sizes, results_train, '--')
ax.plot(batch_sizes, results_test)
ax.set_xlabel = 'batch size'
ax.set_ylabel = 'Loss'
ax.set_yscale('log');
ax.set_xscale('log');
beta1s = 1 - (1 - beta1) * np.logspace(-1, 1, 7, base=10)
print(np.log10(beta1s))
results_train, results_test = [], []
beta1s = 1 - (1 - beta1) * np.logspace(-1, 1, 7, base=10)
print(beta1s)
for beta1_ in beta1s:
tic = time.time()
trans, loss_train, loss_test, loss_test_l1, p_1, p_2, p_2_pred = fit_data(df_1, df_2, beta1=beta1_, num_epochs=num_epochs, verbose=False)
print(f'Pour beta1= {beta1_:.2e}, {loss_train=:.2e} / {loss_test=:.2e}; le temps de calcul est {time.time()-tic:.3f} s')
results_train.append(loss_train)
results_test.append(loss_test)
fig, ax = plt.subplots(figsize = (13, 8))
ax.plot(beta1s, results_train, '--')
ax.plot(beta1s, results_test)
ax.set_xlabel = 'beta1'
ax.set_ylabel = 'Loss';
ax.set_yscale('log');
#ax.set_xscale('log');
results_train, results_test = [], []
beta2s = 1 - (1 - .95) * np.logspace(-1, 1, 7, base=10)
for beta2_ in beta2s:
tic = time.time()
trans, loss_train, loss_test, loss_test_l1, p_1, p_2, p_2_pred = fit_data(df_1, df_2, beta2=beta2_, num_epochs=num_epochs, verbose=False)
print(f'Pour beta2= {beta2_:.2e}, {loss_train=:.2e} / {loss_test=:.2e}; le temps de calcul est {time.time()-tic:.3f} s')
results_train.append(loss_train)
results_test.append(loss_test)
fig, ax = plt.subplots(figsize = (13, 8))
ax.plot(beta2s, results_train, '--')
ax.plot(beta2s, results_test)
ax.set_xlabel = 'beta2 with Adam instead of SGD'
ax.set_ylabel = 'Loss'
ax.set_yscale('log');
ax.set_xscale('log');
results_train, results_test = [], []
num_epochss = ((num_epochs-1) * np.arange(1, N_scan) ** 2) // 8 + 1
print(num_epochss)
for num_epochs_ in num_epochss:
tic = time.time()
trans, loss_train, loss_test, loss_test_l1, p_1, p_2, p_2_pred = fit_data(df_1, df_2, num_epochs=num_epochs_, verbose=False)
print(f'Pour num_epochs={num_epochs_}, {loss_train=:.2e} / {loss_test=:.2e}; le temps de calcul est {time.time()-tic:.3f} s')
results_train.append(loss_train)
results_test.append(loss_test)
fig, ax = plt.subplots(figsize = (13, 8))
ax.plot(num_epochss, results_train, '--')
ax.plot(num_epochss, results_test)
ax.set_xlabel = 'batch size'
ax.set_ylabel = 'Loss'
ax.set_yscale('log');
ax.set_xscale('log');