ML-обработка результатов голосований Госдумы (2016-2021)

ox5dxti83weehof_8lvwhrsdkyi.png

Всем привет! Недавно я наткнулся на сайт vote.duma.gov.ru, на котором представлены результаты голосований Госдумы РФ за весь период её работы — с 1994-го года по сегодняшний день. Мне показалось интересным применить некоторые техники машинного обучения, а так же обычной статистической обработки для выяснения следующих вопросов.


  1. Каков диапазон степени корреляции депутатов внутри партий? Имеются ли депутаты, которые голосуют всегда так же, как большинство в их партии?
  2. Смогут ли классические методы кластеризации автоматически разделить депутатов думы на фракции, к которым они относятся, основываясь только на их голосах?
  3. Можно ли добиться приемлемого качества предсказания итога голосования, зная только исходный текст вопроса голосования?

Предполагаю, что людям, не знакомым с методами машинного обучения, может быть интересно посмотреть раздел 1, а также заглянуть в выводы — там дана краткая интерпретация всех результатов.

Итак, поехали.

Пара слов про датасет и принятые допущения.
У вышеуказанного сайта есть API, через который можно скачать все интересующие данные и многое другое, однако он слегка замороченный и не всегда работает, поэтому я решил, что проще будет спарсить данные с веб-версии. Код парсера (и весь остальной код, написанный на Python, а также датасеты) можно посмотреть на GitHub. Для своих экспериментов я скачал результаты всех голосований Госдумы 7-го созыва (2016 — 2021 гг.). Были получены данные по 16249 голосованиям (дата сбора информации — 07.06.2021).
Фрагмент полученного датасета выглядит примерно так:


Спойлер

В качестве индекса используется соответствующий номер голосования на сайте vote.duma.gov.ru, далее идёт текст вопроса голосования, результат голосования и пофамильные результаты всех депутатов, входящих в текущий созыв на момент голосования.
Так как список депутатов, входящих в данный созыв, со временем слегка менялся, было принято решение убрать из датасета тех, кто отсутствовал в составе думы в течение 10 и более процентов всех голосований. Оставшие пропуски были заполнены значением »2», что соответствует тому, что депутат на момент голосования был в составе думы, но по тем или иным причинам не голосовал. Остальные значения в таблице голосов:»1» — «против»,»0» — «воздержался»,»-1» — «за».


1. Корреляции

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Загрузка основного датафрейма с результатами голосований
df_full = pd.read_csv('./data/df_full_cr.csv', index_col=0)

# Загрузка таблицы со списком депутатов и их партий
dep_df_cr = pd.read_csv('./data/dep_df_cr.csv', index_col=0, squeeze=True)

# Функция построения тепловой карты корреляций
def corr_func(df, annot=True, figsize=(15,10)):
    corr = df.corr()
    mask = np.triu(np.ones_like(corr, dtype=bool))
    plt.figure(figsize=figsize)
    sns.heatmap(corr, mask=mask, annot=annot, fmt='.2f')

Чтобы при расчёте корреляций нивелировать влияние количества пропусков голосований у каждого депутата (иначе главные прогульщики будут иметь наименьший коэффициент корреляции с остальными, т.е. выглядеть более независимо), я решил для каждого голосования заполнить голоса отсутствующих депутатов результирующим голосом их партии. Т.е. условно принимается, что если депутат отсутствует, то он доверяет свой голос партии.

# Функция подсчёта итогов голосования
def vote_result(row):
    v_sum = 0
    for v in row:
        if np.abs(v) == 1:
            v_sum += v
    return v_sum

# Функция замены голоса отсутствующего депутата на "голос партии"
def votes_olny(row):
    for i, v in enumerate(row):
        if v == 2:
            row[i] = row['party_result']
    return row

Справедливая Россия

df = {}

# Создаём отдельный датафрейм для партии
df['СР'] = df_full.loc[:, dep_df_cr[dep_df_cr == 'СР'].index]

# Для каждого голосования определяем общий результат для партии
df['СР']['party_result'] = df['СР'].apply(vote_result, axis='columns').map(lambda x: int(abs(x)/x) if x != 0 else 0)

# Меняем все значение "2" (не голосовал) на 'party_result'
df['СР'].apply(votes_olny, axis='columns')

# Строим тепловую карту коэффициентов корреляции Пирсона
corr_func(df['СР'])

13eb5ed16b2b7ab6d7be590df723081b.png

Депутаты, у которых преобладают тёмные клетки, голосуют более независимо, чем остальные. Отдельные светлые клеточки показывают пары депутатов, которые часто голосуют одинаково. Посмотрим на остальные партии.

ЛДПР

86a52446ed8db7e7a8eebd4a01035e52.png

(Все значения округляются до двух знаков после запятой)

КПРФ

c25bfcf0be94defca1bafddf3cc5d7ac.png

Единая Россия
(чтобы рассмотреть хоть что-то, лучше открыть картинку в отдельной вкладке)

5e91429f9d780cd3f966d946218ada43.jpg

Как можно видеть, во всех случаях коэффициент корреляции составил более 0,91 (в подавляющем большинстве — более 0,97).

Теперь посмотрим, существуют ли такие депутаты, которые весь созыв голосовали точно так же, как и их партийное большинство (в данном случае учитывались только голоса «за» и «против»).

# Функция определения списка депутатов, голосовавших всегда так же, как большинство в их партии
def ident_deps(df_party):
    deps_party_full = list(df_party.drop(columns=['party_result']).columns.values)
    deps_party_ident = deps_party_full.copy()

    for ind in df_party.index.values:
        for d in deps_party_ident:
            vote = df_party.loc[ind, d]
            if vote != df_party.loc[ind, 'party_result'] and (vote != 0 and vote != 2):
                deps_party_ident.remove(d)
        if len(deps_party_ident) == 0:
            break
    return deps_party_ident, deps_party_full

Справедливая Россия

deps_SR_ident, deps_SR_full = ident_deps(df['СР'])

# Количество "идентичных" депутатов
len(deps_SR_ident)

>>> 0    

ЛДПР

deps_LDPR_ident, deps_LDPR_full = ident_deps(df['ЛДПР'])

# Количество "идентичных" депутатов
len(deps_LDPR_ident)

>>> 5

# Доля "идентичных" депутатов от общего числа в партии
np.round(len(deps_LDPR_ident) / len(deps_LDPR_full), 2)

>>> 0.16

# Список "идентичных" депутатов
deps_LDPR_ident

>>> ['Жириновский Владимир Вольфович',
 'Кулиева Василина Васильевна',
 'Морозов Антон Юрьевич',
 'Пашин Виталий Львович',
 'Свищев Дмитрий Александрович']

КПРФ

deps_KPRF_ident, deps_KPRF_full = ident_deps(df['КПРФ'])

# Количество "идентичных" депутатов
len(deps_KPRF_ident)

>>> 0    

Единая Россия

deps_ER_ident, deps_ER_full = ident_deps(df['ЕР'])

# Количество "идентичных" депутатов
len(deps_ER_ident)

>>> 59

# Доля "идентичных" депутатов от общего числа в партии
np.round(len(deps_ER_ident) / len(deps_ER_full), 2)

>>> 0.19

# Список "идентичных" депутатов
deps_ER_ident

>>>

Спойлер
['Азимов Рахим Азизбоевич',
 'Альшевских Андрей Геннадьевич',
 'Аскендеров Заур Асевович',
 'Балыбердин Алексей Владимирович',
 'Бикбаев Ильдар Зинурович',
 'Богуславский Ирек Борисович',
 'Боева Наталья Дмитриевна',
 'Валуев Николай Сергеевич',
 'Ветлужских Андрей Леонидович',
 'Воевода Алексей Иванович',
 'Ганиев Фарит Глюсович',
 'Делимханов Адам Султанович',
 'Дерябкин Виктор Ефимович',
 'Изотов Алексей Николаевич',
 'Ишсарин Рамзил Рафаилович',
 'Каличенко Андрей Владимирович',
 'Канаев Алексей Валерианович',
 'Карпов Анатолий Евгеньевич',
 'Колесников Олег Алексеевич',
 'Кравченко Денис Борисович',
 'Кривоносов Сергей Владимирович',
 'Кувшинова Наталья Сергеевна',
 'Кудрявцев Максим Георгиевич',
 'Левицкий Юрий Андреевич',
 'Макиев Зураб Гайозович',
 'Максимова Светлана Викторовна',
 'Москвин Денис Павлович',
 'Москвичев Евгений Сергеевич',
 'Муцоев Зелимхан Аликоевич',
 'Назарова Наталья Васильевна',
 'Никонов Вячеслав Алексеевич',
 'Огуль Леонид Анатольевич',
 'Окунева Ольга Владимировна',
 'Перминов Дмитрий Сергеевич',
 'Петров Сергей Валериевич',
 'Петров Юрий Александрович',
 'Петрунин Николай Юрьевич',
 'Пилюс Наталия Николаевна',
 'Пирог Дмитрий Юрьевич',
 'Пискарев Василий Иванович',
 'Пушкарев Владимир Александрович',
 'Романенко Роман Юрьевич',
 'Сазонов Дмитрий Валерьевич',
 'Саралиев Шамсаил Юнусович',
 'Скляр Геннадий Иванович',
 'Слыщенко Константин Григорьевич',
 'Смирнов Юрий Валентинович',
 'Солнцева Светлана Юрьевна',
 'Сураев Максим Викторович',
 'Терешкова Валентина Владимировна',
 'Туров Артём Викторович',
 'Тутова Лариса Николаевна',
 'Фокин Александр Иванович',
 'Харсиев Алихан Анатольевич',
 'Хасанов Мурат Русланович',
 'Цыбизова Татьяна Игоревна',
 'Чепиков Сергей Владимирович',
 'Шойгу Лариса Кужугетовна',
 'Ямпольская Елена Александровна']

«Идентичные» депутаты обнаружились в двух из четырех партий, в одной из которых их оказалось целых 59 человек (19%).


2. Кластеризация

from sklearn.metrics import silhouette_score
from sklearn.cluster import KMeans, AgglomerativeClustering
from sklearn.decomposition import PCA
from MulticoreTSNE import MulticoreTSNE as TSNE
import umap

# В основном датафрейме для облегчения последующей кластеризации значения "не голосовал" (2) были приравнены к "воздержался" (0).
for d in df_full.drop(columns=['law_name', 'vote_result']).columns:
    df_full[d] = df_full[d].map(lambda x: 0 if x == 2 else x)

# Транспонирование матрицы для последующей кластеризации
df_t = df_full.transpose().drop(index=['law_name', 'vote_result'])
df_t.iloc[:3, :7]
>>>

X = df_t # Матрица для кластеризации
y = dep_df_cr # Данные о партийной принадлежности

def plot(x1, x2, hue=None, name=None):
    plt.figure(figsize=(10,10))
    sns.scatterplot(x1, x2, hue=hue, palette="bright")
    plt.title(name, fontsize=18)
    plt.show()

Далее отображаем нашу матрицу в двухмерное пространство с помощью PCA, UMAP и t-SNE и смотрим, что получилось.

PCA

pca = PCA(random_state=7)
pca.fit(X)

PCA_transformed = PCA(n_components=2, random_state=7).fit_transform(X)

plot(PCA_transformed[:, 0], PCA_transformed[:, 1], hue=y, name='PCA')

f3119f588e269c2aee47e976417391fb.png

Видно, что PCA справился плоховато, смешав СР и ЛДПР в одну кучу.

t-SNE

tsne = TSNE(n_jobs=-1, random_state=7)
tsne_transformed = tsne.fit_transform(X)

plot(tsne_transformed[:,0], tsne_transformed[:,1], hue=y, name='t-SNE')

8d354ea2a7a10f5f3b1b5cde7b60246e.png

UMAP

reducer = umap.UMAP(random_state=7)
embedding = reducer.fit_transform(X)

plot(embedding[:, 0], embedding[:, 1], hue=y, name='UMAP')

9bf33c0067b1ea3dbc659f0737a20306.png

А вот t-SNE/UMAP сработали отлично, чётко разделив все 4 партии. Глядя на картинки, можно предположить, что сама кластеризация сработает так же успешно, однако, проверим.

Алгоритм k-means

Определим оптимальное количество кластеров методом силуэтного графика:

silhouette = []
for i in range(2,11):
    kmeans = KMeans(n_clusters=i, random_state=7, n_jobs=-1).fit(X)
    labels = kmeans.labels_
    score = silhouette_score(X, labels)
    silhouette.append(score)

plt.plot(range(2,11), silhouette, marker='o')

2de5cbe4dbb59554452ee7e6b655ca2d.png

По графику видно, что максимальное число кластеров, на которое хорошо делятся данные, равно 4.

kmeans_4 = KMeans(n_clusters=4, random_state=7, n_jobs=-1).fit(X)
labels_km_4 = kmeans_4.labels_

plot(tsne_transformed[:,0], tsne_transformed[:,1], hue=labels_km_4, name='t-SNE')

88a3ed12a70f25db16f7dc66457c8d65.png

plot(embedding[:, 0], embedding[:, 1], hue=labels_km_4, name='UMAP')

65b9ce7a3a074a7e07ff10f1a42d8696.png

Как можно видеть, K-means с настройками по-умолчанию отлично справился с задачей кластеризации. Проверял также иерархический алгоритм (AgglomerativeClustering) — не буду приводить код и картинки, т.к. результат аналогичный.


3. Прогнозирование результатов голосования

from sklearn.linear_model import LogisticRegressionCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import GradientBoostingClassifier
from xgboost.sklearn import XGBClassifier
from lightgbm import LGBMClassifier
from sklearn.neighbors import KNeighborsClassifier

from sklearn.model_selection import train_test_split

from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.metrics import roc_auc_score, roc_curve

from nltk.corpus import stopwords
from sklearn.feature_extraction.text import TfidfVectorizer
from pymystem3 import Mystem
from joblib import Parallel, delayed

# Распределение голосований
df_full.vote_result.value_counts()

>>>
принят         12770
отклонен        3328
Рейтинговое      151

# Удаление "рейтинговых" голосований
df_lem = df_full.drop(index=df_full[df_full['vote_result'] == 'Рейтинговое'].index)

# Перекодирование таргета в числовую форму
df_lem.vote_result = df_lem.vote_result.map(lambda s: 1 if s=='принят' else 0)

# Список с текстами вопросов голосования
law_list_p = df_lem.law_name.astype('str').tolist()

Ниже используется кусок кода для ускоренной лемматизации, подсмотренный в этой статье. Спасибо юзерам Codex1 и serhit.

# Функция лемматизации с объединением текстов в один большой (для увеличения скорости работы pymystem3)
def lemma3(text):
    m = Mystem()
    merged_text = "".join(txt + ' br ' for txt in text)

    doc = ''
    res = []

    for t in m.lemmatize(merged_text):
        if t != '\n':
            if t != 'br':
                doc += t
            else:
                res.append(doc)
                doc = ''

    return res

text_batch = [law_list_p[i: i + 1000] for i in range(0, len(law_list_p), 1000)]

# Лемматизация с использованием всех ядер процессора
law_list_p_clean = Parallel(n_jobs=-1)(delayed(lemma3)(t) for t in text_batch)

# Пересобрание списка с текстами вопросов голосований
law_list_p_clean_list = []
for l in law_list_p_clean:
    for b in l:
        law_list_p_clean_list.append(b)

df_law_clean = pd.DataFrame(law_list_p_clean_list)

# Разбиение датасета на train и test
X_train, X_test, y_train, y_test = train_test_split(
    df_law_clean[0], 
    df_lem.vote_result, 
    test_size=0.3,
    stratify = df_lem.vote_result,
    random_state = 7
)    

# Векторизация текстов
v = TfidfVectorizer(stop_words=stopwords.words('russian'), ngram_range=(1, 2), max_df=0.95, min_df=0.002, norm=None)
X_train_tfid = v.fit_transform(X_train).todense()
X_test_tfid = v.transform(X_test).todense()

models = {}

# Логистическая регрессия
models[0] = LogisticRegressionCV()

# Random Forest
models[1] = RandomForestClassifier(n_estimators=400, min_samples_split=3, random_state=7)

# Градиентный бустинг (sklearn)
models[2] = GradientBoostingClassifier(random_state=7)

# XGB
models[3] = XGBClassifier(n_jobs=-1, random_state=7)

# LightGBM
models[4] = LGBMClassifier(n_jobs=-1, random_state=7)

# kNN
models[5] = KNeighborsClassifier(n_neighbors=3)

for m in models:
    models[m].fit(X_train_tfid, y_train)

model_names = {0: 'log_reg', 1: 'RF_clf', 2: 'GB_clf', 3: 'XGB_clf', 4: 'LGBM_clf', 5: 'knn_clf'}

# Функции вывода метрик классификации

def dataframe_quality_metrics(actual, prediction):
    stats = [
        accuracy_score(actual, prediction),
        precision_score(actual, prediction),
        recall_score(actual, prediction),
        f1_score(actual, prediction)
    ]
    return stats

def metrics_all(models, model_names, y_test):
    measured_quality_metrics = pd.DataFrame({"Test_quality":["Accuracy", "Precision", "Recall", "f1_score"]})
    measured_quality_metrics.set_index("Test_quality")

    y_test_baseline = np.array([1]*len(y_test))
    measured_quality_metrics["baseline"] = dataframe_quality_metrics(y_test_baseline, y_test)

    for m in models:
        measured_quality_metrics[model_names[m]] = dataframe_quality_metrics(models[m].predict(X_test_tfid), y_test)

    return measured_quality_metrics

Т.к. в датасете присутствует некоторый дисбаланс классов (результат около 80% голосований — «принят»), в качестве простейшего бейзлайна был взят предиктор, выдающий всегда значение «принят».

# Вывод метрик
metrics_all(models=models, model_names=model_names, y_test=y_test)

Судя по метрикам, все алгоритмы классификации справились хорошо. Исключением стал kNN, который тяжело переваривает с датасеты с таким огромным количеством признаков (напомню, в данном случае — 16 тыс.) Думаю, потратив время на хорошую кросс-валидацию, можно было бы вытянуть ещё пару процентов, но так как данная работа носила скорее шуточный характер, а времени было жалко, я остановился на этом результате.

Под спойлером также можно посмотреть ROC-кривые, а также степень влияния определённых слов на результат предсказания для модели логистической регрессии.


Спойлер
# Функция построение ROC-кривых для каждой модели
def plot_roc_curve_all(models, model_names, y_test):
    i = 1
    ax = {}
    row_len = 3
    nrows = len(models) // row_len + 1

    plt.figure(figsize=(20,18))    

    for m in models:
        prob_prediction = models[m].predict_proba(X_test_tfid)[:,1]

        fpr, tpr, thresholds = roc_curve(y_test, prob_prediction)
        auc_score = roc_auc_score(y_test, prob_prediction)

        ax[i] = plt.subplot(nrows, row_len, i)
        ax[i].plot(fpr, tpr, label='ROC curve ')
        ax[i].plot([0, 1], [0, 1])
        ax[i].set_xlim([0.0, 1.0])
        ax[i].set_ylim([0.0, 1.05])
        ax[i].set_xlabel('False Positive Rate')
        ax[i].set_ylabel('True Positive Rate')
        ax[i].set_title('{} ROC AUC: {:.3f}'.format(model_names[m], auc_score))
        i += 1

    plt.show()

plot_roc_curve_all(models=models, model_names=model_names, y_test=y_test)

315f9119c304dfc62d3098fbd17e4c0c.png

df = pd.DataFrame(X_train_tfid, columns=v.get_feature_names())

# Слова/словосочетания, положительно влияющие на прогноз результата "принят"
featureImportance = pd.DataFrame({"feature": df.columns, 
                                  "importance": models[0].coef_[0]})

featureImportance.set_index('feature', inplace=True)
featureImportance.sort_values(["importance"], ascending=False, inplace=True)
featureImportance["importance"][:30].plot(kind='bar', figsize=(18, 6))

41424fe23401b815dbffcca4bffce2f3.png

# Слова/словосочетания, отрицательно влияющие на прогноз результата "принят"
featureImportance = pd.DataFrame({"feature": df.columns, 
                                  "importance": models[0].coef_[0]})

featureImportance.set_index('feature', inplace=True)
featureImportance.sort_values(["importance"], ascending=True, inplace=True)
featureImportance["importance"][:30].plot(kind='bar', figsize=(18, 6))

bccaf16decfce3a3386521c760c0e726.png

Основное влияние оказывают технические термины. Например, если рассматривается второе или третье чтение законопроекта, вероятность его принятия повышается, а если первое, либо вопрос вынесен на голосование каким-либо депутатом — понижается. Из графиков видно, что также имеют значение слова, отражающие тематику рассматриваемых законопроектов.


Выводы

Пройдусь вкратце по вопросам, поставленным в начале работы.


  1. Степень корреляции голосов депутатов внутри всех партий оказалась довольно высокой, значения коэффициента Пирсона колебались в диапазоне 0,91–0,999. При этом в двух партиях обнаружились депутаты, голос которых во всех голосованиях совпадает с мнением партийного большинства (вариант «воздержался» не учитывался).
  2. Два алгоритма кластеризации (k-means и hierarhical), имея только информацию о результатах голосования каждого депутата, отлично справились с автоматическим распределением всех депутатов по их партиям.
  3. Отталкиваясь от текста рассматриваемого вопроса голосования, классические ML-модели без какой-либо серьёзной настройки позволяют угадывать итог голосования с точностью около 90% (максимальные Accuracy — 0,9, f1-score — 0,94, ROC AUC — 0,96).

Благодарю Дмитрия Сергеева и Дмитрия Головина за помощь в подготовке публикации.


Также приглашаю всех желающих записаться на Demo Day курса «Промышленный ML на больших данных». В рамках вебинара вы сможете познакомиться с экспертами OTUS, подробно узнать о курсе, а также о процессе обучения.


Всем спасибо за внимание!

© Habrahabr.ru