Для чего нужна данная статья? :
Написать нейронную сеть в декларативном стиле, как в Keras, но без runtime-затрат с использованием procedural macros, для реального использования в ML-задачах (без backprop, pooling, batch-обучения, оптимизаторов и т.д.).
Зачем Вам это уметь? :
Реализовать процедурный макрос, который генерирует реализацию нейронной сети с поддержкой различных слоев, функций активации и базового обучения.
Обзор типов макросов
Декларативные макросы, такие как vec! или println!, позволяют создавать новый синтаксис для упрощения кода. Они просты в использовании и подходят для задач, связанных с повторяющимся текстом.
Процедурные макросы более сложны и мощны. Они включают:
- Функциональные макросы, которые работают как функции и могут принимать переменное количество аргументов (например, sql!(SELECT * FROM posts WHERE id=1)).
- Derive макросы, которые автоматически реализуют трейты для структур и перечислений (например, #[derive(Debug)]).
- Атрибутные макросы, которые добавляют пользовательские атрибуты к любому элементу кода (например, #[route(GET, "/")]).
Эти категории обеспечивают гибкость для различных сценариев программирования в Rust.
Поддерживающие ресурсы
Подробную информацию можно найти в официальной документации Rust, например, на страницах Rust By Example: Macros и The Rust Reference: Macros.
Декларативные макросы (Macros by Example)
Декларативные макросы, также известные как макросы по примеру или macro_rules!, являются наиболее простым способом расширения синтаксиса. Они работают аналогично выражению match, где входной код сопоставляется с предопределенными паттернами, и при успешном совпадении заменяется на соответствующий код.
- Определение: Используется ключевое слово macro_rules! для создания макроса.
- Примеры: Встроенные макросы, такие как vec! для создания векторов или println! для вывода в консоль.
- Применение: Подходят для задач, связанных с повторяющимся кодом, например, для создания шаблонов или упрощения синтаксиса.
- Преимущества: Простота в использовании, минимальные требования к настройке.
Подробности можно найти в Rust By Example: Macros, где приведены примеры, такие как определение макроса say_hello!, который расширяется в вызов println!("Hello!").
Процедурные макросы
Процедурные макросы представляют собой более сложный уровень метапрограммирования, где макросы ведут себя как функции, принимающие токены (фрагменты кода) и возвращающие сгенерированный код. Они требуют определения в отдельном crate с типом proc-macro и могут использоваться только после импорта в другой crate.
Процедурные макросы делятся на три подтипа, каждый из которых имеет свои особенности:
Функциональные процедурные макросы
- Описание: Работают как вызовы функций, принимая код в качестве аргументов и возвращая сгенерированный код. Они могут обрабатывать переменное количество аргументов, что делает их гибкими.
- Пример: Макрос sql!(SELECT * FROM posts WHERE id=1), который может генерировать SQL-запросы на основе входных данных.
- Применение: Подходят для создания DSL (доменных специфичных языков) или генерации сложного кода, например, для работы с базами данных.
Derive макросы (Custom #[derive])
- Описание: Используются для автоматической реализации трейтов (интерфейсов) для структур и перечислений с помощью атрибута #[derive].
- Пример: #[derive(Debug)] автоматически реализует трейт Debug для структуры, позволяя выводить её в отладочном формате.
- Применение: Упрощение работы с трейтами, такими как Clone, Copy, Eq, и другими, без необходимости ручной реализации.
- Примеры в документации: Подробно описаны в The Rust Programming Language: Macros, с примерами в листингах 20-37, 20-38, 20-40, 20-42, где используются crates syn и quote (версии 2.0 и 1.0).
Атрибутные макросы
- Описание: Позволяют определять пользовательские атрибуты, которые можно применять к любому элементу кода, такому как функции, структуры или модули. Они более гибкие, чем derive макросы, и могут использоваться в более широком контексте.
- Пример: #[route(GET, "/")] может быть использован для определения маршрутов в веб-фреймворках, таких как Actix или Rocket.
- Применение: Подходят для добавления метаданных или генерации кода на основе атрибутов, например, для логирования, сериализации или маршрутизации.
Подробности о процедурных макросах можно найти в The Rust Reference: Procedural Macros, где описаны требования к их определению и ограничения, такие как необходимость использования в корне crate с типом proc-macro.
Макросы с использованием macro_rules!:
макросы, которые работают с синтаксическим деревом, используя шаблоны и подстановочные параметры.
Пример:
macro_rules! say_hello {
() => {
println!("Hello, world!");
};
}
fn main() {
say_hello!();
}
В этом примере макрос vec_of! принимает список значений и создает вектор с этими значениями.
macro_rules! vec_of {
($($x:expr),*) => {
{
let mut temp_vec = Vec::new();
$( temp_vec.push($x);
)*
temp_vec }
};
}
fn main() {
let my_vec = vec_of!(1, 2, 3, 4, 5); println!("{:?}", my_vec);
}
Модульные макросы:
используются в модулях и могут быть вызваны из других модулей. Это полезно для создания макросов, которые должны быть доступными из разных частей вашего кода.
Пример:
// В модуле `my_macros.rs` #[macro_export] macro_rules! my_macro {
($val:expr) => {
println!("Value: {}", $val);
};
}
// В основном файле use my_macros::*;
fn main() {
my_macro!(42);
}
ML макрос define_neural_network
- Входные данные: Имя сети, размер входного слоя, список слоев (dense или conv2d с параметрами), скорость обучения.
- Генерация:Структура: Создаются поля для весов и смещений каждого слоя.
Инициализация: Веса и смещения заполняются случайными значениями.
Прямой проход: Выполняется последовательный расчет активаций через слои.
Обучение: Реализован базовый градиентный спуск (для простоты без обратного распространения).
Сложность
- Разные типы слоев: Поддержка полносвязных (dense) и сверточных (conv2d) слоев.
- Функции активации: Динамическая генерация кода для ReLU и Sigmoid.
- Процедурные макросы: Использование syn и quote для парсинга и генерации сложного AST.
Ограничения
- Упрощенное обучение: нет полного обратного распространения.
- Conv2d работает только с одним каналом и квадратными входами.
- Нет проверки типов данных и размеров.
Как используется макрос
- Создаётся сеть: let mut nn = MyNeuralNet::new(); — макрос сгенерировал метод new(), который инициализирует случайные веса и смещения.
- Подготавливается входной вектор (784 значения от 0.0 до почти 1.0) и целевой вектор (one-hot: класс 5).
- Выполняется прямой проход: nn.forward(&input) — сеть последовательно применяет все слои.
- Затем сеть обучается 100 эпох на одном примере: nn.train(&input, &target).Внутри train сначала делается прямой проход с сохранением активаций каждого слоя.
Затем выполняется упрощённое обратное распространение ошибки и обновление весов. - Периодически выводится MSE-потеря.
- После обучения снова делается прямой проход — выход должен приблизиться к целевому вектору (значение для класса 5 ≈ 1.0, остальные ≈ 0.0).
Далее создаётся сверточная сеть ConvNet, делается один прямой проход (без обучения).
Затем создаётся тестовая сеть с разными активациями (tanh → sigmoid → relu) и тоже обучается на простом примере.
Как работает сам процедурный макрос
Макрос — это функция, помеченная #[proc_macro], которая на этапе компиляции принимает токены (синтаксис макровызова) и возвращает сгенерированный код.
Шаг 1: Парсинг входа
Используются crates syn и quote.
NeuralNetworkDef — структура, реализующая Parse. Она парсит:
- Имя сети (MyNeuralNet).
- input_size: 784.
- Список слоёв: каждый слой — либо dense(neurons, activation), либо conv2d(filters, kernel_size, activation).
- learning_rate.
Каждый слой парсится в enum LayerDef.
Шаг 2: Генерация кода
Макрос проходит по всем слоям и для каждого генерирует:
a. Поля структуры
Для dense-слоя:
weights_0: Vec<Vec<f32>>, // матрица weights (neurons × prev_size)
biases_0: Vec<f32>
Для conv2d-слоя:
conv_weights_0: Vec<Vec<Vec<Vec<f32>>>>, // (filters, channels=1, kernel_h, kernel_w)
conv_biases_0: Vec<f32>
b. Инициализация в new()
Веса и смещения инициализируются случайными значениями в диапазоне [-1, 1).
Для первого слоя размер предыдущего слоя = input_size.
Для последующих — берётся количество нейронов/фильтров предыдущего слоя (для conv упрощённо используется #filters * 100 — это заглушка).
c. Прямой проход (forward)
Генерируется код, который последовательно преобразует вектор activations:
Для dense:
for i in 0..neurons {
sum = bias[i] + ∑(activations[j] * weights[i][j])
next_activations.push(activation(sum))
}
activations = next_activations;
Для conv2d (упрощённая реализация):
- Предполагается, что вход — квадратное изображение (sqrt(784) = 28).
- Вычисляется свёртка без padding и stride=1, только 1 входной канал.
- Для каждого фильтра и каждой позиции ядра считается сумма, добавляется bias, применяется активация.
- Результат «расплющивается» в вектор.
d. Функции активации
Генерируются через вспомогательную функцию generate_activation_function:
- relu → max(0, x)
- sigmoid → 1 / (1 + exp(-x))
- tanh → x.tanh()
- иначе — линейная (identity)
e. Обучение (train)
Это сильно упрощённая версия backpropagation:
- Сначала делается прямой проход с сохранением активаций каждого слоя в layer_outputs.
- Затем для каждого слоя (в обратном порядке) считается ошибка:Для выходного слоя: output[i] - target[i].
Для скрытых слоёв: очень грубая эвристика (current_output[i] * 0.01 или суммарная ошибка для conv). - По градиенту обновляются веса и смещения: weight -= learning_rate * gradient.
Важно: это не настоящий backprop. Производные функций активации не учитываются, ошибки на скрытых слоях вычисляются некорректно. Для conv-слоя обучение ещё более упрощённое (все веса обновляются одинаково на основе средней ошибки).
Шаг 3: Сборка и возврат кода
В конце собирается весь код через quote!:
pub struct MyNeuralNet { ...fields... }
impl MyNeuralNet {
pub fn new() -> Self { ...init... }
pub fn forward(&self, input: &[f32]) -> Vec<f32> { ...layers... }
pub fn train(&mut self, input: &[f32], target: &[f32]) { ...forward + backprop... }
}