ВВЕДЕНИЕ

В этой статье я представляю простую реализацию платформы PyTorch для решения задачи классификации изображений. В этом проекте используется набор данных изображений Intel. Я написал короткую статью, чтобы представить и поработать над этапами предварительной обработки изображений этого набора данных здесь. Эта статья является продолжением, посвященным этапам построения модели, обучения и логического вывода, с рефакторингом кода в правильную структуру репозитория. Вы также можете найти репозиторий GitHub этого проекта ниже.

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



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



II. ПАПКА ПРОЕКТА ОРГАНИЗАЦИЯ

Мы будем следить за организацией проекта ниже:

  • data — это место, куда мы поместили, как вы уже догадались, наши данные. Необработанные данные обучения, тестирования и прогнозирования находятся в подпапке 01_raw. Другие выходные данные наших кодов, такие как файлы промежуточных аннотаций csv, обученная модель и отчеты о производительности модели, также хранятся в папке data.
  • notebooks — это место, где мы храним нашу экспериментальную записную книжку Jupyter. Цель состоит в том, чтобы сделать этот блокнот как можно более чистым: никаких пользовательских функций/классов… Он предназначен для импорта всех необходимых библиотек и пользовательских модулей и выполнения экспериментальных шагов максимально простым способом.
  • src — это место, где мы определяем все наши модули. config хранит необходимые константы для простоты повторного использования и будущих изменений в одном месте. model хранит наш класс модели CNN и класс набора данных, preprocessing и postprocessing хранит utils файлы со вспомогательными функциями для подготовки данных для обучения, а также для получения результатов производительности и экспорта обученной модели.
  • И последнее, но не менее важное: виртуальная среда (созданная с помощью Python 3.9, отсюда и название) обычно размещается снаружи в корневой папке (эта папка не синхронизируется с GitHub, если вам нужна помощь в создании собственного venv, см. мою другую статью ниже).
+---data
|   +---01_raw
|   +---02_intermediate
|   +---03_model_input
|   +---04_model
|   +---05_model_output
|   \---06_reporting
+---notebooks
+---src
|   +---config
|   +---model
|   +---postprocessing
|   \---preprocessing
\---venv39


III. ДЕТАЛИ СПЕЦИАЛЬНЫХ МОДУЛЕЙ

Теперь давайте кратко рассмотрим пользовательские модули.

1. Модуль конфигурации

Прежде всего, содержимое файла конфигурации указано ниже. Первый файл data_config.py определяет входную форму изображений, которые позже будут загружены в модель CNN, вместе с параметрами обучения: размер пакета и количество рабочих. Любые изменения этих констант могут быть внесены в этот файл и повторно импортированы в другие модули, что упрощает процесс обслуживания. Второй файл loc_config.py указывает другие папки для нашего эксперимента позже.

INPUT_WIDTH = 224
INPUT_HEIGHT = 224
INPUT_CHANNEL = 3
BATCH_SIZE = 32
NUM_WORKERS = 2
TRAIN_DATA_LOC = r'..\data\01_raw\seg_train'
TEST_DATA_LOC = r'..\data\01_raw\seg_test'
PRED_DATA_LOC = r'..\data\01_raw\seg_pred'
ANNOT_LOC = r'..\data\02_intermediate'
MODEL_SAVE_LOC = r'..\data\04_model'
REPORT_SAVE_LOC = r'..\data\06_reporting'

2. Модуль предварительной обработки

Во-вторых, модуль preprocessing помогает нам подготовить набор данных изображения. build_annotation_csv начинается с записи меток столбцов в пустой CSV-файл, затем проходит через каждую папку в указанном каталоге (например, папку для обучения) и извлекает имена подпапок в качестве имен классов. Затем полные пути ко всем изображениям вместе с соответствующими именами классов (и индексами) записываются в CSV-файл построчно. Он также возвращает CSV в качестве аннотации для последующего использования в классе data.Dataset и сохраняет этот файл в классе annot_location (позже мы будем использовать константу ANNOT_LOC в блокноте для экспериментов).

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

def build_annotation_dataframe(image_location, annot_location, output_csv_name):
    class_lst = os.listdir(
        image_location)  # returns a LIST containing the names of the entries (folder names in this case) in the directory.
    class_lst.sort()  # IMPORTANT
    with open(os.path.join(annot_location, output_csv_name), 'w', newline='') as csvfile:
        writer = csv.writer(csvfile, delimiter=',')
        writer.writerow(['file_name', 'file_path', 'class_name',
                        'class_index'])  # create column names
        for class_name in class_lst:
            # concatenates various path components with exactly one directory separator (‘/’) except the last path component.
            class_path = os.path.join(image_location, class_name)
            # get list of files in class folder
            file_list = os.listdir(class_path)
            for file_name in file_list:
                # concatenate class folder dir, class name and file name
                file_path = os.path.join(image_location, class_name, file_name)
                # write the file path and class name to the csv file
                writer.writerow(
                    [file_name, file_path, class_name, class_lst.index(class_name)])
    return pd.read_csv(os.path.join(annot_location, output_csv_name))


def check_annot_dataframe(annot_df):
    class_zip = zip(annot_df['class_index'], annot_df['class_name'])
    my_list = list()
    for index, name in class_zip:
        my_list.append(tuple((index, name)))
    unique_list = list(set(my_list))
    return unique_list


def transform_bilinear(output_img_width, output_img_height):
    image_transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
        transforms.Resize((output_img_width, output_img_height),
                          interpolation=PIL.Image.BILINEAR)
    ])
    return image_transform

3. Модельный модуль

Далее давайте посмотрим на модуль model, содержащий файлы dataset.py, modelling_config.py и cnn_model.py.

dataset.py — это место, где мы определяем пользовательский класс набора данных с именем IntelDataset, который наследуется от класса набора данных PyTorch, как описано в моей предыдущей статье. Я добавил еще один метод visualize для вывода 10 случайных изображений набора данных для иллюстрации. Наконец, функция create_validation_dataset принимает объект набора данных и разделяет его на основной набор данных (для использования в качестве обучающего набора) и набор данных проверки с заданной пропорцией. Это нужно для того, чтобы мы могли позже увидеть, как потери при проверке поезда прогрессируют в течение тренировочных эпох, и определить точку остановки, чтобы предотвратить переобучение.

class IntelDataset(torch.utils.data.Dataset):
    def __init__(self, annot_df, transform=None):
        self.annot_df = annot_df
        # root directory of images, leave "" if using the image path column in the __getitem__ method
        self.root_dir = ""
        self.transform = transform

    def __len__(self):
        # return length (numer of rows) of the dataframe
        return len(self.annot_df)

    def __getitem__(self, idx):
        # use image path column (index = 1) in csv file
        image_path = self.annot_df.iloc[idx, 1]
        image = cv2.imread(image_path)  # read image by cv2
        # convert from BGR to RGB for matplotlib
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        # use class name column (index = 2) in csv file
        class_name = self.annot_df.iloc[idx, 2]
        # use class index column (index = 3) in csv file
        class_index = self.annot_df.iloc[idx, 3]
        if self.transform:
            image = self.transform(image)
        # when accessing an instance via index, 3 outputs are returned - the image, class name and class index
        return image, class_name, class_index

    def visualize(self, number_of_img=10, output_width=12, output_height=6):
        plt.figure(figsize=(output_width, output_height))
        for i in range(number_of_img):
            idx = random.randint(0, len(self.annot_df))
            image, class_name, class_index = self.__getitem__(idx)
            ax = plt.subplot(2, 5, i+1)  # create an axis
            # create a name of the axis based on the img name
            ax.title.set_text(class_name + '-' + str(class_index))
            if self.transform == None:
                plt.imshow(image)
            else:
                plt.imshow(image.permute(1, 2, 0))


def create_validation_dataset(dataset, validation_proportion):
    if (validation_proportion > 1) or (validation_proportion < 0):
        return "The proportion of the validation set must be between 0 and 1"
    else:
        dataset_size = int((1 - validation_proportion) * len(dataset))
        validation_size = len(dataset) - dataset_size
        print(dataset_size, validation_size)
        dataset, validation_set = torch.utils.data.random_split(
            dataset, [dataset_size, validation_size])
        return dataset, validation_set

Второй файл modelling_config.py используется для определения вспомогательных функций для процесса моделирования. Первые 3 функции говорят сами за себя. Следующая функция model_prep_and_summary перемещает модель на устройство по умолчанию (GPU или CPU) и выводит сводку модели с помощью модуля torchsummary.summary. Это для нас, чтобы проверить входные/выходные размеры различных слоев в модели.

def default_loss():
    return nn.CrossEntropyLoss()

def default_optimizer(model, learning_rate = 0.001):
    return optim.Adam(model.parameters(), lr = learning_rate)

def get_default_device():
    """Picking GPU if available or else CPU"""
    if torch.cuda.is_available():
        return torch.device('cuda')
    else:
        return torch.device('cpu')

def model_prep_and_summary(model, device):
    """
    Move model to GPU and print model summary
    """
    # Define the model and move it to GPU:
    model = model
    model = model.to(device)
    print('Current device: ' + str(device))
    print('Is Model on CUDA: ' + str(next(model.parameters()).is_cuda))
    # Display model summary:
    summary(model, (INPUT_CHANNEL, INPUT_WIDTH, INPUT_HEIGHT))

Далее внутри файла cnn_model.py мы определяем модель CNN, собирая слои с классом nn.Module. Несколько замечаний:

  • Выходной размер сверточных слоев должен быть рассчитан для использования в качестве входных данных для первого слоя FC. Поскольку форма вывода self.conv7 равна 64*56*56, слой MaxPool просто уменьшает W и H наполовину, не влияя на количество каналов (глубину). Следовательно, вход на уровень FC равен 64*28*28 с self.fc14 = nn.Linear(64*28*28, 500) . Я приложил электронную таблицу, чтобы помочь с этим расчетом в репозитории, и некоторые расчеты в комментариях к коду.
  • Выходное измерение последнего слоя FC (fc16) должно быть равно количеству классов, которые вы хотите классифицировать в наборе данных. Здесь у нас 6 классов.

class MyCnnModel(nn.Module):
    def __init__(self):
        super(MyCnnModel, self).__init__()
        self.conv1 = nn.Conv2d(
            in_channels=3, out_channels=16, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(
            in_channels=16, out_channels=16, kernel_size=3, padding=1)

        self.conv3 = nn.Conv2d(
            in_channels=16, out_channels=32, kernel_size=3, padding=1)
        self.conv4 = nn.Conv2d(
            in_channels=32, out_channels=32, kernel_size=3, padding=1)

        self.conv5 = nn.Conv2d(
            in_channels=32, out_channels=64, kernel_size=3, padding=1)
        self.conv6 = nn.Conv2d(
            in_channels=64, out_channels=64, kernel_size=3, padding=1)
        self.conv7 = nn.Conv2d(
            in_channels=64, out_channels=64, kernel_size=3, padding=1)

        # Define a max pooling layer to use repeatedly in the forward function
        # The role of pooling layer is to reduce the spatial dimension (H, W) of the input volume for next layers.
        # It only affects weight and height but not depth.
        self.maxpool = nn.MaxPool2d(kernel_size=2, stride=2)

        # output shape of maxpool3 is 64*28*28
        self.fc14 = nn.Linear(64*28*28, 500)
        self.fc15 = nn.Linear(500, 50)
        # output of the final DC layer = 6 = number of classes
        self.fc16 = nn.Linear(50, 6)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = F.relu(self.conv2(x))
        # maxpool1 output shape is 16*112*112 (112 = (224-2)/2 + 1)
        x = self.maxpool(x)
        x = F.relu(self.conv3(x))
        x = F.relu(self.conv4(x))
        # maxpool2 output shape is 32*56*56 (56 = (112-2)/2 + 1)
        x = self.maxpool(x)
        x = F.relu(self.conv5(x))
        x = F.relu(self.conv6(x))
        x = F.relu(self.conv7(x))
        # maxpool3 output shape is 64*28*28 (28 = (56-2)/2 + 1)
        x = self.maxpool(x)

        x = x.reshape(x.shape[0], -1)
        x = F.relu(self.fc14(x))
        x = F.relu(self.fc15(x))
        x = F.dropout(x, 0.5)
        x = self.fc16(x)
        return x

Следующая важная функция — train_model(). При количестве эпох по умолчанию = 5 для каждой партии в каждой эпохе он устанавливает модель в режим train для прямого/обратного распространения/расчета потерь/обновления параметров с использованием набора обучающих данных, а затем устанавливает модель в режим eval, чтобы зафиксировать параметры и вычисление потерь при тестировании на наборе данных проверки. Функция вернет саму обученную модель вместе с кадром данных истории потерь в наборе данных для обучения и проверки, который мы можем исследовать. Для этого я определяю функцию visualize_training для отображения истории убытков.

def train_model(model, device, train_loader, val_loader, criterion, optimizer, num_epochs=5):
    # device = get_default_device()
    model = model.to(device)
    train_result_dict = {'epoch': [], 'train_loss': [],
                         'val_loss': [], 'accuracy': [], 'time': []}

    for epoch in range(num_epochs):
        start_time = time.time()
        train_loss = 0.0
        correct = 0
        total = 0
        model.train()  # set the model to training mode, parameters are updated
        for i, data in enumerate(train_loader, 0):
            image, class_name, class_index = data
            image = image.to(device)
            class_index = class_index.to(device)
            optimizer.zero_grad()  # zero the parameter gradients
            outputs = model(image)  # forward propagation
            loss = criterion(outputs, class_index)  # loss calculation
            loss.backward()  # backward propagation
            optimizer.step()  # params update
            train_loss += loss.item()  # loss for each minibatch
            _, predicted = torch.max(outputs.data, 1)
            total += class_index.size(0)
            correct += (predicted == class_index).sum().item()
            epoch_accuracy = round(float(correct)/float(total)*100, 2)

        # Here evaluation is combined together with
        val_loss = 0.0
        model.eval()  # set the model to evaluation mode, parameters are frozen
        for i, data in enumerate(val_loader, 0):
            image, class_name, class_index = data
            image = image.to(device)
            class_index = class_index.to(device)
            outputs = model(image)
            loss = criterion(outputs, class_index)
            val_loss += loss.item()

        # print statistics every 1 epoch
        # divide by the length of the minibatch because loss.item() returns the loss of the whole minibatch
        train_loss_result = round(train_loss / len(train_loader), 3)
        val_loss_result = round(val_loss / len(val_loader), 3)

        epoch_time = round(time.time() - start_time, 1)
        # add statistics to the dictionary:
        train_result_dict['epoch'].append(epoch + 1)
        train_result_dict['train_loss'].append(train_loss_result)
        train_result_dict['val_loss'].append(val_loss_result)
        train_result_dict['accuracy'].append(epoch_accuracy)
        train_result_dict['time'].append(epoch_time)

        print(f'Epoch {epoch+1} \t Training Loss: {train_loss_result} \t Validation Loss: {val_loss_result} \t Epoch Train Accuracy (%): {epoch_accuracy} \t Epoch Time (s): {epoch_time}')
    # return the trained model and the loss dictionary
    return model, train_result_dict


def visualize_training(train_result_dictionary):
    # Define Data
    df = pd.DataFrame(train_result_dictionary)
    x = df['epoch']
    data_1 = df['train_loss']
    data_2 = df['val_loss']
    data_3 = df['accuracy']

    # Create Plot
    fig, ax1 = plt.subplots(figsize=(7, 7))
    ax1.set_xlabel('Epoch')
    ax1.set_ylabel('Loss')
    ax1.plot(x, data_1, color='red', label='training loss')
    ax1.plot(x, data_2, color='blue', label='validation loss')

    # Adding Twin Axes
    ax2 = ax1.twinx()
    ax2.plot(x, data_3, color='green', label='Training Accuracy')

    # Add label
    plt.ylabel('Accuracy')
    lines = ax1.get_lines() + ax2.get_lines()
    ax1.legend(lines, [line.get_label() for line in lines], loc='upper center')

    # Show plot
    plt.show()

Наконец, для этого длинного файла у нас есть коды логического вывода. Первая функция infer() работает с классом DataLoader и выполняет вывод для всего набора данных, а вторая функция infer_single_image работает с одним изображением. Обратите внимание, что мы должны выполнить ту же предварительную обработку изображения, что и предыдущий метод IntelDataset class __getitem()__.

def infer(model, device, data_loader):
    '''
    Calculate predicted class indices of the data_loader by the trained model 
    '''
    model = model.to(device)
    y_pred = []
    y_true = []
    with torch.no_grad():
        for data in data_loader:
            image, class_name, class_index = data
            image = image.to(device)
            class_index = class_index.to(device)
            outputs = model(image)
            outputs = (torch.max(torch.exp(outputs), 1)[1]).data.cpu().numpy()
            y_pred.extend(outputs)
            class_index = class_index.data.cpu().numpy()
            y_true.extend(class_index)
    return y_pred, y_true

def infer_single_image(model, device, image_path, transform):
    '''
    Calculate predicted class index of the image by the trained model 
    '''
    # Prepare the Image
    image = cv2.imread(image_path)  # read image by cv2
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    image_transformed = transform(image)
    plt.imshow(image_transformed.permute(1, 2, 0))
    image_transformed_sq = torch.unsqueeze(image_transformed, dim=0)

    # Inference
    model.eval()
    with torch.no_grad():
        image_transformed_sq = image_transformed_sq.to(device)
        output = model(image_transformed_sq)
        _, predicted_class_index = torch.max(output.data, 1)
    print(f'Predicted Class Index: {predicted_class_index}')
    return predicted_class_index

4. Модуль постобработки

Последний модуль содержит вспомогательные функции в файле utils.py, помогающие нам с выходными данными модели.

Первая функция принимает выходные данные вышеприведенной функции infer() вместе со списком имен классов и выполняет расчет метрик для точности, прецизионности, полноты и F1 для каждого из 6 классов. Он также выводит общую точность и баллы F1.

Обратите внимание, что:

  • Использование class_names предназначено только для косметических целей на этапе представления метрик.
  • Мы используем инструменты библиотеки torch для вычисления различных показателей, таких как точность и F1, с выходными данными в тензорах. Следовательно, чтобы представить их в кадре данных, нам нужно преобразовать их в обычный формат numpy.
def calculate_model_performance(y_true, y_pred, class_names):
    num_classes = len(set(y_true + y_pred))
    # build confusion matrix based on predictions and class_index
    confusion_matrix = torch.zeros(num_classes, num_classes)
    for i in range(len(y_pred)):
        # true label on row, predicted on column
        confusion_matrix[y_true[i], y_pred[i]] += 1

    # PER-CLASS METRICS:
    # calculate accuracy, precision, recall, f1 for each class:
    accuracy = torch.zeros(num_classes)
    precision = torch.zeros(num_classes)
    recall = torch.zeros(num_classes)
    f1_score = torch.zeros(num_classes)
    for i in range(num_classes):
        # find TP, FP, FN, TN for each class:
        TP = confusion_matrix[i, i]
        FP = torch.sum(confusion_matrix[i, :]) - TP
        FN = torch.sum(confusion_matrix[:, i]) - TP
        TN = torch.sum(confusion_matrix) - TP - FP - FN
        # calculate accuracy, precision, recall, f1 for each class:
        accuracy[i] = (TP+TN)/(TP+FP+FN+TN)
        precision[i] = TP/(TP+FP)
        recall[i] = TP/(TP+FN)
        f1_score[i] = 2*precision[i]*recall[i]/(precision[i]+recall[i])
    # calculate support for each class
    support = torch.sum(confusion_matrix, dim=0)
    # calculate support proportion for each class
    support_prop = support/torch.sum(support)

    # OVERALL METRICS
    # calculate overall accuracy:
    overall_acc = torch.sum(torch.diag(confusion_matrix)
                            )/torch.sum(confusion_matrix)
    # calculate macro average F1 score:
    macro_avg_f1_score = torch.sum(f1_score)/num_classes
    # calculate weighted average rF1 score based on support proportion:
    weighted_avg_f1_score = torch.sum(f1_score*support_prop)

    TP = torch.diag(confusion_matrix)
    FP = torch.sum(confusion_matrix, dim=1) - TP
    FN = torch.sum(confusion_matrix, dim=0) - TP
    TN = torch.sum(confusion_matrix) - (TP + FP + FN)

    # calculate micro average f1 score based on TP, FP, FN
    micro_avg_f1_score = torch.sum(
        2*TP)/(torch.sum(2*TP)+torch.sum(FP)+torch.sum(FN))

    # METRICS PRESENTATION
    # performance for each class
    class_columns = ['accuracy', 'precision', 'recall', 'f1_score']
    class_data_raw = [accuracy.numpy(), precision.numpy(),
                      recall.numpy(), f1_score.numpy()]
    class_data = np.around(class_data_raw, decimals=3)
    df_class_raw = pd.DataFrame(
        class_data, index=class_columns, columns=class_names)
    class_metrics = df_class_raw.T

    # overall performance
    overall_columns = ['accuracy', 'f1_mirco', 'f1_macro', 'f1_weighted']
    overall_data_raw = [overall_acc.numpy(), micro_avg_f1_score.numpy(
    ), macro_avg_f1_score.numpy(), weighted_avg_f1_score.numpy()]
    overall_data = np.around(overall_data_raw, decimals=3)
    overall_metrics = pd.DataFrame(
        overall_data, index=overall_columns, columns=['overall'])
    return confusion_matrix, class_metrics, overall_metrics

Последний набор функций просто помогает нам сохранить обученную модель и отчет об обучении с текущей отметкой времени (чтобы избежать перезаписи старых файлов).

def get_current_timestamp():
    now = datetime.datetime.now()
    return now.strftime("%Y%m%d_%H%M%S")


def save_model_with_timestamp(model, filepath = MODEL_SAVE_LOC):
    filename = get_current_timestamp() + '_cnn_model' + '.pt'
    filepath = os.path.join(filepath, filename)
    torch.save(model.state_dict(), filepath)
    return print('Saved model to: ', filepath)


def save_csv_with_timestamp(train_result_dict, filepath = MODEL_SAVE_LOC):
    filename = get_current_timestamp() + '_training_report' + '.csv'
    filepath = os.path.join(filepath, filename)
    df = pd.DataFrame(train_result_dict)
    df.to_csv(filepath)
    return print('Saved training report to: ', filepath)

IV. ОБУЧЕНИЕ МОДЕЛИ

После определения всех необходимых модулей наш экспериментальный блокнот может быть очень простым:

1. Создание и предварительная обработка входного набора данных

Во-первых, мы создаем кадры данных аннотации и печатаем имя класса — пары индексов классов для проверки. Мы видим, что пары согласуются как в обучающем, так и в тестовом наборе данных.

train_df = build_annotation_dataframe(image_location = TRAIN_DATA_LOC, annot_location = ANNOT_LOC, output_csv_name = 'train.csv')
test_df = build_annotation_dataframe(image_location = TEST_DATA_LOC, annot_location = ANNOT_LOC, output_csv_name = 'test.csv')
class_names = list(train_df['class_name'].unique())
print(class_names)
print(check_annot_dataframe(train_df))
print(check_annot_dataframe(test_df))
Output:
['buildings', 'forest', 'glacier', 'mountain', 'sea', 'street']
[(5, 'street'), (1, 'forest'), (4, 'sea'), (0, 'buildings'), (2, 'glacier'), (3, 'mountain')]
[(5, 'street'), (1, 'forest'), (4, 'sea'), (0, 'buildings'), (2, 'glacier'), (3, 'mountain')]

Во-вторых, мы вызываем функцию преобразования изображения и применяем ее к обучающим, проверочным (соотношение 4:1 с validation_proportion = 0.2) и тестовым наборам данных, которые создаются пользовательским классом IntelDataset. Мы также распечатываем размер этих наборов данных для проверки.

image_transform = transform_bilinear(INPUT_WIDTH, INPUT_HEIGHT)
main_dataset = IntelDataset(annot_df = train_df, transform=image_transform)
train_dataset, validation_dataset = create_validation_dataset(main_dataset, validation_proportion = 0.2)
print('Train set size: ', len(train_dataset))
print('Validation set size: ', len(validation_dataset))

test_dataset = IntelDataset(annot_df = test_df, transform=image_transform)
print('Test set size: ', len(test_dataset))
Output:
Train set size:  11227
Validation set size:  2807
Test set size:  3000

2. Настройка загрузчиков данных

Следующим важным шагом является настройка загрузчиков данных из наборов данных выше. Основная цель состоит в том, чтобы обеспечить итерацию по этому набору данных в соответствии с размером пакета. Также необходимо перетасовывать изображения в этих наборах данных, особенно в обучающем наборе, чтобы процесс обучения можно было каждый раз выполнять с разными классами изображений и улучшать способность обобщения модели. Использование num_workers может быть хорошо объяснено в статье, указанной ниже, но это не является целью этого проекта, и вы можете оптимизировать его дальше.

train_loader = DataLoader(train_dataset, batch_size = BATCH_SIZE, shuffle=True, num_workers = NUM_WORKERS)
val_loader = DataLoader(validation_dataset, batch_size = BATCH_SIZE, shuffle=True, num_workers = NUM_WORKERS)
test_loader = DataLoader(test_dataset, batch_size = BATCH_SIZE, shuffle=True, num_workers = NUM_WORKERS)


3. Обучение модели и экспорт

Наконец мы подошли к тренировочной части. Мы просто инициализируем модель с архитектурой, определенной ранее, используем функцию потерь и оптимизатор по умолчанию и устанавливаем количество эпох (10 в этом испытании). Мы видим, что имеем дело с довольно крупной моделью с более чем 25 миллионами обучаемых параметров (как и ожидалось, в основном из первого слоя FC). Столбец «Форма вывода» помогает понять размеры вывода слоя.

# initiation
model = cnn_model.MyCnnModel()
device = modelling_config.get_default_device()
modelling_config.model_prep_and_summary(model, device)
criterion = modelling_config.default_loss()
optimizer = modelling_config.default_optimizer(model = model)
num_epochs = 10

# get training results
trained_model, train_result_dict = cnn_model.train_model(model, device, train_loader, val_loader, criterion, optimizer, num_epochs)
cnn_model.visualize_training(train_result_dict)
Current device: cpu
Is Model on CUDA: False
----------------------------------------------------------------
        Layer (type)               Output Shape         Param #
================================================================
            Conv2d-1         [-1, 16, 224, 224]             448
            Conv2d-2         [-1, 16, 224, 224]           2,320
         MaxPool2d-3         [-1, 16, 112, 112]               0
            Conv2d-4         [-1, 32, 112, 112]           4,640
            Conv2d-5         [-1, 32, 112, 112]           9,248
         MaxPool2d-6           [-1, 32, 56, 56]               0
            Conv2d-7           [-1, 64, 56, 56]          18,496
            Conv2d-8           [-1, 64, 56, 56]          36,928
            Conv2d-9           [-1, 64, 56, 56]          36,928
        MaxPool2d-10           [-1, 64, 28, 28]               0
           Linear-11                  [-1, 500]      25,088,500
           Linear-12                   [-1, 50]          25,050
           Linear-13                    [-1, 6]             306
================================================================
Total params: 25,222,864
Trainable params: 25,222,864
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.57
Forward/backward pass size (MB): 25.65
Params size (MB): 96.22
Estimated Total Size (MB): 122.44
----------------------------------------------------------------

История тренировок построена следующим образом. Здесь мы можем видеть прогрессирование потерь классификации в наборах для обучения и проверки. точка переобучения возникает в эпоху 4, после чего потери при проверке начинают расти по мере уменьшения потерь при обучении. Следовательно, эпоха 5 может быть хорошей точкой для остановки обучения в нашем следующем испытании. Наконец, мы сохраняем модели, обученные на 10 и 5 эпохах, в определенное место.

save_model_with_timestamp(trained_model, MODEL_SAVE_LOC)

V. ХАРАКТЕРИСТИКИ МОДЕЛИ

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

Здесь модель, обученная 10 эпохам, является первой моделью в сохраненном месте. Мы используем встроенный метод .load_state_dict класса модели для загрузки схемы модели с предварительно обученными параметрами. Наконец, мы вызываем пользовательский метод infer(), описанный ранее в тестовом DataLoader, и используем функцию calculate_model_performance для просмотра метрик.

Общая точность составляет 68,4%. Мы можем увидеть больше деталей из метрики путаницы, где диагональные значения представляют правильные прогнозы. Например, ледники (строка 3) чаще всего путают с горами (строка 4), где 136 изображений ледников классифицируются как горы.

trained_model_list = os.listdir(MODEL_SAVE_LOC)
MODELutilsEPOCH_PATH = os.path.join(MODEL_SAVE_LOC, trained_model_list[0])
MODELutilsEPOCH = cnn_model.MyCnnModel()
device = modelling_config.get_default_device()
print(MODELutilsEPOCH_PATH)
MODELutilsEPOCH.load_state_dict(torch.load(MODELutilsEPOCH_PATH))

# check accuracy on test set
y_pred, y_true = cnn_model.infer(model = MODELutilsEPOCH, device = device, data_loader = test_loader)
confusion_matrix, class_metrics, overall_metrics = calculate_model_performance(y_pred, y_true, class_names = class_names)

print(confusion_matrix)
print(class_metrics)
print(overall_metrics)
Output:
tensor([[325.,  15.,  37.,  47.,  46.,  76.],
        [ 14., 425.,   4.,   3.,   7.,  29.],
        [  5.,   0., 270.,  51.,  51.,   7.],
        [  2.,   5., 136., 366., 116.,   1.],
        [ 14.,   0.,  89.,  55., 283.,   4.],
        [ 77.,  29.,  17.,   3.,   7., 384.]])
           accuracy  precision  recall  f1_score
buildings     0.889      0.595   0.744     0.661
forest        0.965      0.882   0.897     0.889
glacier       0.868      0.703   0.488     0.576
mountain      0.860      0.585   0.697     0.636
sea           0.870      0.636   0.555     0.593
street        0.917      0.743   0.766     0.754
             overall
accuracy       0.684
f1_mirco       0.684
f1_macro       0.685
f1_weighted    0.681

Давайте посмотрим на модель, обученную с 5 эпохами. Мы видим, что точность немного выше, хотя время обучения сокращается вдвое. Это ожидается, поскольку модель демонстрирует переоснащение после 4-й эпохи, поэтому ее способность к обобщению фактически ухудшается с увеличением количества эпох и будет хуже работать на тестовом наборе.

tensor([[309.,  23.,  16.,  21.,  12.,  49.],
        [  5., 383.,   0.,   3.,   1.,   9.],
        [  8.,   0., 373.,  67.,  63.,   7.],
        [  6.,   3.,  24., 233.,  34.,   0.],
        [ 23.,   2., 130., 200., 392.,  12.],
        [ 86.,  63.,  10.,   1.,   8., 424.]])
           accuracy  precision  recall  f1_score
buildings     0.917      0.719   0.707     0.713
forest        0.964      0.955   0.808     0.875
glacier       0.892      0.720   0.675     0.697
mountain      0.880      0.777   0.444     0.565
sea           0.838      0.516   0.769     0.618
street        0.918      0.716   0.846     0.776
             overall
accuracy       0.705
f1_mirco       0.705
f1_macro       0.707
f1_weighted    0.704

VI. ТЕСТИРОВАНИЕ НА ОДНОМ ИЗОБРАЖЕНИИ

Наконец, давайте воспользуемся моделью с 5 эпохами, чтобы вывести одно изображение. Мы просто вызываем метод infer_single_image() класса модели и вводим соответствующие аргументы. В приведенном ниже коде из папки PRED_DATA_LOC выбирается случайное изображение.

image_list = os.listdir(PRED_DATA_LOC)
random_image = random.choice(image_list)
random_image_path = os.path.join(PRED_DATA_LOC, random_image)
print(random_image_path)

predicted_class_index = cnn_model.infer_single_image(
    model=MODELsrcEPOCH, 
    device=device, 
    image_path=random_image_path, 
    transform=image_transform)
print(class_names[predicted_class_index])
Output:
..\data\01_raw\seg_pred\11904.jpg
Predicted Class Index: tensor([0])
buildings

Выбранное изображение (11904.jpg) показано ниже, и он соответствует классу buildings, что верно.

V. ЗАКЛЮЧЕНИЕ

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

Спасибо за чтение, и я приветствую любые отзывы и советы.