Creando una red neuronal desde cero con Rust
Descubre cómo crear tu primera red neuronal
Tabla de contenidos
- 1 - Dataset
- 2 - Arquitectura de la red neuronal
- 3 - Backpropagation
- 4 - Carga de datos
- 5 - Entrenamiento e inferencia
- 6 - Ejecutar el proyecto
- 7 - Conclusiones
- 8 - Referencias
En este post presento cómo crear una perceptrón multicapa (MLP) desde cero en Rust. Puedes visualizar el repositorio desde este enlace.
Dataset
Para poder entrenar un modelo de Machine Learning, uno de los requisitos necesarios es contar con dataset, esta es la “bencina” que permite que nuestro modelo posteriormente pueda aprender y predecir en torno a un problema propuesto. El dataset utilizado para este proyecto es el Breast Cancer Winsconsin Dataset, dentro del repositorio se encuentra una versión ya procesada del dataset lista para el entrenamiento, la cual tiene la siguiente estructura:
radius_mean,texture_mean,smoothness_mean,compactness_mean,concavity_mean,concave_points_mean,symmetry_mean,fractal_dimension_mean,radius_se,smoothness_se,compactness_se,concavity_se,concave_points_se,fractal_dimension_se,radius_worst,texture_worst,smoothness_worst,compactness_worst,concavity_worst,concave_points_worst,symmetry_worst,fractal_dimension_worst,diagnosis
17.99,10.38,0.1184,0.2776,0.3001,0.1471,0.2419,0.07871,1.095,0.006399,0.04904,0.05373,0.01587,0.006193,25.38,17.33,0.1622,0.6656,0.7119,0.2654,0.4601,0.1189,0
...
La variable a predecir es la categoría “diagnosis” y tiene sólo dos valores posibles:
- 1: Esto significa que el tumor encontrado es maligno
- 0: Esto significa que el tumor encontrado es benigno
Por tanto, la red a entrenar debe resolver un problema de clasificación binaria.
Arquitectura de la red neuronal
Muchos frameworks de Machine Learning ya incluyen funcionalidades por debajo que facilitan la experiencia de desarrollo. Por ejemplo, cuando estamos trabajando con una red neuronal y dependiendo de la función de activación con la que estemos trabajando, se optará por realizar una incialización de los valores que corresponde a aquella neurona artificial.
Si la función de activación es ReLU se optará por realizar una inicialización de He y en el caso de que sea sigmoide se optará por realizar una inicialización de Glorot, esto es revelante para poder inicializar los pesos de cada neurona y los valores de sesgo como se puede apreciar acá:
#[derive(Serialize, Deserialize)]
struct Layer {
weights: Vec<Vec<f32>>,
bias: Vec<f32>,
activation: Activation,
#[serde(skip)]
last_input: Vec<f32>,
#[serde(skip)]
last_output: Vec<f32>,
}
impl Layer {
fn new(in_features: usize, out_features: usize, activation: Activation) -> Self {
let mut weights = vec![vec![0.0; in_features]; out_features];
match activation {
Activation::Sigmoid => weights = uniform_glorot_distribution(in_features, out_features),
Activation::ReLU => weights = uniform_he_distribution(in_features, out_features)
}
let bias = vec![0.0; out_features];
let last_input = vec![0.0; in_features];
let last_output = vec![0.0; out_features];
Self { weights, bias, activation, last_input, last_output }
}
fn forward(&mut self, x: &Vec<f32>) -> Vec<f32> {
self.last_input = x.clone();
let mut z = vector_dot_matrix(x, &self.weights);
z = vector_sum_vector(z, &self.bias);
let h = activate(z, &self.activation);
self.last_output = h.clone();
h
}
}
Una neurona artificial se puede representar a través de la siguiente ecuación:
Donde es el input, son los pesos y es el sesgo. Tanto como son vectores y es una matriz, por la que en la función forward() computa las multiplicaciones y sumas necesarias para trasladar los pesos entre las distintas neuronas de la misma capa.
Para poder implementar la red neuronal completa, se creó una nueva estructura llamada MLP{}, la cual se encarga de crear una red basada en la cantidad de capas ocultas, neuronas por capa y funciones de activación que vamos a utilizar tanto para las capas ocultas como para la capa de salida. También permite realizar forward() entre las distintas capas de la red neuronal, como se puede apreciar acá:
#[derive(Serialize, Deserialize)]
struct MLP {
input_size: usize,
hidden_size: usize,
num_hidden_layers: usize,
out_features: usize,
hidden_layers: Vec<Layer>,
output_layer: Layer
}
impl MLP {
fn init(input_size: usize, hidden_size: usize, num_hidden_layers: usize, out_features: usize, hidden_act: Activation, output_act: Activation) -> Self {
let mut layers = Vec::with_capacity(num_hidden_layers+1);
// Input layer (in_features -> hidden_size)
let first_hidden = Layer::new(input_size, hidden_size, hidden_act.clone());
layers.push(first_hidden);
// Hidden layers (hidden_size -> hidden_size)
for _ in 1..num_hidden_layers {
let new_hidden = Layer::new(hidden_size, hidden_size, hidden_act.clone());
layers.push(new_hidden);
}
// Clasification layer (hidden_size -> out_features)
let output_layer = Layer::new(hidden_size, out_features, output_act);
Self {
input_size,
hidden_size,
num_hidden_layers,
out_features,
hidden_layers: layers,
output_layer,
}
}
fn forward(&mut self, x: &Vec<f32>) -> Vec<f32> {
assert!(x.len() == self.input_size, "input_size doesn't match x.len()");
let mut h = x.clone();
for layer in self.hidden_layers.iter_mut() {
h = layer.forward(&h);
}
self.output_layer.forward(&h)
}
...
}
Backpropagation
Esta es la parte más densa matemáticamente, ya que al entrenar una red neuronal y una vez llegamos a la capa final, esta realiza un computo para determinar qué tan lejos o cerca están las predicciones de la red realizadas versus los valores reales.
Para ello, el algoritmo de propagación hacia atrás (backpropagation) permite calcular el gradiente local entre la capa actual y la capa anterior, donde aplicando la regla de la cadena se obtiene:
Donde es la función de pérdida, es una variable intermedia de una capa posterior y una variable de la capa anterior. Por lo tanto, la fórmula anterior representa la relación entre las derivadas parciales de cada valor para con esa diferencia propagarlo a las capas anteriores de la red neuronal y actualizar los pesos, la implementación de este algoritmo se define en la siguiente parte del código:
impl MLP {
...
fn backpropagation(&mut self, y_true: f32, lr: f32) {
// Delta value for output layer
let y_hat = self.output_layer.last_output[0];
let mut delta = vec![y_hat - y_true]; // BCE + sigmoid
let mut next_weights = self.output_layer.weights.clone(); // Save weights
// Update output layer
let input = &self.output_layer.last_input;
for i in 0..self.output_layer.weights[0].len() {
self.output_layer.weights[0][i] -= lr * delta[0] * input[i];
}
self.output_layer.bias[0] -= lr * delta[0];
// Backpropagation algorithm
for idx in (0..self.hidden_layers.len()).rev() {
let layer = &mut self.hidden_layers[idx];
let mut new_delta = vec![0.0; layer.bias.len()];
// Delta value calc
for i in 0..layer.bias.len() {
let mut sum = 0.0;
for j in 0..delta.len() {
sum += next_weights[j][i] * delta[j];
}
let h = layer.last_output[i];
let grad = derivative(h, &layer.activation);
new_delta[i] = sum * grad;
}
let current_weights = layer.weights.clone();
// Update weights and bias
for i in 0..layer.weights.len() {
for j in 0..layer.weights[0].len() {
layer.weights[i][j] -= lr * new_delta[i] * layer.last_input[j];
}
layer.bias[i] -= lr * new_delta[i];
}
next_weights = current_weights;
delta = new_delta;
}
}
...
}
Para simplificar, dentro de la función derivate() están computadas solamente las derivadas que pertenecen a las funciones de activación Sigmoid y ReLU.
Carga de datos
Ya definimos anteriormente el dataset que utilizaríamos para este proyecto, pero para realizar la carga de datos se implementaron un conjunto de funciones que permiten; leer un archivo .csv, almacenarlo dentro de una estructura llamada Dataset{}, realizar la mezcla de los datos de manera aleatoria, así como partir el dataset en los conjuntos de entrenamiento, pruebas y validación. Esto se ve en la siguiente función:
struct Dataset {
x: Vec<Vec<f32>>,
y: Vec<f32>
}
impl Dataset {
// Fisher-Yates shuffle algorithm
fn shuffle(&mut self) {
assert!(self.x.len() == self.y.len(), "Different sizes between X and Y axes");
let mut rng = rng();
let dist = Uniform::new_inclusive(0, 1).unwrap();
for i in (1..self.x.len()).rev() {
let j = rng.sample(dist);
self.x.swap(i, j);
self.y.swap(i, j);
}
}
fn split(self, train_dist: f32, val_dist: f32, test_dist: f32) -> (Dataset, Dataset, Dataset) {
assert!(self.x.len() == self.y.len(), "Different sizes between X and Y axes");
assert!(train_dist + val_dist + test_dist == 1.0, "The assigned distribution is invalid");
let n = self.x.len();
// Calculating the size of each dataset and index
let train_size = (n as f32 * train_dist).floor() as usize;
let val_size = (n as f32 * val_dist).floor() as usize;
let train_end = train_size;
let val_end = train_size + val_size;
// Datasets construction
let train = Dataset {
x: self.x[0..train_end].to_vec(),
y: self.y[0..train_end].to_vec(),
};
let val = Dataset {
x: self.x[train_end..val_end].to_vec(),
y: self.y[train_end..val_end].to_vec(),
};
let test = Dataset {
x: self.x[val_end..n].to_vec(),
y: self.y[val_end..n].to_vec(),
};
(train, val, test)
}
}
Entrenamiento e inferencia
También se crearon funciones que permiten automatizar el proceso de entrenamiento e inferencia de la red neuronal, de manera que en la función main() se pueda definir todo de una sola vez.
El proyecto considera una red MLP con los siguientes parametros:
- 2 Capas ocultas, cada capa con 20 neuronas y utilizando como función de activación ReLU
- Capa de salida con función de activación sigmoid
- Binary cross entropy como función de pérdida
- Distribución 70% entrenamiento, 10% validación y 20% testeo para el dataset
- Columna “diagnosis” como eje Y, los demás valores como eje X
- Learning rate de 0.01, entrenamiento por un total de 50 épocas
Esto se traduce en la siguiente implementación:
fn main() {
// Columns to parse
let y_col = "diagnosis";
let x_cols = vec!["radius_mean", "texture_mean", "smoothness_mean", "compactness_mean",
"concavity_mean", "concave_points_mean", "symmetry_mean",
"fractal_dimension_mean", "radius_se", "smoothness_se",
"compactness_se", "concavity_se", "concave_points_se",
"fractal_dimension_se", "radius_worst", "texture_worst",
"smoothness_worst", "compactness_worst", "concavity_worst",
"concave_points_worst", "symmetry_worst", "fractal_dimension_worst"];
// Read dataset, shuffle and split sets
let mut df = read_csv("src/data/data_cleaned.csv", y_col, x_cols).unwrap();
df.shuffle();
let (df_train, df_val, df_test) = df.split(0.7, 0.1, 0.2);
println!("Train: [{}, {}]", df_train.x.len(), df_train.x[0].len());
println!("Val: [{}, {}]", df_val.x.len(), df_val.x[0].len());
println!("Test: [{}, {}]\n", df_test.x.len(), df_test.x[0].len());
// Train model
let learning_rate = 0.01;
let total_epochs = 50;
let start = Instant::now();
let mut mlp = MLP::init(df_train.x[0].len(), 20, 2, 1, Activation::ReLU, Activation::Sigmoid);
mlp = model_train(mlp, df_train, df_val, total_epochs, learning_rate);
let duration = start.elapsed();
println!("\nTraining elapsed time: {:.3} seconds\n", duration.as_secs_f64());
mlp.save("src/models/model.json").unwrap();
// Test model
let start2 = Instant::now();
let mlp2 = MLP::load("src/models/model.json").unwrap();
model_test(mlp2, df_test);
let duration2 = start2.elapsed();
println!("\nTesting elapsed time: {:.3} seconds", duration2.as_secs_f64());
}
Ejecutar el proyecto
Antes de ejecutar el proyecto, instalar las depedencias:
cargo add rand serde serde_json
Una vez ya hecho, simplemente ejecutar con
C:\Users\%username%\.cargo\bin\cargo.EXE run --package rustmlp --bin rustmlp
Al ejecutar el proyecto, debería mostrarse un resultado como el siguiente:
Train: [398, 22]
Val: [56, 22]
Test: [115, 22]
Epoch 1/50 | train_loss 0.6595 | val_loss 0.5666 | val_acc 0.821
Epoch 2/50 | train_loss 0.5780 | val_loss 0.5047 | val_acc 0.804
Epoch 3/50 | train_loss 0.5490 | val_loss 0.5094 | val_acc 0.857
Epoch 4/50 | train_loss 0.5148 | val_loss 0.4870 | val_acc 0.839
Epoch 5/50 | train_loss 0.5048 | val_loss 0.4601 | val_acc 0.893
Epoch 6/50 | train_loss 0.4831 | val_loss 0.4298 | val_acc 0.893
Epoch 7/50 | train_loss 0.4703 | val_loss 0.4184 | val_acc 0.875
Epoch 8/50 | train_loss 0.4559 | val_loss 0.4080 | val_acc 0.857
Epoch 9/50 | train_loss 0.4427 | val_loss 0.4114 | val_acc 0.821
Epoch 10/50 | train_loss 0.4328 | val_loss 0.3797 | val_acc 0.839
...
Training elapsed time: 1.051 seconds
================ TEST RESULTS ================
Test loss : 0.1866
Test acc : 0.939
Correct : 108/115
Testing elapsed time: 0.003 seconds
Conclusiones
Actualmente la función de backpropagation está limitada para unos tipos específicos de funciones de activación, lo cual deja abierto una posible implementación de un autograd que pueda realizar el cálculo de manera automática permitiendo que se puedan calcular las derivadas de funciones de activación más complejas.
También, se pueden implementar funciones de generalización como Dropout y EarlyStopping para mejorar las capacidades predictivas del modelo.
La inferencia sólo se realiza en CPU, pero a futuro quiero realizar una implementación de este mismo proyecto utilizando GPU.
Referencias
Relacionadas con Rust:
Otros libros y papers que fueron de gran ayuda: