Государственные перевороты: бармалеи выпрыгивают как черти из табакерки. Не хотите, дети, в Африку сыграть?

На исторических данных за 1991–2019 год покажем, как можно «увидеть» и «выцепить» признаки переворота.  С помощью машинного обучения и ансамблевых модели. Ансамбли (конечно, не музыкальные), как показывает практика, — более эффективны в таких делах, и самое главное —  хорошо «тюнятся» и «чипуются».

*Nota Bene (та Bene, что ни разу не гессерит). При всем негативном отношении к революциям, переворотам и прочим событиям в любой части мира, это — объективная реальность, которую можно не только изучать, но и предупреждать.

Когда реальность интереснее игры

В различных пошаговых или реал-тайм стратегиях от «Цивилизации» до серии «Total war» и им подобных всегда есть место кризисам — запрограммировано внезапным событиям, рушащим прежнюю стратегию игрока. Изобретение нового оружия, технологии, вторжения племен, как и революции, расколы и гражданские войны могут спутать все карты.

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

К сожалению, не одна игра не показывает истинные причины появления «бармалеев», хотя они как мир — просты. Классическое «перепроизводство» элиты, а особенно резкое увеличение пограничного слоя стремящихся в нее попасть. И все это на фоне резкого сужения «экономического» пирога в обществе/падения прироста ВВП по причине неурожая, наводнения, эпидемии, падения астероида, вулкана, ошибки федерально резервной системы со ставкой, нашествия рептилоидов и восстания ИИ — нужное подчеркнуть.

Остатки «пирога» возросшая в численности элита и нарождающаяся контрэлита начинают активно делить. И ладно бы они отбирали все у среднего класса, увеличивая социальное неравенство, расслоение и общую бедность, но они начинают активно выяснять отношения друг с другом, растягивать и поляризовывать общество, «пифпафить» политиков и кандидатов в политики (ничего не напоминает, а?).

При высоком уровне солидарности и сплоченности, наличии креативной функции у элиты они скорее всего бы помирились и поплыли открывать Америку/осваивать Марс. Но что, если соседи сильные и смогли дать отпор, а до Марса все-таки дальше, чем до канадской границы?  С падением креативной функции элиты, солидарности, объединяющих начал, конкурентам придётся грызть друг друга, как паукам в банке. (Большинство санкций, кстати, вводится геополитическими конкурентами для провоцирование именно этого фактора, а не из миролюбивых и душеспасительных идеалов).

В прочем, отставить теоретизацию и морализацию! Как и в случае с революционными кризисами, будем решать задачу  детекции переворотов «в лоб» при помощи «черного ящика», без опоры на теории и абстрактные выводы, то есть предобученные модели. И только по результатам эксперимента подведем нужные теоретические обобщения.

Некоторые спросят, а почему  только 1991–2019 годы? Почему не заглянуть во вторую половину 20-ых или 30-ые. Заранее скажем честно, иногда лучше тренироваться на «котиках»! В нашем случае на CatBoost-ах.

Источники данных и термины

Мы  по прежнему будем использовать базу данных  The Cross National Time Series (CNTS), которая  содержит годовые значения около 200 переменных для более чем 200 стран, начиная с 1815 г. за исключением периодов двух мировых войн 1914–1918 и 1939–1945 гг. База структурирована по разделам и включает статистические данные по территории и населению страны, информацию по использованию технологий, экономические и электоральные данные, информацию по внутренним конфликтам, использованию энергии, промышленной статистике, по военным расходам, международной торговле, урбанизации, образованию, занятности, деятельности законодательных органов и т.п. 

Более точные и перспективные модели, естественно, должны учитывать больший объем источников, временные лаги, зависимости между показателями и их разный вес. Но у нас «котик» — иллюстрация принципа модели, а не полномасштабная система предиктивной аналитики и поддержки принятия решений в политической отрасли, основанная на гибридном, то есть человеко-машинном искусственном интеллекте.

В данной базе мы будем смотреть показатель polit03, связанный с «количеством внеконституционных или насильственных изменений в высшем правительстве элиты и/или ее эффективный контроль над структурой власти нации в данном год. Термин «переворот» включает, но не исчерпывается, термином «успешная революция». Неудачные перевороты идут не в счет и не учитываются. Для простоты оценки, мы будем использовать двухклассовую классификацию по принципу: был переворот — не было переворота в течение периода времени. При том что в реальности за год может быть не один, а то и два и более переворотов.

Успешный удавшийся государственный переворот — это достаточно редкое событие по сравнению с другими кризисами. В выборке за указанный период зафиксировано всего 68 эпизодов или 1,2%. Фактически перед нами скорее задача детекции аномалий.  

Точно аномалия?

Для проверки гипотезы об аномалии попробуем IsolationForest с подборкой гиперпараметров.

Тюним алгоритм под f1

from sklearn.metrics import  f1_score
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
import optuna

def objective(trial):
    contamination = trial.suggest_float('contamination', 0.01, 0.1)
    max_samples = trial.suggest_int('max_samples', 100, 500)
    
    model = IsolationForest(contamination=contamination, max_samples=max_samples, random_state=42)
    model.fit(X_train)

    y_pred = model.predict(X_test)
    # Преобразуем -1, 1 в 0, 1
    y_pred = np.where(y_pred == -1, 1, 0)

    return f1_score(y_test, y_pred)

  # Настройка и запуск Optuna
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)

И получаем следующие результаты на подобранных гиперпараметрах IsolationForest(contamination=0.05806623546201597, max_samples=432, random_state=42)

97fa1f74c25c83a1f505ce148ce8eecf.png

Результаты, честно не впечатляют. Таким образом мы не предскажем переворот. Разумеется, можно попробовать и более сложные алгоритмы. Но что, если наше предположение о «аномальности переворотов» — неверно. И они — вполне себе закономерное событие. Просто редкое и возникающее при определенном сочетании факторов?

Товарищ маршал, запускайте котиков!

Товарищ котомаршал!

Товарищ котомаршал!

Ситуация сложная и мы опять используем ансамблевые модели машинного обучения вместо основных. «Котики» будет использоваться в базовой настройке для оценки их исходных метрик и скорости.  В их числе:  AdaBoostClassifier, BaggingClassifier, ExtraTreesClassifier, GradientBoostingClassifier, RandomForestClassifier, StackingClassifier, VotingClassifier, HistGradientBoostingClassifier, CatBoostClassifier, lgb.LGBMClassifier, xgb.XGBClassifier.

Метрики оценки моделей — стандартные:

Accuracy — доля правильных предсказаний (истинно положительных и истинно отрицательных) от общего числа предсказаний.

Precision — доля истинно положительных результатов среди всех предсказанных положительных. Показывает, насколько положительные предсказания верны.

Recall — доля истинно положительных результатов среди всех реальных положительных. Показывает, насколько хорошо модель находит положительные примеры.

F1_score — гармоническое среднее Precision и Recall. Используется для балансировки между ними, особенно в случае несбалансированных классов.

ROC_AUC — (Площадь под ROC-кривой): мера качества модели, показывает, насколько хорошо модель различает положительный и отрицательный классы. AUC (Area Under the Curve) равен 1 для идеальной модели и 0.5 для случайного гадания.

Ансамбли «котиков»

#подключаем ансамблевые модели МL
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, roc_curve
from sklearn.ensemble import AdaBoostClassifier, BaggingClassifier, ExtraTreesClassifier, GradientBoostingClassifier, RandomForestClassifier, StackingClassifier, VotingClassifier, HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from  catboost import CatBoostClassifier
import lightgbm as lgb
import xgboost as xgb

# Создаем модели в базе
models = {
    'AdaBoost': AdaBoostClassifier(random_state = 42),
    'Bagging': BaggingClassifier(random_state = 42),
    'ExtraTrees': ExtraTreesClassifier(random_state = 42),
    'GradientBoosting': GradientBoostingClassifier(random_state = 42),
    'RandomForest': RandomForestClassifier(random_state = 42),
    'Stacking': StackingClassifier(estimators=[('lr', LogisticRegression(random_state = 42)), ('rf', RandomForestClassifier(random_state = 42))]),
    'Voting': VotingClassifier(estimators=[('lr', LogisticRegression(random_state = 42)), ('rf', RandomForestClassifier(random_state = 42))]),
    'HistGradientBoosting': HistGradientBoostingClassifier(random_state = 42),
    'CatBoost': CatBoostClassifier(random_state = 42, verbose = 0),
    'lightgbm': lgb.LGBMClassifier(random_state=42),
    'xgboost': xgb.XGBClassifier(random_state=42)
    }

# Создаем словарь для метрик
metrics = {
    'Accuracy': accuracy_score,
    'Precision': precision_score,
    'Recall': recall_score,
    'F1_score': f1_score,
    'ROC_AUC': roc_auc_score
    }

# Обучаем модели на базовых настройках и делаем предсказания
predictions = {}
for name, model in models.items():
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    predictions[name] = y_pred

# Вычисляем метрики для каждой модели
results = {}
for name, y_pred in predictions.items():
    results[name] = [metric(y_test, y_pred) for metric in metrics.values()]

# Выводим сравнительную таблицу с метриками
print("Метрики для различных моделей:")
print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<10}".format("Модель", "Accuracy", "Precision", "Recall", "F1_score", "ROC_AUC"))
for name, metrics in results.items():
    print("{:<20} {:<10.4f} {:<10.4f} {:<10.4f} {:<10.4f} {:<10.4f}".format(name, *metrics))

Почему так много ансамблей? — для прояснения одной очень специфической вещи. Существует убеждение, что использование синтетических данных для балансировки классов (положительный класс у нас только 1,2% от выборки) в сложных ансамблевых моделях не требуется. И не приводит к улучшению метрик. Вот что мы получили в результате.

Таблица 1. Метрики без оверсемплинга и балансировки синтетическими данными

Модель Accuracy Precision Recall F1_score ROC_AUC
AdaBoost 0.9871 0.5455 0.2609 0.3529 0.6289
Bagging 0.9871 0.5385 0.3043 0.3889 0.6504
ExtraTrees 0.9865 0.0000 0.0000 0.0000 0.5000
GradientBoosting 0.9842 0.4000 0.3478 0.3721 0.6703
RandomForest 0.9865 0.0000 0.0000 0.0000 0.5000
Stacking 0.9865 0.0000 0.0000 0.0000 0.5000
Voting 0.9865 0.0000 0.0000 0.0000 0.5000
HistGradientBoosting 0.9889 0.7000 0.3043 0.4242 0.6513
CatBoost 0.9871 0.5556 0.2174 0.3125 0.6075
lightgbm 0.9889 0.7500 0.2609 0.3871 0.6298
xgboost 0.9894 0.7273 0.3478 0.4706 0.6730

*Небольшой комментарий по ROC_AUC

В табличках ROC_AUC считали для наглядности как:

roc_auc_score (y_test, y_pred)

Но дальше будем использовать также:

y_prob = model_X.predict_proba (X_test)[:, 1]

roc_auc_score (y_test, y_prob)

Таблица 2. Метрики после проходки тренинговой выборки с ADASIN

Модель Accuracy Precision Recall F1_score ROC_AUC AdaBoost 0.9836 0.3684 0.3043 0.3333 0.6486 Bagging 0.9900 0.7143 0.4348 0.5405 0.7162 ExtraTrees 0.9859 0.4000 0.0870 0.1429 0.5426 GradientBoosting 0.9889 0.6000 0.5217 0.5581 0.7585 RandomForest 0.9894 0.7273 0.3478 0.4706 0.6730 Stacking 0.9889 0.6250 0.4348 0.5128 0.7156 Voting 0.9889 0.7500 0.2609 0.3871 0.6298 HistGradientBoosting 0.9912 0.7500 0.5217 0.6154 0.7597 CatBoost 0.9894 0.6190 0.5652 0.5909 0.7802 lightgbm 0.9894 0.6471 0.4783 0.5500 0.7373 xgboost 0.9894 0.6471 0.4783 0.5500 0.7373

Мы перестали использовать оверсемплинг RandomOverSampler из раздела модуля imblearn.over_sampling . Для генерации синтетических данных применили ADASIN, который работает аналогично SMOTE, но с добавлением адаптивности, уделяет больше внимания классам, которые более затруднительны для классификации, генерируя больше синтетических примеров для таких классов. Естественно синтетикой мы дополняем X_train и y_train. Тестовая выборка остается без изменений — это святое!

По факту оказалось, что без балансировки классов некоторые модели у нас вообще не смогли справиться с поставленной задачей. После балансировки метрики качества моделей выросли, особенно связанные с детекцией положительного класса, а это recall и взвешенный f1.

Быстрый киберпанк-техно-котик LGBMClassifier

Быстрый киберпанк-техно-котик LGBMClassifier

Но, будучи максималистами, мы понимаем, что полученных показателей качества недостаточно для полноценного прогноза. Поэтому мы запускаем тюнинг очень быстрого и проворного «техно-котика», а именно: алгоритм LGBMClassifier

Этого котика мы как следует «чипанем» с помощью optuna по метрике f1

И где-то после 47 проходки, с гиперпараметрами boosting_type = 'gbdt', num_leaves = 98, max_depth = -1, learning_rate = 0.0010474112385068781, n_estimators = 334, subsample = 0.9622375189384493, colsample_bytree = 0.6506877830660953, получился пристойный результат.

Тюнинг LGBMClassifier под f1

# Оптимизационная функция для Optuna
def objective(trial):
    # Подбор гиперпараметров
    param = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'verbosity': -1,
        'boosting_type': trial.suggest_categorical('boosting_type', ['gbdt', 'dart', 'goss']),
        'num_leaves': trial.suggest_int('num_leaves', 20, 150),
        'max_depth': trial.suggest_int('max_depth', -1, 20),
        'learning_rate': trial.suggest_loguniform('learning_rate', 1e-4, 1),
        'n_estimators': trial.suggest_int('n_estimators', 50, 500),
        'subsample': trial.suggest_float('subsample', 0.5, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0)
    }

    # Создание и обучение модели
    model = lgb.LGBMClassifier(**param)
    model.fit(X_train, y_train)

    # Предсказания и оценка
    y_pred =  model.predict(X_test)
    y_prob =  model.predict_proba(X_test)[:, 1]  # Получаем вероятности для положительного класса
    
    recall = recall_score(y_test, y_pred)
    precision = precision_score(y_test, y_pred)
    accuracy = accuracy_score(y_test, y_pred)
    roc_auc = roc_auc_score(y_test, y_prob)
    f1 = f1_score(y_test, y_pred)
    
    return f1

Согласитесь, что по сравнению с «деревянным» IsolationForestполучен просто замечательный результат. «Котик» очень хорошо, а самое главное быстро справился с задачей быстрого прогноза о том, будет какой-либо далекой стране переворот в 13 случаев или не будет. Упустил он 10 событий и, обжегшись на молоке, подул на воду в 7 случаях.

roc_auc_score(y_test, y_prob)

roc_auc_score (y_test, y_prob)

Вот и наш любимый ROC_AUC. Для того, чтобы сравнить с табличной метрикой 0.7373 используем другой способ подсчета, без вероятностей roc_auc_score(y_test, y_pred)

От 0.7373 до 0.7805 — очень приличный прирост. Так что тюнинговать ансамблевые модели очень даже стоит!

Неплохо приросли и другие метрики качества модели.

Таблица 3. Отчет о классификации «чипованого» LGBMClassifier

precision

recall

f1-score

support

0

0.994069

0.995841

0.994954

1683

1

0.650000

0.565217

0.604651

23

accuracy

0.990035

macro avg

0.822034

0.780529

0.799803

1706

weighted avg

0.989430

0.990035

0.989692

1706

А можно ли еще повысить точность прогнозов и другие метрики?

Вусмерть чипованный товарищ CatBoost

Вусмерть чипованный товарищ CatBoost

Да, но нам придется выпустить самого мощного (из доступных) тяжеловооруженного и тяжелобронированного «котика». Тюнить его придется долго — в зависимости от модели ПК и ресурсов — от нескольких часов до нескольких дней. И тут придется именно «оптунить», поскольку на том же «гридсерче» можно будет помереть от ожидания, что даже n_jobs=-1 — не поможет. Встречайте отечественного «импортокотозаместителя» от Яндекс — CatBoost.

Чипуем CatBoost под f1

def objective(trial):
    # Подбор гиперпараметров
    iterations = trial.suggest_int('iterations', 100, 1000)
    learning_rate = trial.suggest_loguniform('learning_rate', 0.01, 0.3)
    depth = trial.suggest_int('depth', 4, 10)
    l2_leaf_reg = trial.suggest_float('l2_leaf_reg', 1e-1, 10.0)
    subsample = trial.suggest_float('subsample', 0.5, 1.0)
    eval_metric = trial.suggest_categorical('eval_metric', ['F1'])

    # Создание и обучение модели
    model = CatBoostClassifier(
        iterations=iterations,
        learning_rate=learning_rate,
        depth=depth,
        l2_leaf_reg=l2_leaf_reg,
        subsample=subsample,
        eval_metric=eval_metric,
        random_seed=42,
        verbose=0
    )
    
    model.fit(X_train, y_train)
    
    # Предсказание
    y_pred = model.predict(X_test)
    
    # Вычисление F1-score
    f1 = f1_score(y_test, y_pred)
    
    return f1

# Создание исследования Optuna
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)

Как следует подапгрейженный CatBoostClassifier(iterations = 824, learning_rate = 0.08829916843620536, depth = 10, l2_leaf_reg = 4.550158930547992, subsample = 0.6974953311160857, random_state = 42,verbose = 0) показал нам весьма приличные результаты.

01c925b76fc20ddf368383aba799d8ea.png

«Чиповка» CatBoost дала нам прирост с 0.7802 до 0.8028 по ROC_AUC на табличном значении и взвешенный f1 с 0.5909 до 0.6667 что выше чем у LGBMClassifier.

Соответственно, возросли и другие метрики качества — см. таблицу.

Таблица 3. Отчет о классификации «чипованого» CatBoost

precision

recall

f1-score

support

0

0.994665

0.997029

0.995846

1683

1

0.736842

0.608696

0.666667

23

accuracy

0.991794

macro avg

0.865754

0.802862

0.831256

1706

weighted avg

0.991189

0.991794

0.991408

1706

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

Импотенс фичей

4bbed03b8fc5a0bf89874a3f09e22e48.png

Главный фактор — это изменение баланса сил в исполнительной власти. Те самые межэлитные споты и диспропорции, приводящие к падению эффективности исполнительной власти. Второй фактор — эффективность законодательной власти, насколько она объективна и независима от исполнительной, принимает законы соответствующие текущим обстоятельствам. Третий фактор — крупные конституционные изменения. Основной закон государства — это его становой хребет. Модернизировать его изредка стоит, но не слишком часто и не «с размаху». Четвертый фактор — тип политического режима (совокупность средств и методов, с помощью которых осуществляется политическая власть в государстве). Пятый — экономический фактор, связанный с производством электроэнергии. Шестой фактор — партийные коалиции и их эффективность (как оппоненты могут договариваться и объединяться). Седьмой фактор — распространение интернет. Восьмой и девятый факторы — производство энергии и ВВП (наш «экономический пирог»). Десятый фактор — легитимность (и ее признание) политической партии, особенно той, которая находится у руля. И так далее.

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

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

реализация метода удаления признаков

def calculate_weight_importance(model, X, y):
    weight_importances = []
    original_weights = model.coef_.copy()
    for idx in range(original_weights.shape[0]):
        new_weights = np.delete(original_weights, idx, axis=0)
        new_model = model.__class__(**{key: val for key, val in model.get_params().items() if key != 'coefs'})
        new_model.coefs_ = new_weights
        score_change = cross_val_score(new_model, X, y, cv=5).mean() - cross_val_score(model, X, y, cv=5).mean()
        weight_importances.append((idx, abs(score_change)))
    return sorted(weight_importances, key=lambda x: x[1], reverse=True)

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

Бро, мне нужны твоя мышка и ноутбук! И не забудь про пирог с чаем!

Бро, мне нужны твоя мышка и ноутбук! И не забудь про пирог с чаем!

© Habrahabr.ru