[Перевод] Учимся летать: симуляция эволюции на Rust. 2/5
Это вторая часть серии статей по разработке симуляции эволюции с помощью нейронной сети и генетического алгоритма.
В этой статье мы заложим основы нашего проекта и реализуем простую FFNN (feedforward neural network — нейронная сеть прямого распространения), которая впоследствии станет мозгом. Мы также рассмотрим множество тонкостей и идиом, которые встречаются в коде Rust, включая тесты.
Готовы? Тогда поехали.
Настройка
Создаем новый проект:
mkdir shorelark
cd shorelark
Прежде всего, нам нужно определить, какую версию набора инструментов (toolchain) мы будем использовать — в противном случае, некоторые части кода могут работать не так, как ожидается.
По состоянию на март 2024 года последней стабильной версией Rust является 1.76
, поэтому создаем файл rust-toolchain
следующего содержания:
1.76.0
Теперь самое сложное — нам нужно определиться со структурой нашего проекта. Поскольку он будет состоять из множества независимых подмодулей (таких как нейронная сеть и генетический алгоритм), нам пригодится рабочая область (workspace):
# Cargo.toml
[workspace]
resolver = "2"
members = [
"libs/*",
]
Это означает, что вместо обычного src/main.rs
, мы создадим директорию libs
и поместим туда наши библиотеки (крейты):
mkdir libs
cd libs
cargo new neural-network --name lib-neural-network --lib
Существует много подходов к организации рабочих пространств. Вместо того, чтобы хранить все в директорииlibs
, можно хранить все в директорииcrates
. Или можно создать две отдельные директории: одну для крейтов приложения, другую — для крейтов библиотек:
project
├─ Cargo.toml
├─ app
│ ├─ Cargo.toml
│ └─ src
│ └─ main.rs
└─ libs
├─ subcrate-a
│ ├─ Cargo.toml
│ └─ src
│ └─ lib.rs
└─ subcrate-b
├─ Cargo.toml
└─ src
└─ lib.rs
Разработка propagate()
Пора заняться делом.
Начнем сверху вниз со структуры, моделирующей сеть — она обеспечит точку входа в наш крейт. Редактируем lib.rs
:
// libs/neural-network/src/lib.rs
#[derive(Debug)]
pub struct Network;
Самая важная операция нейронной сети — распространение (передача) чисел:
… поэтому:
#[derive(Debug)]
pub struct Network;
impl Network {
pub fn propagate(&self, inputs: Vec) -> Vec {
todo!()
}
}
Некоторые языки программирования позволяют оставлять разрабатываемые функции пустыми:
int get_berry_number() {
// TODO решить парадокс
}
но эквивалентный код Rust не будет компилироваться:
fn berry_number() -> usize {
// TODO решить парадокс
}
error[E0308]: mismatched types
--> src/lib.rs
|
1 | fn berry_number() -> usize {
| ------------ ^^^^^ expected `usize`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return`
| expression
Это объясняется тем, что почти все в Rust является выражением:
// это выражение
let value = if condition {
"computer says yass"
} else {
"computer says no"
};
// и это выражение
let value = loop {
break 123;
};
let value = {
// пустой блок - это тоже выражение
};
… поэтому Rust видит эту функцию так:
fn berry_number() -> usize {
return ();
}
()
называется единичным значением (unit value) (или единичным типом (unit type), в зависимости от контекста).Rust предоставляет два макроса, позволяющие пометить функцию как находящуюся в процессе разработки:
todo!()
и устаревшийunimplemented!()
.Оба макроса позволяют компилировать код и, если встречаются во время выполнения, приводят к безопасному сбою приложения:
thread 'main' panicked at 'not yet implemented'
Подобно океану, состоящему из капель, сеть состоит из слоев:
… поэтому:
#[derive(Debug)]
pub struct Network {
layers: Vec,
}
#[derive(Debug)]
struct Layer;
Слои состоят из нейронов:
… поэтому:
#[derive(Debug)]
struct Layer {
neurons: Vec,
}
Наконец, нейроны содержат смещения (biases) и выходные (output) веса:
#[derive(Debug)]
struct Neuron {
bias: f32,
weights: Vec,
}
Взглянем на наш начальный проект целиком:
#[derive(Debug)]
pub struct Network {
layers: Vec,
}
impl Network {
pub fn propagate(&self, inputs: Vec) -> Vec {
todo!()
}
}
#[derive(Debug)]
struct Layer {
neurons: Vec,
}
#[derive(Debug)]
struct Neuron {
bias: f32,
weights: Vec,
}
Отлично.
Вы могли заметить, что только два объекта являются общедоступными (pub
):Network
иNetwork:propagate()
.Это связано с тем, что
Layer
иNeuron
останутся деталями реализации, мы не будем делать их общедоступными.Благодаря такому подходу мы сможем вносить изменения в нашу реализацию, не навязывая критических изменений другим крейтам (пользователям нашей библиотеки).
Например, настоящие нейронные сети обычно реализуются с помощью матриц — если мы решим переписать нашу сеть для использования матриц, это не будет критическим изменением: сигнатура
Network:propagate()
останется прежней, а поскольку пользователи не имеют доступа кLayer
иNeuron
, они не заметят их исчезновения.
Далее, поскольку числа необходимо передавать через каждый слой, в них нам также понадобится propagate()
:
impl Layer {
fn propagate(&self, inputs: Vec) -> Vec {
todo!()
}
}
Имея Layer::propagate()
, мы можем вернуться и реализовать Network::propagate()
:
impl Network {
pub fn propagate(&self, inputs: Vec) -> Vec {
let mut inputs = inputs;
for layer in &self.layers {
inputs = layer.propagate(inputs);
}
inputs
}
}
Это вполне удовлетворительный и правильный фрагмент кода, но он не идиоматичен — мы можем сделать его лучше, более rustic!
Прежде всего, это называется перепривязкой (повторной привязкой, rebinding) (или затенением (shadowing)):
let mut inputs = inputs;
… и в этом нет необходимости, потому что мы можем переместить этот mut
в параметр функции:
impl Network {
pub fn propagate(&self, mut inputs: Vec) -> Vec {
for layer in &self.layers {
inputs = layer.propagate(inputs);
}
inputs
}
}
Но не обяжет ли это вызывающую сторону передавать изменяемые (мутабельные) значения? Неа!
fn process(mut items: Vec) {
// ...
}
fn main() {
let items = vec![1.2, 3.4, 5.6];
// ^ `mut` здесь не нужен
process(items);
// ^ просто работает
}
Только что введенный нами mut
находится в так называемой позиции привязки (binding position):
// иммут. - иммутабельный, мут. - мутабельный
fn foo_1(items: &[f32]) {
// ^^^^^ ------
// привязка тип
// (иммут.) (иммут.)
}
fn foo_2(mut items: &[f32]) {
// ^^^^^^^^^ ------
// привязка тип
// (мут.) (иммут.)
}
fn foo_3(items: &mut [f32]) {
// ^^^^^ ----------
// привязка тип
// (иммут.) (мут.)
}
fn foo_4(mut items: &mut [f32]) {
// ^^^^^^^^^ ----------
// привязка тип
// (мут.) (мут.)
}
struct Person {
name: String,
eyeball_radius: usize,
}
fn decompose(Person { name, mut eyeball_radius }: Person) {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------
// привязка тип
// (частично иммут., частично мут.) (иммут.)
}
… и привязки, в отличие от типов, являются локальными для функции:
fn foo(items: &mut Vec) {
// Когда тип является мутабельным, мы можем модифицировать то,
// на что указывает ссылка:
items.push(1234);
// Но если привязка остается иммутабельной, мы НЕ можем модифицировать
// саму ссылку:
items = some_another_vector;
// ^ error: cannot assign to immutable argument
}
fn bar(mut items: &Vec) {
// С другой стороны, когда привязка является мутабельной, мы можем модифицировать
// саму ссылку:
items = some_another_vector;
// Но если тип остается иммутабельным, мы не можем модифицировать то,
// на что указывает ссылка:
items.push(1234);
// ^^^^^ error: cannot borrow `*items` as mutable, as it is
// behind a `&` reference
}
Есть еще одна вещь, которую мы можем применить к нашему коду — этот шаблон известен как свертывание (folding):
for layer in &self.layers {
inputs = layer.propagate(inputs);
}
… и стандартная библиотека Rust предоставляет для этого специальную функцию:
impl Network {
pub fn propagate(&self, inputs: Vec) -> Vec {
self.layers
.iter()
.fold(inputs, |inputs, layer| layer.propagate(inputs))
}
}
На вкус и цвет, как известно, товарища нет.
Вуаля — в конце концов, благодаря замыканию нам даже не нужен mut inputs
— теперь наш код полностью соответствует Haskell (т.е. является функциональным по духу).
Перейдем к нейронам — один нейрон принимает много входных данных и возвращает одно выходное значение, поэтому:
#[derive(Debug)]
struct Neuron {
bias: f32,
weights: Vec,
}
impl Neuron {
fn propagate(&self, inputs: Vec) -> f32 {
todo!()
}
}
Возвращаемся к реализации Layer::propagate()
:
#[derive(Debug)]
struct Layer {
neurons: Vec,
}
impl Layer {
fn propagate(&self, inputs: Vec) -> Vec {
let mut outputs = Vec::new();
for neuron in &self.neurons {
let output = neuron.propagate(inputs);
outputs.push(output);
}
outputs
}
}
Если мы попытаемся скомпилировать этот код, то получим первый сбой проверки заимствований:
error[E0382]: use of moved value: `inputs`
--> src/lib.rs
|
| fn propagate(&self, inputs: Vec) -> Vec {
| ------ move occurs because `inputs` has
| type `Vec`, which does not
| implement the `Copy` trait
...
| let output = neuron.propagate(inputs);
| ^^^^^^
| value moved here, in previous
| iteration of loop
Очевидно, что компилятор прав: после вызова neuron.propagate(inputs)
мы теряем владение над inputs
, поэтому не можем использовать их в последующих итерациях цикла.
К счастью, исправить это легко — достаточно сделать так, чтобы neuron::propagate()
работал с заимствованными значениями:
impl Layer {
fn propagate(&self, inputs: Vec) -> Vec {
/* ... */
for neuron in &self.neurons {
let output = neuron.propagate(&inputs);
/* ... */
}
/* ... */
}
}
/* ... */
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
/* ... */
}
}
Вот как выглядит наш код на данный момент:
impl Layer {
fn propagate(&self, inputs: Vec) -> Vec {
let mut outputs = Vec::new();
for neuron in &self.neurons {
let output = neuron.propagate(&inputs);
outputs.push(output);
}
outputs
}
}
… и, верите или нет, этот шаблон называется отображением (mapping), и стандартная библиотека также предоставляет для него специальный метод!
impl Layer {
fn propagate(&self, inputs: Vec) -> Vec {
self.neurons
.iter()
.map(|neuron| neuron.propagate(&inputs))
.collect()
}
}
Остается завершить neuron::propagate()
— как и прежде, начнем с сырой версии:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
let mut output = 0.0;
for i in 0..inputs.len() {
output += inputs[i] * self.weights[i];
}
output += self.bias;
if output > 0.0 {
output
} else {
0.0
}
}
}
Этот фрагмент содержит две неидиоматичные конструкции и одну потенциальную ошибку — начнем с последней.
Поскольку мы перебираем self.weights
, используя длину inputs
, у нас есть три крайних случая:
- Когда
inputs.len() < self.weights.len()
. - Когда
inputs.len() == self.weights.len()
. - Когда
inputs.len() > self.weights.len()
.
Наш код основан на предположении, что пункт 2 всегда верен, но это предположение: мы нигде его не проверяем! Если мы по ошибке передадим меньше или больше входных данных, то получим либо неверный результат, либо сбой.
Существует как минимум два способа решить эту проблему:
- Мы можем изменить
Neuron::propagate()
для возврата сообщения об ошибке:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> Result {
if inputs.len() != self.weights.len() {
return Err(format!(
"получено {} входных данных, ожидалось {}",
inputs.len(),
self.weights.len(),
));
}
/* ... */
}
}
… или с помощью одного из моих любимых крейтов — thiserror:
pub type Result = std::result::Result;
#[derive(Debug, Error)]
pub enum Error {
#[error("получено {got} входных данных, ожидалось {expected}")]
MismatchedInputSize {
got: usize,
expected: usize,
},
}
/* ... */
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> Result {
if inputs.len() != self.weights.len() {
return Err(Error::MismatchedInputSize {
got: inputs.len(),
expected: self.weights.len(),
});
}
/* ... */
}
}
- Мы можем использовать
assert_eq!()
/panic!()
:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
assert_eq!(inputs.len(), self.weights.len());
/* ... */
}
}
В большинстве случаев первый вариант лучше, поскольку он позволяет вызывающей стороне поймать ошибку и легко ее обработать. Однако в нашем случае игра не стоит свеч, поскольку:
- Если
assert_eq()
не сработает, значит, наша реализация, скорее всего, неверна, и пользователи ничего не смогут сделать со своей стороны, чтобы решить проблему. - Это игрушечный проект — не будем зря тратить время.
Поэтому:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
assert_eq!(inputs.len(), self.weights.len());
/* ... */
}
}
Что касается идиом, это:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
/* ... */
if output > 0.0 {
output
} else {
0.0
}
}
}
… является скрытым f32::max()
:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
/* ... */
output.max(0.0)
}
}
А это:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
/* ... */
let mut output = 0.0;
for i in 0..inputs.len() {
output += inputs[i] * self.weights[i];
}
/* ... */
}
}
… можно упростить сначала с помощью .zip()
:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
/* ... */
let mut output = 0.0;
for (&input, &weight) in inputs.iter().zip(&self.weights) {
output += input * weight;
}
/* ... */
}
}
Операции индексации массива, такие как inputs[i]
, всегда выполняют так называемую проверку границ (bounds check) — это фрагмент кода, который гарантирует, что индекс находится в пределах массива, и паникует, когда он слишком велик:
fn main() {
let numbers = vec![1];
println!("{}", numbers[123]);
}
thread 'main' panicked at 'index out of bounds: the len is 1 but
the index is 123'
Когда вместо индексации мы используем комбинаторы, такие как.zip()
или.map()
, мы позволяем компилятору опустить эти проверки, что делает наш код не только более читаемым (на вкус и цвет…), но и более быстрым.
… затем с помощью .map()
и .sum()
:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
/* ... */
let mut output = inputs
.iter()
.zip(&self.weights)
.map(|(input, weight)| input * weight)
.sum::();
/* ... */
}
}
Синтаксис ::<>
, используемый на последней строке, называется турборыбой (turbofish). Он позволяет предоставлять явные общие (generic) аргументы, когда у компилятора возникают проблемы с их выводом.
Вуаля:
impl Neuron {
fn propagate(&self, inputs: &[f32]) -> f32 {
assert_eq!(inputs.len(), self.weights.len());
let output = inputs
.iter()
.zip(&self.weights)
.map(|(input, weight)| input * weight)
.sum::();
(self.bias + output).max(0.0)
}
}
Это, несомненно, красиво, но работает ли это? Может ли наш код распознать кошку на изображении? Можем ли мы использовать его для прогнозирования будущих цен Dogecoin?
Разработка new()
До сих пор мы были настолько сосредоточены на алгоритме, что практически не думали о конструкторах, но как можно использовать сеть, которую невозможно создать?
Мы могли бы сделать так:
#[derive(Debug)]
pub struct Network {
layers: Vec,
}
impl Network {
pub fn new(layers: Vec) -> Self {
Self { layers }
}
/* ... */
}
…, но не будем, поскольку хотим оставить Layer
и Neuron
вне общедоступного интерфейса.
В прошлой статье мы много говорили о произвольных числах, поэтому я уверен, что рано или поздно нам понадобится что-то вроде этого:
impl Network {
pub fn random() -> Self {
todo!()
}
}
Чтобы рандомизировать сеть, нам нужно знать количество ее слоев и количество нейронов в каждом слое — их можно описать одним вектором:
impl Network {
pub fn random(neurons_per_layer: Vec) -> Self {
todo!()
}
}
… или более элегантным способом:
#[derive(Debug)]
pub struct LayerTopology {
pub neurons: usize,
}
impl Network {
pub fn random(layers: Vec) -> Self {
todo!()
}
/* ... */
}
// Заметьте, как создание отдельного типа позволило нам переименовать аргумент просто в `layers`.
//
// Изначально аргумент назывался `neurons_per_layer`, поскольку `Vec`
// не предоставлял достаточно информации о том, что представляет собой этот `usize`.
// Использование отдельного типа делает это очевидным.
Теперь, если внимательно посмотреть на слой нейронной сети:
… можно заметить, что на самом деле он определяется двумя числами: размерами входных и выходных данных. Означает ли это, что наша структура LayerTopology
с одним полем неверна? Наоборот!
Мы использовали знания в предметной области.
В FFNN все слои соединены последовательно слева направо:
Поскольку выходные данные слоя A являются входными данными слоя B, если мы сделаем так:
#[derive(Debug)]
pub struct LayerTopology {
pub input_neurons: usize,
pub output_neurons: usize,
}
… тогда мы не только сделаем наш интерфейс громоздким, но, что еще хуже, нам потребуется дополнительная проверка, гарантирующая, что соблюдаются условия layer[0].output_neurons == layer[1].input_neurons
и т.д. — полная бессмыслица!
Принимая во внимание тот простой факт, что последовательные слои должны иметь совпадающие входы и выходы, мы можем упростить код еще до его написания.
Наивная реализация выглядит так:
impl Network {
pub fn random(layers: Vec) -> Self {
let mut built_layers = Vec::new();
for i in 0..(layers.len() - 1) {
let input_size = layers[i].neurons;
let output_size = layers[i + 1].neurons;
built_layers.push(Layer::random(
input_size,
output_size,
));
}
Self { layers: built_layers }
}
}
… давайте ее «растифицируем» — угадайте, что произойдет, если мы вызовем Network::random(vec![])
?
impl Network {
pub fn random(layers: Vec) -> Self {
// Сеть с одним слоем технически возможна, но бессмысленна:
assert!(layers.len() > 1);
/* ... */
}
}
Так лучше.
Что касается цикла for
, то итерация по соседним элементам — это еще один шаблон, предоставляемый стандартной библиотекой через метод windows()
:
impl Network {
pub fn random(layers: Vec) -> Self {
/* ... */
for adjacent_layers in layers.windows(2) {
let input_size = adjacent_layers[0].neurons;
let output_size = adjacent_layers[1].neurons;
/* ... */
}
/* ... */
}
}
Если вы знаете о деструктуризации, то можете попробовать переписать этот цикл следующим образом:
for [fst, snd] in layers.windows(2) {
built_layers.push(Layer::random(fst.neurons, snd.neurons));
}
… но, к сожалению, компьютер говорит «Нет»:
error[E0005]: refutable pattern in `for` loop binding: `&[]`,
`&[_]` and `&[_, _, _, ..]` not covered
--> src/lib.rs
|
| for [fst, snd] in layers.windows(2) {
| ^^^^^^^^^^ patterns `&[]`, `&[_]` and `&[_, _, _, ..]`
| not covered
|
= note: the matched value is of type `&[LayerTopology]`
Компилятор не понимает, чтоwindows(2)
возвращает массив, состоящий ровно из двух элементов, ему известно лишь, чтоwindows()
может возвращать массивы произвольных размеров, которые не обязательно соответствуют нашему шаблону (этот шаблон называется опровержимым (refutable), потому что он не совпадает со всеми возможными случаями; трюк с деструктуризацией можно провернуть только с помощью неопровержимого (irrefutable) шаблона).Ночной Rust, содержащий константные дженерики (const generics) предлагает решение —
.array_windows()
:
#![feature(array_windows)]
for [fst, snd] in layers.array_windows() {
built_layers.push(Layer::random(fst.neurons, snd.neurons));
}
… однако для простоты мы не будем использовать константные дженерики и продолжим работать со стабильной функцией.
В этом случае переход на итераторы для меня является очевидным:
impl Network {
pub fn random(layers: Vec) -> Self {
let layers = layers
.windows(2)
.map(|layers| Layer::random(layers[0].neurons, layers[1].neurons))
.collect();
Self { layers }
}
}
И последний штрих: если это не делает код неудобным, рекомендуется принимать заимствованные значения вместо собственных:
impl Network {
pub fn random(layers: &[LayerTopology]) -> Self {
/* ... */
}
}
Чаще всего принятие заимствованных значений не сильно меняет логику функции, но делает ее более универсальной — с заимствованным массивом теперь можно делать следующее:
let network = Network::random(&[
LayerTopology { neurons: 8 },
LayerTopology { neurons: 15 },
LayerTopology { neurons: 2 },
]);
… и:
let layers = vec![
LayerTopology { neurons: 8 },
LayerTopology { neurons: 15 },
LayerTopology { neurons: 2 },
];
let network_a = Network::random(&layers);
let network_b = Network::random(&layers);
// ^ нет необходимости в `.clone()`
Что дальше, что дальше… проверяем заметки… а, Layer::random()
!
impl Layer {
fn random(input_size: usize, output_size: usize) -> Self {
let mut neurons = Vec::new();
for _ in 0..output_size {
neurons.push(Neuron::random(input_size));
}
Self { neurons }
}
/* ... */
}
… или:
impl Layer {
fn random(input_size: usize, output_size: usize) -> Self {
let neurons = (0..output_size)
.map(|_| Neuron::random(input_size))
.collect();
Self { neurons }
}
}
|_|
называется toilet closure (гардеробным замыканием?) — это функция, принимающая аргумент, который ей не важен/нужен.Мы могли бы написать:
.map(|output_neuron_id| Neuron::random(input))
…, но поскольку нам не нужно читать этотoutput_neuron_id
, более идиоматичным будет назвать аргумент_
(или хотя бы_output_neuron_id
) для индикации факта неиспользуемости.Сам
_
называется заменителем (placeholder) и может использоваться в разных контекстах:
// Как привязка:
fn ignore_some_arguments(_: usize, b: usize, _: usize) {
// ^ ^
}
// ...но не как название:
fn _() {
// ^ error: expected identifier, found reserved identifier `_`
}
// Как тип:
fn load_files(paths: &[&Path]) -> Vec {
paths
.iter()
.map(std::fs::read_to_string)
.collect::>()
// ^ ^
.unwrap()
}
// ...но только внутри выражений:
fn what_am_i(foo: _) {
// ^ error: the type placeholder `_` is not allowed
// within types on item signatures
}
Наконец, последний фрагмент нашей цепочки — Neuron::random()
:
impl Neuron {
fn random(input_size: usize) -> Self {
let bias = todo!();
let weights = (0..input_size)
.map(|_| todo!())
.collect();
Self { bias, weights }
}
/* ... */
}
В отличие от C++ или Python, стандартная библиотека Rust не предоставляет генератора псевдослучайных чисел (далее также — ГПСЧ). Что это означает? Это означает, что пришло время для crates.io!
Когда дело доходит до ГПСЧ, rand фактически является стандартом. Это чрезвычайно универсальный крейт, который позволяет генерировать не только псевдослучайные числа, но и другие типы, такие как строки.
Добавляем его в Cargo.toml
:
# ...
[dependencies]
rand = "0.8"
… и затем:
use rand::Rng;
/* ... */
impl Neuron {
fn random(input_size: usize) -> Self {
let mut rng = rand::thread_rng();
let bias = rng.gen_range(-1.0..=1.0);
let weights = (0..input_size)
.map(|_| rng.gen_range(-1.0..=1.0))
.collect();
Self { bias, weights }
}
}
0..3
— это полуоткрытый интервал, который совпадает с0
,1
и2
.0..=3
— это закрытый интервал, который также включает3
.
Конечно, rng.gen_range(-1.0..=1.0)
довольно точно предсказывает цены Dogecoin, но существует ли способ убедиться, что наша сеть работает, как ожидается?
Тестирование
Чистая функция — это функция, которая при одних и тех же аргументах всегда возвращает одно и то же значение. Например, это чистая функция:
pub fn add(x: usize, y: usize) -> usize {
x + y
}
…, а это нет:
pub fn read(path: impl AsRef) -> String {
std::fs::read_to_string(path).unwrap()
}
add(1, 2)
всегда возвращает 3
, в то время как read("file.txt")
будет возвращать разные строки в зависимости от того, что содержит файл file.txt
в данный момент.
Чистые функции хороши тем, что их можно тестировать изолированно:
// Этот тест всегда проходит
// (он является *детерминированным*)
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
// Этот тест может пройти, а может провалиться, предугадать невозможно
// (он является *недетерминированным*)
#[test]
fn test_read() {
assert_eq!(
std::fs::read_to_string("serials-to-watch.txt"),
"killing eve",
);
}
К сожалению, генерация чисел в Neuron::random()
делает эту функцию нечистой, что легко доказать:
#[test]
fn random_is_pure() {
let neuron_a = Neuron::random(4);
let neuron_b = Neuron::random(4);
// Если бы `Neuron::random()` была чистой, оба нейрона всегда были бы одинаковыми:
assert_eq!(neuron_a, neuron_b);
}
Тестировать нечистые функции сложно, потому что мы мало что можем утверждать достоверно:
/* ... */
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random() {
let neuron = Neuron::random(4);
assert!(/* что? */);
}
}
Мы можем попробовать:
#[test]
fn test() {
let neuron = Neuron::random(4);
assert_eq!(neuron.weights.len(), 4);
}
…, но это бесполезный тест, он ничего не доказывает.
С другой стороны, превращать Neuron::random()
в чистую функцию кажется… нелепым? Какой смысл в рандомизации, если результат всегда будет одинаковым?
Обычно я примиряю оба мира, рассматривая источник нечистоты. В данном случае это:
impl Neuron {
fn random(input_size: usize) -> Self {
let mut rng = rand::thread_rng(); // упс
/* ... */
}
}
Что если вместо вызова thread_rng()
, мы будем принимать параметр с рандомизатором?
use rand::{Rng, RngCore};
/* ... */
impl Network {
pub fn random(rng: &mut dyn RngCore, layers: &[LayerTopology]) -> Self {
let layers = layers
.windows(2)
.map(|layers| Layer::random(rng, layers[0].neurons, layers[1].neurons))
.collect();
/* ... */
}
/* ... */
}
/* ... */
impl Layer {
fn random(rng: &mut dyn RngCore, input_size: usize, output_size: usize) -> Self {
let neurons = (0..output_size)
.map(|_| Neuron::random(rng, input_size))
.collect();
/* ... */
}
/* ... */
}
/* ... */
impl Neuron {
fn random(rng: &mut dyn RngCore, input_size: usize) -> Self {
/* ... */
}
/* ... */
}
… тогда мы сможем использовать в наших тестах фиктивный, предсказуемый ГПСЧ, в то время как пользователи смогут передавать настоящий ГПСЧ по своему выбору.
Вы можете использовать аналогичный подход для проверки вывода приложения, если вместо:
fn do_something() {
println!("Делаем что-нибудь...");
println!("...сделано!");
}
… мы сделаем:
fn do_something(stdout: &mut dyn Write) {
writeln!(stdout, "Делаем что-нибудь...").unwrap();
writeln!(stdout, "...сделано!").unwrap();
}
… тогда мы сможем легко тестировать вывод:
#[test]
fn ensure_something_happens() {
let mut stdout = String::new();
do_something(&mut stdout);
assert_eq!(stdout, "Делаем что-нибудь...\n...сделано!\n");
}
Я делал это раньше, и это оказалось очень удобным.Технически ни
do_something()
, ниrandom()
не являются чистыми функциями, поскольку им не хватает свойства, называемого ссылочной прозрачностью (referential transparency) — хотя всегда есть:
fn do_something(stdout: W) -> W {
/* ... */
}
Поскольку крейт rand
не предоставляет предсказуемого (фиктивного) ГПСЧ, нам придется использовать другой крейт — мне нравится rand_chacha (легко запомнить, вероятно, вы уже запомнили):
# ...
[dependencies]
rand = "0.8"
[dev-dependencies]
rand_chacha = "0.3"
… что позволяет нам сделать следующее:
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
#[test]
fn random() {
// Поскольку мы всегда используем одинаковый заполнитель (seed), наш `rng`
// будет всегда возвращать одинаковый набор значений
let mut rng = ChaCha8Rng::from_seed(Default::default());
let neuron = Neuron::random(&mut rng, 4);
assert_eq!(neuron.bias, /* ... */);
assert_eq!(neuron.weights, &[/* ... */]);
}
}
Мы пока не знаем, какие числа будут возвращены, но это легко выяснить — мы просто начнем с нулей, а затем скопируем и вставим числа из результатов теста:
#[test]
fn random() {
/* ... */
assert_eq!(neuron.bias, 0.0);
assert_eq!(neuron.weights, &[0.0, 0.0, 0.0, 0.0]);
}
Первый cargo test
дает нам:
thread '...' panicked at 'assertion failed: `(left == right)`
left: `-0.6255188`,
right: `0.0`
… поэтому:
#[test]
fn random() {
/* ... */
assert_eq!(neuron.bias, -0.6255188);
/* ... */
}
Второй cargo test
дает:
thread '...' panicked at 'assertion failed: `(left == right)`
left: `[0.67383957, 0.8181262, 0.26284897, 0.5238807]`,
right: `[0.0, 0.0, 0.0, 0.0]`', src/lib.rs:29:5
… поэтому:
#[test]
fn random() {
/* ... */
assert_eq!(
neuron.weights,
&[0.67383957, 0.8181262, 0.26284897, 0.5238807]
);
}
Обратите внимание, что числа разные, и это нормально — они могут быть разными, пока каждый cargo test
работает с одним и тем же набором чисел (и это так, потому что мы использовали ГПСЧ с постоянным заполнителем).
Прежде чем двигаться дальше, нам нужно затронуть еще одну тему: неточность чисел с плавающей точкой (запятой).
Тип, который мы используем, f32
, представляет собой 32-битное число с плавающей точкой, которое может представлять значения от ~1,2*10^-38
до ~3,4*10^38
. Увы, он может представлять не все эти числа, а только некоторые.
Например, с помощью f32
мы не можем закодировать ровно 0.15
:
fn main() {
println!("{:.10}", 0.15f32);
// 0.1500000060
}
… или 0.45
:
fn main() {
println!("{:.10}", 0.45f32);
// 0.4499999881
}
Обычно это не имеет значения, потому что числа с плавающей точкой проектировались не для точности, а только для скорости, но иногда это похоже на кирпич, падающий с неба:
#[test]
fn test() {
assert_eq!(0.45f32, 0.15 + 0.15 + 0.15);
}
thread 'test' panicked at 'assertion failed: `(left == right)`
left: `0.45`,
right: `0.45000002`'
Если вы не читали о числах с плавающей точкой, советую взглянуть на эту статью.
Итак, если мы не можем сравнивать числа точно, то как их сравнивать? Приблизительно!
#[test]
fn test() {
let actual: f32 = 0.1 + 0.2;
let expected = 0.3;
assert!((actual - expected).abs() < f32::EPSILON);
}
Это стандартный способ сравнения чисел с плавающей точкой во всех языках программирования, реализующих IEEE 754 (по сути, во всех языках программирования) — вместо того, чтобы искать точный результат, мы сравниваем оба числа с отступом погрешности (margin of error) (также называемым допуском (tolerance)).
Поскольку сравнивать числа таким способом неудобно, это можно делать с помощью макроса:
macro_rules! assert_almost_eq {
($left:expr, $right:expr) => {
let left: f32 = $left;
let right: f32 = $right;
assert!((left - right).abs() < f32::EPSILON);
}
}
#[test]
fn test() {
assert_almost_eq!(0.45f32, 0.15 + 0.15 + 0.15);
}
… или с помощью крейта, такого как approx:
#[test]
fn test() {
approx::assert_relative_eq!(0.45f32, 0.15 + 0.15 + 0.15);
}
Мне нравится approx
, добавляем его в Cargo.toml
:
# ...
[dev-dependencies]
approx = "0.4"
rand_chacha = "0.3"
… и обновляем тесты:
use super::*;
use approx::assert_relative_eq;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
#[test]
fn random() {
let mut rng = ChaCha8Rng::from_seed(Default::default());
let neuron = Neuron::random(&mut rng, 4);
assert_relative_eq!(neuron.bias, -0.6255188);
assert_relative_eq!(
neuron.weights.as_slice(),
[0.67383957, 0.8181262, 0.26284897, 0.5238807].as_ref()
);
}
После того, что мы узнали, написать тест для Neuron::propagate()
не составляет труда:
#[cfg(test)]
mod tests {
/* ... */
#[test]
fn random() {
/* ... */
}
#[test]
fn propagate() {
todo!()
}
}
Вероятно, вы слышали, что тесты следует именовать, исходя из их предварительных условий и ожиданий.Обычно это так — если бы мы разрабатывали магазин, было бы полезно структурировать тесты следующим образом:
#[cfg(test)]
mod tests {
use super::*;
mod cart {
use super::*;
mod when_user_adds_a_flower_to_their_cart {
use super::*;
#[test]
fn user_can_see_this_flower_in_their_cart() {
/* ... */
}
#[test]
fn user_can_remove_this_flower_from_their_cart() {
/* ... */
}
mod and_submits_order {
/* ... */
}
mod and_abandons_cart {
/* ... */
}
}
}
}
Проблема в том, что наш Neuron
не является типичным «бизнес-кодом», и многие «шаблоны бизнес-кода» не работают с математическим кодом. Если нам потребуется учитывать некоторые крайние случаи, скажем:
fn propagate(/* ... */) {
if /* ... */ {
do_foo()
} else {
do_bar()
}
}
… тогда имеет смысл создать два отдельных теста:
#[cfg(test)]
mod tests {
use super::*;
mod propagate {
use super::*;
mod given_neuron_with_foo {
use super::*;
#[test]
fn fooifies_it() {
/* ... */
}
}
mod given_neuron_thats_bar {
use super::*;
#[test]
fn bars_it() {
/* ... */
}
}
}
}
…, но в нашем случае лучше использовать простыеfn random()
иfn propagate()
.
Как убедиться в том, что propagate()
работает правильно? Вычислить ожидаемый результат вручную:
#[test]
fn propagate() {
let neuron = Neuron {
bias: 0.5,
weights: vec![-0.3, 0.8],
};
// Проверяем работу `.max()` (нашего ReLU):
assert_relative_eq!(
neuron.propagate(&[-10.0, -10.0]),
0.0,
);
// `0.5` и `1.0` выбраны произвольно:
assert_relative_eq!(
neuron.propagate(&[0.5, 1.0]),
(-0.3 * 0.5) + (0.8 * 1.0) + 0.5,
);
// Мы могли бы сразу написать `1.15`, но полная формула
// делает наши намерения более ясными
}
Попробуйте самостоятельно реализовать тесты для Layer
и Network
.
Заключительные мысли
Что именно мы создали?
Может показаться, что реализованное нами не имеет ничего общего с обучением или моделированием:
- Что насчет глаз?
- Где код, отвечающий за движение?
- Как создать этот зеленоватый терминал в стиле Fallout?
…, но это только потому, что сама нейронная сеть, хотя и является относительно сложной частью нашей кодовой базы, сама по себе почти ничего не делает — не волнуйтесь, в конце концов, все встанет на свои места.
А пока не стесняйтесь знакомиться с исходным кодом.
Почему наш код такой объемный?
При поиске «нейронная сеть на Python с нуля», мы находим множество статей, в которых FFNN реализуется с помощью нескольких строк кода Python — по сравнению с ними наш код кажется раздутым, почему так?
Потому что это позволяет многому научиться! Мы могли бы закодировать нашу сеть в 1/10 от ее текущего размера, используя nalgebra, мы могли бы использовать один из уже существующих крейтов, но нам важен не только пункт назначения, но и само путешествие.
Что дальше?
На данный момент у нас есть простая FFNN — в следующей статье мы реализуем генетический алгоритм и подключим его к нашей нейронной сети.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩