Мы в ПГК занимаемся грузоперевозками, причем решаем различные транспортные задачи не только методами математической оптимизации, но и с помощью моделей машинного обучения. Наши дата-сайентисты проводят десятки экспериментов — в том числе и без необходимости прибегать к инструментам логирования вроде MLflow. В этом им помогает компактный Python-класс. Расскажем, как он устроен, и поделимся кодом.

Фотография David Clode / Unsplash
Фотография David Clode / Unsplash

Мы активно используем машинное обучение для разработки решений, упрощающих жизнь наших сотрудников. Например, у нас есть «Оптимизатор ремонтов», который помогает составлять план ремонта вагонов и в то же время ранжировать депо по стоимости работ. Также мы используем «Прогнозатор», который предсказывает объёмы грузоперевозок между ж/д станциями для последующего планирования продаж.

Поскольку мы работаем с десятками различных моделей, нам необходимо вести учет переменных, управлять метриками, сравнивать прогнозы. Да, для версионирования экспериментов мы используем MLflow, который предлагает мощные специализированные возможности. Мы уже рассказывали об этом инструменте у себя в блоге. Однако его внедрение и поддержка подразумевает работу с инфраструктурой для логирования и мониторинга ML-экспериментов, а также управления кодом.

На простых задачах и небольших проектах не всегда нужно сложное решение для управления версиями. Если есть необходимые данные, а задача понятна, то прототип вполне можно «набросать» за один стандартный двухнедельный спринт. Иными словами, в контексте быстрого прототипирования, когда нужно просто проверить гипотезу, MLflow — это определённо overkill.

Всего один класс

Чтобы помочь дата-сайентистам разрабатывать прототипы и тестировать гипотезы [причем делать это локально], мы разработали компактный инструмент MLExperimentManager. Это — Python-класс, который предоставляет интерфейс для загрузки данных, обучения моделей, оценки их качества и логирования результатов экспериментов. Скрипт делает процесс исследования и сравнения различных ML-моделей более организованным и систематизированным.

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_curve, auc, confusion_matrix
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
import seaborn as sns
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

class MLExperimentManager:
    def __init__(self):
        self.data = None
        self.X_train, self.X_test, self.y_train, self.y_test = (None,) * 4
        self.model = None
        self.experiments_log = None
        self.log_path = 'experiments_log.csv'

    def load_data(self, dataset):
        self.data = dataset
        X = pd.DataFrame(self.data.data, columns=self.data.feature_names)
        y = pd.Series(self.data.target)
        self.X_train, self.X_test, self.y_train, self.y_test = train_test_split(X, y, test_size=0.2, random_state=42)
        print("Data loaded successfully.")

    def train_model(self, classifier='RandomForest'):
        if classifier == 'RandomForest':
            self.model = RandomForestClassifier(n_estimators=100, random_state=42)
        elif classifier == 'LogisticRegression':
            self.model = LogisticRegression(max_iter=200, random_state=42)
        elif classifier == 'SVC':
            self.model = SVC(probability=True, random_state=42)
        else:
            raise ValueError(f"{classifier} is not supported")
        self.model.fit(self.X_train, self.y_train)
        print(f"Model training completed using {classifier}.")

    def evaluate_model(self, metric='accuracy'):
        predictions = self.model.predict(self.X_test)
        if metric == 'accuracy':
            result = accuracy_score(self.y_test, predictions)
        elif metric == 'precision':
            result = precision_score(self.y_test, predictions, average='macro')
        elif metric == 'recall':
            result = recall_score(self.y_test, predictions, average='macro')
        elif metric == 'f1':
            result = f1_score(self.y_test, predictions, average='macro')
        else:
            raise ValueError(f"{metric} is not a supported metric")
        print(f"Model {metric}: {result}")
        return result

    def log_experiment_to_csv(self, experiment_id, model_params, data_path, result, metric):
        if self.experiments_log is None:
            self.experiments_log = pd.DataFrame({
            'ExperimentID': experiment_id,
            'Comment': 'just_test',
            'Model': self.model.__class__.__name__,
            'ModelParams': str(model_params),
            'DataPath': data_path,
            'Metric': metric,
            'Result': result
        }, index=[0])
        else: 
            self.experiments_log = pd.concat([self.experiments_log, 
                                             pd.DataFrame({
                                            'ExperimentID': experiment_id,
                                            'Comment': 'just_test',
                                            'Model': self.model.__class__.__name__,
                                            'ModelParams': str(model_params),
                                            'DataPath': data_path,
                                            'Metric': metric,
                                            'Result': result
                                            }, index=[self.experiments_log.index[-1]+1])], ignore_index=True
                                            )
            
        log_df = pd.DataFrame(self.experiments_log)
        log_df.to_csv(self.log_path, index=False)
        print(f"Experiment {experiment_id} logged successfully to {self.log_path}.")
   
    # актуально только для бинарной классификации
    def plot_roc_curves(self):
        if hasattr(self.model, "predict_proba"):
            probas_ = self.model.predict_proba(self.X_test)
            fpr, tpr, thresholds = roc_curve(self.y_test, probas_[:, 1])
            roc_auc = auc(fpr, tpr)

            plt.figure()
            plt.plot(fpr, tpr, color='darkorange', lw=2, label='ROC curve (area = %0.2f)' % roc_auc)
            plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
            plt.xlim([0.0, 1.0])
            plt.ylim([0.0, 1.05])
            plt.xlabel('False Positive Rate')
            plt.ylabel('True Positive Rate')
            plt.title('Receiver operating characteristic example')
            plt.legend(loc="lower right")
            plt.show()
        else:
            print("The current model does not support probability predictions.")

    def plot_results(self):
        # Предсказываем классы на тестовом наборе
        y_pred = self.model.predict(self.X_test)
        
        # Считаем матрицу ошибок
        conf_mat = confusion_matrix(self.y_test, y_pred)
        
        # Визуализируем матрицу ошибок
        sns.heatmap(conf_mat, annot=True, fmt='d', cmap='Blues',
                    xticklabels=self.data.target_names, yticklabels=self.data.target_names)
        plt.xlabel('Predicted labels')
        plt.ylabel('True labels')
        plt.title('Confusion Matrix')
        plt.show()

MLExperimentManager включает несколько методов:

load_data. Метод отвечает за загрузку данных в формате Parquet/CSV и предоставляет удобный интерфейс для их предварительной обработки — например, удаления пропусков. В то же время он делит данные на обучающий и тестовый наборы. Модель позволяет проводить тесты на данных, находящихся за горизонтом обучения — то есть на выборке out-of-time (ООТ). Иными словами, её работу можно проверять на данных, которые она вообще не видела.

train_model. Метод предназначен для обучения модели. Он поддерживает самые разные алгоритмы — градиентный бустинг, случайный лес, логистическую регрессию, метод опорных векторов. Параметры выбранной модели можно оптимизировать — например, изменить максимальную глубину дерева или модифицировать количество деревьев в случайном лесе, а также поменять коэффициент регуляризации в логистической регрессии.

evaluate_model. Метод для оценки качества модели. Он поддерживает четыре метрики: accuracy, precision, recall и f1. Это — наиболее распространённые метрики для решения задач классификации, но при желании их количество можно расширить.

log_experiment_to_csv. Метод отвечает за сохранение результатов эксперимента. Они записываются в таблицу, которая хранится локально. Все записи содержат идентификатор эксперимента, комментарий, информацию об использованной модели, данные, ссылку на сэмпл, метрику, величину этой метрики на выборке out-of-time и параметры.

plot_roc_curves. Метод для визуализации фактических и предсказанных результатов. Он позволяет наглядно оценить эффективность модели, в том числе коллегам из бизнес-подразделений.

Вы можете свободно применять класс MLExperimentManager в своих проектах, выбирая необходимые параметры в методах. Лишь небольшое замечание: в текущем виде скрипт хорошо подходит для решения задач классификации. Если нужно решать задачи регрессии, код потребует доработок. Придется модифицировать функцию train_model, добавив в нее алгоритмы для решения задач регрессии, и отказаться от функции plot_roc_curves для визуализации результатов. Также потребуется изменить метрики в evaluate_model и переписать функцию plot_results под расчеты ошибок для задач регрессии (например МАЕ, МАРЕ, MSE).

Практика

Покажем, как использовать MLExperimentManager на примере знаменитого набора данных Iris из библиотеки scikit-learn. Он содержит информацию о трёх видах ирисов (Iris setosa, Iris virginica и Iris versicolor), и наша цель — обучить модель машинного обучения для классификации видов на основе четырёх характеристик: длины и ширины лепестков и чашелистиков. Начнем с подготовки окружения: импортируем необходимые библиотеки и наш класс MLExperimentManager, а также загрузим данные.

from sklearn.datasets import load_iris
from MLExperimentManager import MLExperimentManager
iris = load_iris()

Проведем инициализацию и сформируем экземпляр класса MLExperimentManager. Загрузим в него данные Iris, и скрипт автоматически разделит их на обучающую и тестовые выборки.

manager = MLExperimentManager()
manager.load_data(iris)

Далее, перейдем к тренировке модели: manager.train_model. Для примера обучим три различные модели: случайный лес (RandomForest), логистическую регрессию (LogisticRegression) и метод опорных векторов (SVC), чтобы сравнить их эффективность.

Обучаем случайный лес:

manager.train_model(classifier='RandomForest')
manager.evaluate_model(metric='accuracy')

Обучаем логистическую регрессию:

manager.train_model(classifier='LogisticRegression')
manager.evaluate_model(metric='accuracy')

Обучаем метод опорных векторов:

manager.train_model(classifier='SVC')
manager.evaluate_model(metric='accuracy')

Для оценки качества воспользуемся manager.evaluate_model с метриками accuracy, precision, recall и f1. Сразу запишем результаты в файл CSV:

accuracy = manager.evaluate_model(metric='accuracy')
manager.log_experiment_to_csv(experiment_id=1, model_params=manager.model.get_params(), data_path="iris_dataset", result=accuracy, metric='accuracy')
precision = manager.evaluate_model(metric='precision')
manager.log_experiment_to_csv(experiment_id=2, model_params=manager.model.get_params(), data_path="iris_dataset", result=precision, metric='precision')
f1 = manager.evaluate_model(metric='f1')
manager.log_experiment_to_csv(experiment_id=3, model_params=manager.model.get_params(), data_path="iris_dataset", result=f1, metric='f1')

Посмотрим, какая информация записалась в таблицу:

Используем метод plot_roc_curves для визуализации матрицы ошибок, чтобы лучше понять, как модель классифицирует различные виды ирисов:

manager.plot_results()

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

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

Комментарии (2)


  1. danilovmy
    07.05.2024 20:54

    Привет, спасибо за примеры.

    Один вопрос, если на инициализации класса присваиваются константы, почему не сделать их атрибутами класса? Как то так:

    class MLExperimentManager:
        data = None
        X_train = X_test = y_train = y_test = None
        model = None
        experiments_log = None
        log_path = 'experiments_log.csv'

    По коду можно много поправить, но если работает, то, разумеется, лучше не трогать. ;)


    1. FreightOne Автор
      07.05.2024 20:54
      +1

      Спасибо за обратную связь! Да, вы правы, действительно можно было константы задать в качестве атрибутов самого класса, но и без этого всё корректно работает :)