Эксперименты со сверточными нейронными сетями (CNN) с нуля в Rust.

В своей предыдущей статье (Часть 1) я начал свой эксперимент по разработке фреймворка машинного обучения на Rust с нуля. Основная цель моего эксперимента состояла в том, чтобы оценить улучшения скорости обучения модели, которых можно достичь, используя Rust в сочетании с PyTorch по сравнению с эквивалентом Python. Результаты оказались очень обнадеживающими для Feedforward Networks. В этой статье я продолжаю опираться на это с основной целью — иметь возможность определять и обучать сверточные нейронные сети (CNN). Как и в предыдущей статье, я продолжаю использовать крейт Tch-rs Rust в качестве оболочки библиотеки PyTorch C++ LibTorch, в основном для доступа к тензорной линейной алгебре и функциям автоградации, а остальное разрабатывается с нуля. Код частей 1 и 2 теперь доступен на Github (Ссылка).

Окончательный результат этой статьи позволяет определить сверточные нейронные сети (CNN) в Rust следующим образом:

Листинг 1. Определение моей модели CNN.

struct MyModel {
    l1: Conv2d,
    l2: Conv2d,
    l3: Linear,
    l4: Linear,
}

impl MyModel {
    fn new (mem: &mut Memory) -> MyModel {
        let l1 = Conv2d::new(mem, 5, 1, 10, 1);
        let l2 = Conv2d::new(mem, 5, 10, 20, 1);
        let l3 = Linear::new(mem, 320, 64);
        let l4 = Linear::new(mem, 64, 10);
        Self {
            l1: l1,
            l2: l2,
            l3: l3,
            l4: l4,
        }
    }
}

impl Compute for MyModel {
    fn forward (&self,  mem: &Memory, input: &Tensor) -> Tensor {
        let mut o = self.l1.forward(mem, &input);
        o = o.max_pool2d_default(2);
        o = self.l2.forward(mem, &o);
        o = o.max_pool2d_default(2);
        o = o.flat_view();
        o = self.l3.forward(mem, &o);
        o = o.relu();
        o = self.l4.forward(mem, &o);
        o
    }
}

… а затем создать экземпляр и обучить следующим образом:

Листинг 2. Обучающая модель CNN.

fn main() {
    let (mut x, y) = load_mnist();
    x = x / 250.0;
    x = x.view([-1, 1, 28, 28]);
 
    let mut m = Memory::new();
    let mymodel = MyModel::new(&mut m);    
    train(&mut m, &x, &y, &mymodel, 20, 512, cross_entropy, 0.0001);
    let out = mymodel.forward(&m, &x);
    println!("Accuracy: {}", accuracy(&y, &out));
}

Если попытаться сохранить определение модели как можно более похожим на эквивалент Python, приведенный выше листинг 1 должен быть интуитивно понятным для пользователей Python-PyTorch. В структуре MyModel мы теперь можем добавлять слои Conv2D, а затем инициировать их в новой связанной функции. В реализации трейта Compute определена прямая функция, которая принимает входные данные через все уровни, включая промежуточную функцию MaxPooling. В основной функции (листинг 2), как и в нашей предыдущей статье, мы обучаем нашу модель и применяем ее к набору данных Mnist.

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

Эксперименты с ядрами

Уникальная характеристика сверточных сетей заключается в том, что в некоторых слоях (по крайней мере, в одном) мы применяем свертку вместо обычного матричного умножения. Цель операции свертки состоит в том, что она использует ядро ​​для извлечения определенных характеристик, представляющих интерес, из входного изображения. Ядро — это матрица, которая скользит и умножается на части изображения (ввода) таким образом, что вывод представляет собой преобразование ввода определенным желаемым образом (см. диаграмму ниже).

В двумерном случае мы используем двумерное изображение I в качестве входных данных. Обычно мы также используем двумерное ядро ​​K, что приводит к следующим вычислениям свертки:

Как можно вывести из уравнения 1, наивный алгоритм применения свертки довольно затратен с вычислительной точки зрения из-за значительного количества циклов и матричных умножений. Что еще хуже, этот расчет должен быть повторен несколько раз для каждого сверточного слоя в сети и для каждого обучающего примера/партии. Следовательно, прежде чем расширять мою библиотеку из части 1 для обработки CNN, первым шагом было исследование эффективного способа вычисления сверток.

Поиск эффективных способов вычисления сверток — очень хорошо изученная проблема (см. Ссылка). После изучения различных вариантов, которые включали несколько чистых версий ржавчины, но требовали промежуточных преобразований данных из тензоров PyTorch, я решил использовать функцию свертки LibTorch C++. Чтобы поэкспериментировать с этой функцией, я хотел создать небольшую игрушечную программу, которая берет цветное изображение, преобразует его в оттенки серого, а затем применяет некоторые известные ядра для обнаружения границ.

Сначала я попросил чат Microsoft Bing сгенерировать для меня изображение. Как только я был доволен изображением, я захотел применить функцию свертки, сначала используя ядро ​​Гаусса, а затем ядро ​​Лапласа.

Ядра были применены с использованием метода conv2d LibTorch C++, который отображается через Tch-rs как:

Листинг 3 — метод LibTorch conv2d с открытым доступом Tch-rs.

pub fn conv2d<T: Borrow<Tensor>>(
    &self,
    weight: &Tensor,
    bias: Option<T>,
    stride: impl IntList,
    padding: impl IntList,
    dilation: impl IntList,
    groups: i64
) -> Tensor

Моя окончательная программа-игрушка показана ниже:

Листинг 4. Получение изображения и применение операций свертки для обнаружения границ.

use tch::{Tensor, vision::image, Kind, Device};

fn rgb_to_grayscale(tensor: &Tensor) -> Tensor {
    let red_channel = tensor.get(0);
    let green_channel = tensor.get(1);
    let blue_channel = tensor.get(2);
    
    // Calculate the grayscale tensor using the luminance formula
    let grayscale = (red_channel * 0.2989) + (green_channel * 0.5870) + (blue_channel * 0.1140);
    grayscale.unsqueeze(0)
}

fn main() {
    let mut img = image::load("mypic.jpg").expect("Failed to open image"); 
    img = rgb_to_grayscale(&img).reshape(&[1,1,1024,1024]);
    let bias: Tensor = Tensor::full(&[1], 0.0, (Kind::Float, Device::Cpu));
    
    // Define and apply Gaussian Kernel
    let mut k1 = [-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0];
    for element in k1.iter_mut() {
        *element /= 16.0;
    }
    let kernel1 = Tensor::from_slice(&k1)
                        .reshape(&[1,1,3,3])
                        .to_kind(Kind::Float);
    img = img.conv2d(&kernel1, Some(&bias), &[1], &[0], &[1], 1);

    // Define and apply Laplacian Kernel
    let k2 = [0.0, 1.0, 0.0, 1.0, -4.0, 1.0, 0.0, 1.0, 0.0];
    let kernel2 = Tensor::from_slice(&k2)
                        .reshape(&[1,1,3,3])
                        .to_kind(Kind::Float);
    img = img.conv2d(&kernel2, Some(&bias), &[1], &[0], &[1], 1);


    image::save(&img, "filtered.jpg");
    
}

Результат операции следующий:

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

Сверточный слой

Типичная структура CNN включает в себя ряд слоев свертки, за каждым из которых следует слой субдискретизации (объединение), которые затем обычно передаются в полностью связанные слои. Объединение в пул в значительной степени способствует сокращению параметров, поскольку оно снижает выборку входных данных. На приведенной ниже диаграмме изображена одна из самых ранних CNN под названием LeNet-5.

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

Новый сверточный слой, Conv2d, определяется следующим образом:

Листинг 5. Новый слой Conv2d.

pub struct Conv2d {
    params: HashMap<String, usize>,
}

impl Conv2d {
    pub fn new (mem: &mut Memory, kernel_size: i64, in_channel: i64, out_channel: i64, stride: i64) -> Self {
        let mut p = HashMap::new();
        p.insert("kernel".to_string(), mem.new_push(&[out_channel, in_channel, kernel_size, kernel_size], true));
        p.insert("bias".to_string(), mem.push(Tensor::full(&[out_channel], 0.0, (Kind::Float, Device::Cpu)).requires_grad_(true)));
        p.insert("stride".to_string(), mem.push(Tensor::from(stride as i64)));
        Self {
            params: p,
        }
    } 
}

impl Compute for Conv2d {
    fn forward (&self,  mem: &Memory, input: &Tensor) -> Tensor {
        let kernel = mem.get(self.params.get(&"kernel".to_string()).unwrap());
        let stride: i64 = mem.get(self.params.get(&"stride".to_string()).unwrap()).int64_value(&[]);
        let bias = mem.get(self.params.get(&"bias".to_string()).unwrap());
        input.conv2d(&kernel, Some(bias), &[stride], 0, &[1], 1)
    }
}

Если вы помните мой подход из Части 1, структура содержит поле с именем params. Поле params представляет собой набор типа HashMap, где ключ имеет тип String, в котором хранится имя параметра, а значение имеет тип usize, который содержит расположение определенного параметра (который является тензором PyTorch) в нашей памяти. , который, в свою очередь, служит нашим хранилищем для всех параметров нашей модели. В случае сверточного слоя в нашей связанной функции new мы вставляем в нашу HashMap два параметра "Kernel" и "bias" с флагом required_gradient True. Я также ввожу параметр "Шаг", однако он не установлен в качестве обучаемого параметра.

Затем мы реализуем трейт Compute для нашего Convolutional Layer. Для этого необходимо определить функцию forward, которая вызывается во время прямого прохода процесса обучения. В этой функции мы сначала получаем ссылку на тензоры ядра, смещения и шага из нашего хранилища тензоров, используя метод get, а затем вызываем нашу функцию Conv2d (как мы делали в нашей игрушечной программе, однако в этом случае сеть сообщает нам, что ядро для использования). Отступы были жестко запрограммированы на нулевое значение, однако при желании его также можно легко добавить в качестве параметра, аналогичного Stride.

Вот и все! Это единственное дополнение, которое требуется в нашей небольшой структуре из Части 1, чтобы иметь возможность определять и обучать CNN, как в листинге 1–2.

Адам Оптимизация

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

Алгоритм Адама был впервые опубликован в 2015 году (Ссылка) и в основном объединяет идею алгоритмов обучения Momentum и RMSprop. Алгоритм из оригинальной статьи следующий:

В Части 1 мы реализовали нашу тензорную память, которая также обслуживает эквивалентную ступенчатую функцию градиента PyTorch (методы apply_grads_sgd и apply_grads_sgd_momentum). Следовательно, в реализацию структуры памяти добавлен новый метод, который выполняет обновление градиента с помощью Адама:

Листинг 6. Наша реализация Adam.

fn apply_grads_adam(&mut self, learning_rate: f32) {
        let mut g = Tensor::new();
        const BETA:f32 = 0.9;

        let mut velocity = Tensor::zeros(&[self.size as i64], (Kind::Float, Device::Cpu)).split(1, 0);
        let mut mom = Tensor::zeros(&[self.size as i64], (Kind::Float, Device::Cpu)).split(1, 0);
        let mut vel_corr = Tensor::zeros(&[self.size as i64], (Kind::Float, Device::Cpu)).split(1, 0);
        let mut mom_corr = Tensor::zeros(&[self.size as i64], (Kind::Float, Device::Cpu)).split(1, 0);
        let mut counter = 0;

        self.values
        .iter_mut()
        .for_each(|t| {
            if t.requires_grad() {
                g = t.grad();
                mom[counter] = BETA * &mom[counter] + (1.0 - BETA) * &g;
                velocity[counter] = BETA * &velocity[counter] + (1.0 - BETA) * (&g.pow(&Tensor::from(2)));    
                mom_corr[counter] = &mom[counter]  / (Tensor::from(1.0 - BETA).pow(&Tensor::from(2)));
                vel_corr[counter] = &velocity[counter] / (Tensor::from(1.0 - BETA).pow(&Tensor::from(2)));

                t.set_data(&(t.data() - learning_rate * (&mom_corr[counter] / (&velocity[counter].sqrt() + 0.0000001))));
                t.zero_grad();
            }
            counter += 1;
        });

}

Результаты и мнения

Подобно моему подходу в Части 1, чтобы сравнить приведенный выше код с эквивалентом Python-PyTorch, я старался быть максимально точным, чтобы получить справедливое сравнение, в основном следя за тем, чтобы я применял одни и те же гиперпараметры нейронной сети, обучение параметры и алгоритмы обучения. Для своих тестов я также применил тот же набор данных Mnist. Я провел тесты на том же ноутбуке, Surface Pro 8, i7, с 16 ГБ ОЗУ, следовательно, без графического процессора.

После многократного запуска тестов в среднем обучение Rust привело к увеличению скорости на 60% по сравнению с эквивалентом Python. Несмотря на значительное улучшение, оно было меньше, чем в случае с FFN (мои выводы из Части 1). Я объяснил это более низкое улучшение тем фактом, что самым затратным вычислением в CNN является свертка, и, как обсуждалось выше, я решил использовать функцию LibTorch C++ conv2d, которая, в конце концов, является той же функцией, которая вызывается эквивалентом Python. Тем не менее, с учетом сказанного, сокращение времени обучения модели более чем наполовину все же не следует игнорировать — это по-прежнему означает экономию часов, если не дней!

Надеюсь, вам понравилась моя статья!

Рекомендации

Ян Гудфеллоу, Йошуа Бенжио и Аарон Курвиль, Deep Learning, MIT Press, 2016. http://www.deeplearningbook.org

Павел Карас и Дэвид Свобода, Алгоритмы эффективного вычисления свертки,in Design and Architectures for Digital Signal Processing, New York, NY, USA:IntechOpen, январь 2013 г.

Лекун Ю., Боту Л., Бенжио Ю. и Хаффнер П., Обучение на основе градиента в применении к распознаванию документов, Proceedings of the IEEE 86, 2278–2324, 1998.

Дидерик П. Кингма и Джимми Ба, Адам: метод стохастической оптимизации, в материалах 3-й Международной конференции по обучающим представлениям (ICLR), 2015 г.