From 9fe1e05f4bd6dde5bcab9fe359f71e409cbd0150 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Wed, 10 Jun 2026 16:48:55 +0200 Subject: [PATCH 01/14] feat: rbf network --- core_lib/src/models/rbf.rs | 299 +++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/core_lib/src/models/rbf.rs b/core_lib/src/models/rbf.rs index 8b13789..36b2e89 100644 --- a/core_lib/src/models/rbf.rs +++ b/core_lib/src/models/rbf.rs @@ -1 +1,300 @@ +use crate::math::activations::softmax; +use crate::math::vector::Vector; +use crate::optim::gradient_descent::GradientDescentConfig; +use rand::Rng; +use serde::{Deserialize, Serialize}; +// RBF Network (Reseau a Fonctions de Base Radiale) +// Couche cachee : n_centers neurones, activation phi(x) = exp(-gamma * ||x - c||^2) +// Couche sortie : combinaison lineaire + softmax +// Entrainement : centres aleatoires, poids par moindres carres (Gauss-Jordan), raffinement gradient +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RBF { + pub centers: Vec, + pub weights: Vec, + pub biases: Vector, + pub gamma: f64, + pub n_centers: usize, + pub n_outputs: usize, + pub lambda: f64, +} + +impl RBF { + pub fn new(n_centers: usize, gamma: f64, n_outputs: usize) -> Self { + RBF { + centers: Vec::new(), + weights: Vec::new(), + biases: Vector::new(n_outputs), + gamma, + n_centers, + n_outputs, + lambda: 1e-4, + } + } + + pub fn with_lambda(mut self, lambda: f64) -> Self { + self.lambda = lambda; + self + } + + // Noyau gaussien : exp(-gamma * ||x - c||^2) + pub fn rbf_kernel(x: &Vector, center: &Vector, gamma: f64) -> f64 { + let diff = x.sub(center); + let sq_dist = diff.dot(&diff); + (-gamma * sq_dist).exp() + } + + // Activations de la couche cachee pour une entree x + pub fn compute_activations(&self, x: &Vector) -> Vector { + let data: Vec = self.centers.iter() + .map(|c| Self::rbf_kernel(x, c, self.gamma)) + .collect(); + Vector::from_vec(data) + } + + // Initialisation des centres par echantillonnage aleatoire (Fisher-Yates) + fn init_centers_random(inputs: &[Vector], n_centers: usize) -> Vec { + let n = inputs.len(); + let k = n_centers.min(n); + let mut rng = rand::thread_rng(); + let mut indices: Vec = (0..n).collect(); + for i in (1..n).rev() { + let j = rng.gen_range(0..=i); + indices.swap(i, j); + } + indices[..k].iter().map(|&idx| inputs[idx].clone()).collect() + } + + // Resout W = (Phi^T Phi + lambda I)^-1 * Phi^T Y via Gauss-Jordan avec pivot partiel + fn solve_weights( + phi: &[Vec], + targets: &[Vector], + n_centers: usize, + n_outputs: usize, + lambda: f64, + ) -> (Vec, Vector) { + let n = phi.len(); + + // Phi^T Phi + let mut ptp = vec![vec![0.0f64; n_centers]; n_centers]; + for k in 0..n { + for i in 0..n_centers { + for j in 0..n_centers { + ptp[i][j] += phi[k][i] * phi[k][j]; + } + } + } + for i in 0..n_centers { + ptp[i][i] += lambda; + } + + // Phi^T Y + let mut pty = vec![vec![0.0f64; n_outputs]; n_centers]; + for k in 0..n { + for i in 0..n_centers { + for j in 0..n_outputs { + pty[i][j] += phi[k][i] * targets[k].data[j]; + } + } + } + + // Matrice augmentee [A | B] + let w = n_centers + n_outputs; + let mut aug: Vec> = (0..n_centers) + .map(|i| { + let mut row = vec![0.0f64; w]; + for j in 0..n_centers { row[j] = ptp[i][j]; } + for j in 0..n_outputs { row[n_centers + j] = pty[i][j]; } + row + }) + .collect(); + + // Elimination de Gauss-Jordan + for col in 0..n_centers { + let (mut max_row, mut max_val) = (col, aug[col][col].abs()); + for row in (col + 1)..n_centers { + if aug[row][col].abs() > max_val { + max_val = aug[row][col].abs(); + max_row = row; + } + } + aug.swap(col, max_row); + let pivot = aug[col][col]; + if pivot.abs() < 1e-14 { continue; } + let inv = 1.0 / pivot; + for j in 0..w { aug[col][j] *= inv; } + for row in 0..n_centers { + if row == col { continue; } + let f = aug[row][col]; + if f.abs() < 1e-14 { continue; } + for j in 0..w { + let sub = f * aug[col][j]; + aug[row][j] -= sub; + } + } + } + + let weights: Vec = (0..n_outputs) + .map(|j| Vector::from_vec((0..n_centers).map(|i| aug[i][n_centers + j]).collect())) + .collect(); + (weights, Vector::new(n_outputs)) + } + + // Sortie brute avant softmax (utile pour la regression) + pub fn predict_raw(&self, input: &Vector) -> Vector { + assert!(!self.centers.is_empty(), "RBF non entraine"); + let act = self.compute_activations(input); + let mut output = self.biases.clone(); + for (j, w) in self.weights.iter().enumerate() { + output.data[j] += w.dot(&act); + } + output + } + + // Prediction avec softmax pour la classification + pub fn predict(&self, input: &Vector) -> Vector { + softmax(&self.predict_raw(input)) + } + + // Entrainement : + // 1. centres aleatoires + // 2. poids par moindres carres regularises + // 3. cfg.epochs etapes de raffinement gradient avec taux cfg.lr + pub fn train(&mut self, inputs: &[Vector], targets: &[Vector], cfg: GradientDescentConfig) { + assert!(!inputs.is_empty(), "Donnees vides"); + assert_eq!(inputs.len(), targets.len()); + let n_outputs = targets[0].len; + self.n_outputs = n_outputs; + self.biases = Vector::new(n_outputs); + self.centers = Self::init_centers_random(inputs, self.n_centers); + self.n_centers = self.centers.len(); + let phi: Vec> = inputs.iter() + .map(|x| self.centers.iter().map(|c| Self::rbf_kernel(x, c, self.gamma)).collect()) + .collect(); + let (weights, biases) = + Self::solve_weights(&phi, targets, self.n_centers, n_outputs, self.lambda); + self.weights = weights; + self.biases = biases; + if cfg.epochs > 0 && cfg.lr > 0.0 { + for _ in 0..cfg.epochs { + for (x, target) in inputs.iter().zip(targets.iter()) { + let pred = self.predict(x); + let act = self.compute_activations(x); + let delta = pred.sub(target); + for (j, w) in self.weights.iter_mut().enumerate() { + for k in 0..self.n_centers { + w.data[k] -= cfg.lr * delta.data[j] * act.data[k]; + } + self.biases.data[j] -= cfg.lr * delta.data[j]; + } + } + } + } + } + + pub fn save_json(&self, path: &str) -> Result<(), Box> { + let json = serde_json::to_string_pretty(self)?; + std::fs::write(path, json)?; + Ok(()) + } + pub fn load_json(path: &str) -> Result> { + let content = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) + } + pub fn save_binary(&self, path: &str) -> Result<(), Box> { + let encoded = bincode::serialize(self)?; + std::fs::write(path, encoded)?; + Ok(()) + } + pub fn load_binary(path: &str) -> Result> { + let data = std::fs::read(path)?; + Ok(bincode::deserialize(&data)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn xor_data() -> (Vec, Vec) { + ( + vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ], + vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + ], + ) + } + + #[test] + fn rbf_output_shape() { + let (inputs, targets) = xor_data(); + let mut rbf = RBF::new(4, 1.0, 2); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + assert_eq!(rbf.predict(&inputs[0]).len, 2); + } + + #[test] + fn rbf_softmax_sums_to_one() { + let (inputs, targets) = xor_data(); + let mut rbf = RBF::new(4, 1.0, 2); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + for x in &inputs { + let sum: f64 = rbf.predict(x).data.iter().sum(); + assert!((sum - 1.0).abs() < 1e-10); + } + } + + #[test] + fn rbf_xor_converges() { + let (inputs, targets) = xor_data(); + let expected = vec![0usize, 1, 1, 0]; + let mut ok = false; + for _ in 0..10 { + let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { + ok = true; + break; + } + } + assert!(ok, "RBF doit converger sur XOR"); + } + + #[test] + fn rbf_json_roundtrip() { + let (inputs, targets) = xor_data(); + let mut rbf = RBF::new(4, 1.0, 2); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.save_json("__rbf_test.json").unwrap(); + let loaded = RBF::load_json("__rbf_test.json").unwrap(); + let out_orig = rbf.predict(&inputs[0]); + let out_load = loaded.predict(&inputs[0]); + for (a, b) in out_orig.data.iter().zip(out_load.data.iter()) { + assert!((a - b).abs() < 1e-10); + } + std::fs::remove_file("__rbf_test.json").ok(); + } + + #[test] + fn rbf_binary_roundtrip() { + let (inputs, targets) = xor_data(); + let mut rbf = RBF::new(4, 1.0, 2); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.save_binary("__rbf_test.bin").unwrap(); + let loaded = RBF::load_binary("__rbf_test.bin").unwrap(); + let out_orig = rbf.predict(&inputs[0]); + let out_load = loaded.predict(&inputs[0]); + for (a, b) in out_orig.data.iter().zip(out_load.data.iter()) { + assert!((a - b).abs() < 1e-10); + } + std::fs::remove_file("__rbf_test.bin").ok(); + } +} From e5ff85f27cf9fe99041e65e872c155ba822af16c Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Fri, 12 Jun 2026 12:29:58 +0200 Subject: [PATCH 02/14] svm lineaire et a noyau --- core_lib/src/models/mod.rs | 1 + core_lib/src/models/svm.rs | 460 +++++++++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 core_lib/src/models/svm.rs diff --git a/core_lib/src/models/mod.rs b/core_lib/src/models/mod.rs index 2645a95..cac8aa7 100644 --- a/core_lib/src/models/mod.rs +++ b/core_lib/src/models/mod.rs @@ -14,3 +14,4 @@ pub trait Model { pub mod linear; pub mod mlp; pub mod rbf; +pub mod svm; diff --git a/core_lib/src/models/svm.rs b/core_lib/src/models/svm.rs new file mode 100644 index 0000000..91f0c61 --- /dev/null +++ b/core_lib/src/models/svm.rs @@ -0,0 +1,460 @@ +use crate::math::activations::softmax; +use crate::math::vector::Vector; +use serde::{Deserialize, Serialize}; + +// Types de noyaux supportes +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum KernelType { + Linear, + RBF { gamma: f64 }, + Polynomial { degree: usize, coef0: f64 }, +} + +impl KernelType { + pub fn compute(&self, a: &Vector, b: &Vector) -> f64 { + match self { + KernelType::Linear => a.dot(b), + KernelType::RBF { gamma } => { + let diff = a.sub(b); + (-gamma * diff.dot(&diff)).exp() + } + KernelType::Polynomial { degree, coef0 } => { + (a.dot(b) + coef0).powi(*degree as i32) + } + } + } +} + +// SVM lineaire binaire - hinge loss + SGD (style Pegasos) +// Objectif : (1/2)||w||^2 + C * sum(max(0, 1 - y*(w*x + b))) +#[derive(Clone, Debug, Serialize, Deserialize)] +struct BinaryLinearSVM { + pub weights: Vector, + pub bias: f64, + pub c: f64, +} + +impl BinaryLinearSVM { + fn new(input_size: usize, c: f64) -> Self { + BinaryLinearSVM { weights: Vector::new(input_size), bias: 0.0, c } + } + + // labels : +1.0 ou -1.0 + fn train(&mut self, inputs: &[Vector], labels: &[f64], lr: f64, epochs: usize) { + for epoch in 0..epochs { + let lr_t = lr / (1.0 + 0.01 * epoch as f64); + for (x, &y) in inputs.iter().zip(labels.iter()) { + let margin = y * (self.weights.dot(x) + self.bias); + if margin < 1.0 { + for j in 0..self.weights.len { + self.weights.data[j] = + (1.0 - lr_t) * self.weights.data[j] + lr_t * self.c * y * x.data[j]; + } + self.bias += lr_t * self.c * y; + } else { + for j in 0..self.weights.len { + self.weights.data[j] *= 1.0 - lr_t; + } + } + } + } + } + + fn decision(&self, x: &Vector) -> f64 { + self.weights.dot(x) + self.bias + } +} + +// SVM a noyau binaire - optimisation duale via SMO simplifie +// Maximise : sum(alpha) - 0.5 * sum_ij(alpha_i * alpha_j * y_i * y_j * K(x_i, x_j)) +// Sous contraintes : 0 <= alpha_i <= C, sum(alpha_i * y_i) = 0 +#[derive(Clone, Debug, Serialize, Deserialize)] +struct BinaryKernelSVM { + pub alphas: Vec, + pub support_vectors: Vec, + pub sv_labels: Vec, + pub bias: f64, + pub c: f64, + pub kernel: KernelType, +} + +impl BinaryKernelSVM { + fn new(c: f64, kernel: KernelType) -> Self { + BinaryKernelSVM { + alphas: Vec::new(), + support_vectors: Vec::new(), + sv_labels: Vec::new(), + bias: 0.0, + c, + kernel, + } + } + + fn decision_raw(&self, x: &Vector) -> f64 { + let mut result = self.bias; + for i in 0..self.alphas.len() { + result += self.alphas[i] * self.sv_labels[i] + * self.kernel.compute(&self.support_vectors[i], x); + } + result + } + + fn decision_from_matrix( + alphas: &[f64], + labels: &[f64], + k: &[Vec], + bias: f64, + i: usize, + ) -> f64 { + let sum: f64 = alphas.iter() + .zip(labels.iter()) + .enumerate() + .map(|(j, (&a, &y))| a * y * k[j][i]) + .sum(); + sum + bias + } + + fn train(&mut self, inputs: &[Vector], labels: &[f64], max_iter: usize) { + let n = inputs.len(); + let eps = 1e-3; + let tol = 1e-3; + + // Precalcul de la matrice noyau + let mut k = vec![vec![0.0f64; n]; n]; + for i in 0..n { + for j in i..n { + let val = self.kernel.compute(&inputs[i], &inputs[j]); + k[i][j] = val; + k[j][i] = val; + } + } + + let mut alphas = vec![0.0f64; n]; + let mut bias = 0.0f64; + let mut iter = 0; + let mut examine_all = true; + + loop { + let mut changed = 0; + + let candidates: Vec = if examine_all { + (0..n).collect() + } else { + (0..n).filter(|&i| alphas[i] > eps && alphas[i] < self.c - eps).collect() + }; + + for &i in &candidates { + let ei = Self::decision_from_matrix(&alphas, labels, &k, bias, i) - labels[i]; + let ri = labels[i] * ei; + + if (ri < -tol && alphas[i] < self.c) || (ri > tol && alphas[i] > 0.0) { + // Choisir j qui maximise |ei - ej| + let mut best_j = (i + 1) % n; + let mut best_diff = 0.0f64; + for j in 0..n { + if j == i { continue; } + let ej = Self::decision_from_matrix(&alphas, labels, &k, bias, j) - labels[j]; + let diff = (ei - ej).abs(); + if diff > best_diff { best_diff = diff; best_j = j; } + } + + let j = best_j; + let ej = Self::decision_from_matrix(&alphas, labels, &k, bias, j) - labels[j]; + let alpha_i_old = alphas[i]; + let alpha_j_old = alphas[j]; + + let (l, h) = if (labels[i] - labels[j]).abs() < eps { + let s = alphas[i] + alphas[j]; + ((s - self.c).max(0.0), s.min(self.c)) + } else { + let d = alphas[j] - alphas[i]; + ((-d).max(0.0), (self.c - d).min(self.c)) + }; + + if (l - h).abs() < eps { continue; } + + let eta = 2.0 * k[i][j] - k[i][i] - k[j][j]; + if eta >= 0.0 { continue; } + + alphas[j] -= labels[j] * (ei - ej) / eta; + alphas[j] = alphas[j].max(l).min(h); + + if (alphas[j] - alpha_j_old).abs() < eps * (alphas[j] + alpha_j_old + eps) { + continue; + } + + alphas[i] += labels[i] * labels[j] * (alpha_j_old - alphas[j]); + + let b1 = bias - ei + - labels[i] * (alphas[i] - alpha_i_old) * k[i][i] + - labels[j] * (alphas[j] - alpha_j_old) * k[i][j]; + let b2 = bias - ej + - labels[i] * (alphas[i] - alpha_i_old) * k[i][j] + - labels[j] * (alphas[j] - alpha_j_old) * k[j][j]; + + bias = if alphas[i] > eps && alphas[i] < self.c - eps { b1 } + else if alphas[j] > eps && alphas[j] < self.c - eps { b2 } + else { (b1 + b2) / 2.0 }; + + changed += 1; + } + } + + iter += 1; + if iter >= max_iter { break; } + if examine_all { examine_all = false; } + else if changed == 0 { examine_all = true; } + } + + self.bias = bias; + self.alphas.clear(); + self.support_vectors.clear(); + self.sv_labels.clear(); + for i in 0..n { + if alphas[i] > eps { + self.alphas.push(alphas[i]); + self.support_vectors.push(inputs[i].clone()); + self.sv_labels.push(labels[i]); + } + } + } +} + +// SVM multi-classes via One-vs-Rest +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SVM { + pub c: f64, + pub kernel: KernelType, + pub n_classes: usize, + linear_classifiers: Vec, + kernel_classifiers: Vec, + use_kernel: bool, +} + +impl SVM { + pub fn new_linear(c: f64) -> Self { + SVM { + c, + kernel: KernelType::Linear, + n_classes: 0, + linear_classifiers: Vec::new(), + kernel_classifiers: Vec::new(), + use_kernel: false, + } + } + + pub fn new_kernel(c: f64, kernel: KernelType) -> Self { + SVM { + c, + kernel: kernel.clone(), + n_classes: 0, + linear_classifiers: Vec::new(), + kernel_classifiers: Vec::new(), + use_kernel: true, + } + } + + // Retourne softmax des scores OvR, argmax = classe predite + pub fn predict(&self, input: &Vector) -> Vector { + assert!(self.n_classes > 0, "SVM non entraine"); + let scores: Vec = if self.use_kernel { + self.kernel_classifiers.iter().map(|clf| clf.decision_raw(input)).collect() + } else { + self.linear_classifiers.iter().map(|clf| clf.decision(input)).collect() + }; + softmax(&Vector::from_vec(scores)) + } + + // Entrainement OvR : un classifieur binaire par classe + // lr : taux d'apprentissage (SVM lineaire uniquement) + // epochs : iterations (lineaire) ou max_iter SMO (noyau) + pub fn train(&mut self, inputs: &[Vector], targets: &[Vector], lr: f64, epochs: usize) { + assert!(!inputs.is_empty()); + let n_classes = targets[0].len; + self.n_classes = n_classes; + let input_size = inputs[0].len; + + if self.use_kernel { + self.kernel_classifiers = (0..n_classes).map(|cls| { + let labels: Vec = targets.iter() + .map(|t| if t.argmax() == cls { 1.0 } else { -1.0 }) + .collect(); + let mut clf = BinaryKernelSVM::new(self.c, self.kernel.clone()); + clf.train(inputs, &labels, epochs.max(50)); + clf + }).collect(); + } else { + self.linear_classifiers = (0..n_classes).map(|cls| { + let labels: Vec = targets.iter() + .map(|t| if t.argmax() == cls { 1.0 } else { -1.0 }) + .collect(); + let mut clf = BinaryLinearSVM::new(input_size, self.c); + clf.train(inputs, &labels, lr, epochs); + clf + }).collect(); + } + } + + pub fn save_json(&self, path: &str) -> Result<(), Box> { + let json = serde_json::to_string_pretty(self)?; + std::fs::write(path, json)?; + Ok(()) + } + pub fn load_json(path: &str) -> Result> { + let content = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) + } + pub fn save_binary(&self, path: &str) -> Result<(), Box> { + let encoded = bincode::serialize(self)?; + std::fs::write(path, encoded)?; + Ok(()) + } + pub fn load_binary(path: &str) -> Result> { + let data = std::fs::read(path)?; + Ok(bincode::deserialize(&data)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn and_data() -> (Vec, Vec) { + ( + vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ], + vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + ], + ) + } + + fn xor_data() -> (Vec, Vec) { + ( + vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ], + vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + ], + ) + } + + #[test] + fn linear_svm_output_shape() { + let (inputs, targets) = and_data(); + let mut svm = SVM::new_linear(1.0); + svm.train(&inputs, &targets, 0.1, 500); + assert_eq!(svm.predict(&inputs[0]).len, 2); + } + + #[test] + fn linear_svm_softmax_sums_to_one() { + let (inputs, targets) = and_data(); + let mut svm = SVM::new_linear(1.0); + svm.train(&inputs, &targets, 0.1, 500); + for x in &inputs { + let sum: f64 = svm.predict(x).data.iter().sum(); + assert!((sum - 1.0).abs() < 1e-10); + } + } + + #[test] + fn linear_svm_and_converges() { + let (inputs, targets) = and_data(); + let expected = vec![0usize, 0, 0, 1]; + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_linear(10.0); + svm.train(&inputs, &targets, 0.05, 2000); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM lineaire doit converger sur AND"); + } + + #[test] + fn linear_svm_or_converges() { + let inputs = vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ]; + let targets = vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + ]; + let expected = vec![0usize, 1, 1, 1]; + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_linear(10.0); + svm.train(&inputs, &targets, 0.05, 2000); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM lineaire doit converger sur OR"); + } + + #[test] + fn kernel_svm_xor_rbf() { + let (inputs, targets) = xor_data(); + let expected = vec![0usize, 1, 1, 0]; + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); + svm.train(&inputs, &targets, 0.0, 200); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM noyau RBF doit converger sur XOR"); + } + + #[test] + fn svm_json_roundtrip() { + let (inputs, targets) = and_data(); + let mut svm = SVM::new_linear(1.0); + svm.train(&inputs, &targets, 0.1, 500); + svm.save_json("__svm_test.json").unwrap(); + let loaded = SVM::load_json("__svm_test.json").unwrap(); + let out_orig = svm.predict(&inputs[0]); + let out_load = loaded.predict(&inputs[0]); + for (a, b) in out_orig.data.iter().zip(out_load.data.iter()) { + assert!((a - b).abs() < 1e-10); + } + std::fs::remove_file("__svm_test.json").ok(); + } + + #[test] + fn svm_binary_roundtrip() { + let (inputs, targets) = and_data(); + let mut svm = SVM::new_linear(1.0); + svm.train(&inputs, &targets, 0.1, 500); + svm.save_binary("__svm_test.bin").unwrap(); + let loaded = SVM::load_binary("__svm_test.bin").unwrap(); + let out_orig = svm.predict(&inputs[0]); + let out_load = loaded.predict(&inputs[0]); + for (a, b) in out_orig.data.iter().zip(out_load.data.iter()) { + assert!((a - b).abs() < 1e-10); + } + std::fs::remove_file("__svm_test.bin").ok(); + } +} From ae1338dff3e3e98f8ca1b59dc2c5f45f89b15557 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Fri, 12 Jun 2026 12:30:32 +0200 Subject: [PATCH 03/14] optimiseurs sgd momentum et adam --- core_lib/src/models/mlp.rs | 60 +++++++++++++++ core_lib/src/optim/adam.rs | 113 +++++++++++++++++++++++++++++ core_lib/src/optim/mod.rs | 3 + core_lib/src/optim/optimizer.rs | 12 +++ core_lib/src/optim/sgd_momentum.rs | 85 ++++++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 core_lib/src/optim/adam.rs create mode 100644 core_lib/src/optim/optimizer.rs create mode 100644 core_lib/src/optim/sgd_momentum.rs diff --git a/core_lib/src/models/mlp.rs b/core_lib/src/models/mlp.rs index 1c684cc..d32580a 100644 --- a/core_lib/src/models/mlp.rs +++ b/core_lib/src/models/mlp.rs @@ -1,6 +1,7 @@ use crate::math::activations::{Activation, softmax}; use crate::math::vector::Vector; use crate::optim::gradient_descent::GradientDescentConfig; +use crate::optim::optimizer::Optimizer; use rand::Rng; use serde::{Deserialize, Serialize}; @@ -129,6 +130,65 @@ impl MLP { } } + // offsets stables des parametres par couche (poids + biais) + fn layer_param_bases(&self) -> Vec { + let mut bases = vec![0usize]; + for layer in &self.layers { + let last = *bases.last().unwrap(); + bases.push(last + layer.output_size * (layer.input_size + 1)); + } + bases + } + + // backprop en delegant les mises a jour des poids a un optimiseur externe + pub fn train_with_optimizer( + &mut self, + inputs: &[Vector], + targets: &[Vector], + epochs: usize, + optimizer: &mut O, + ) { + let bases = self.layer_param_bases(); + for _epoch in 0..epochs { + for (input, target) in inputs.iter().zip(targets.iter()) { + let mut zs = Vec::new(); + let mut activations = vec![input.clone()]; + for (i, layer) in self.layers.iter().enumerate() { + let z = layer.forward(activations.last().unwrap()); + zs.push(z.clone()); + let a = if i == self.layers.len() - 1 { softmax(&z) } else { self.hidden_activation.apply(&z) }; + activations.push(a); + } + let output = activations.last().unwrap(); + let mut delta = output.sub(target); + for l in (0..self.layers.len()).rev() { + let a_prev = activations[l].clone(); + let base = bases[l]; + let in_size = self.layers[l].input_size; + for i in 0..self.layers[l].output_size { + for j in 0..in_size { + let idx = base + i * (in_size + 1) + j; + let grad = delta.data[i] * a_prev.data[j]; + self.layers[l].weights[i].data[j] = optimizer.update(idx, self.layers[l].weights[i].data[j], grad); + } + let bias_idx = base + i * (in_size + 1) + in_size; + self.layers[l].biases.data[i] = optimizer.update(bias_idx, self.layers[l].biases.data[i], delta.data[i]); + } + if l > 0 { + let mut new_delta = Vector::new(in_size); + for j in 0..in_size { + for i in 0..self.layers[l].output_size { + new_delta.data[j] += self.layers[l].weights[i].data[j] * delta.data[i]; + } + } + let rd = self.hidden_activation.derivative(&zs[l - 1]); + delta = new_delta.hadamard(&rd); + } + } + } + } + } + /// Sauvegarde le modele au format JSON pub fn save_json(&self, path: &str) -> Result<(), Box> { let json = serde_json::to_string_pretty(self)?; diff --git a/core_lib/src/optim/adam.rs b/core_lib/src/optim/adam.rs new file mode 100644 index 0000000..576281c --- /dev/null +++ b/core_lib/src/optim/adam.rs @@ -0,0 +1,113 @@ +use std::collections::HashMap; +use super::optimizer::Optimizer; + +// Adam (Adaptive Moment Estimation) +// m_t = b1 * m_{t-1} + (1-b1) * grad +// v_t = b2 * v_{t-1} + (1-b2) * grad^2 +// m_hat = m_t / (1 - b1^t) +// v_hat = v_t / (1 - b2^t) +// theta = theta - lr * m_hat / (sqrt(v_hat) + eps) +pub struct Adam { + pub lr: f64, + pub beta1: f64, + pub beta2: f64, + pub epsilon: f64, + m: HashMap, + v: HashMap, + t: HashMap, +} + +impl Adam { + pub fn new(lr: f64) -> Self { + Adam { + lr, + beta1: 0.9, + beta2: 0.999, + epsilon: 1e-8, + m: HashMap::new(), + v: HashMap::new(), + t: HashMap::new(), + } + } + + pub fn with_betas(mut self, beta1: f64, beta2: f64) -> Self { + self.beta1 = beta1; + self.beta2 = beta2; + self + } +} + +impl Optimizer for Adam { + fn update(&mut self, idx: usize, value: f64, grad: f64) -> f64 { + let t = { + let t_entry = self.t.entry(idx).or_insert(0); + *t_entry += 1; + *t_entry as f64 + }; + + let m_hat = { + let m = self.m.entry(idx).or_insert(0.0); + *m = self.beta1 * *m + (1.0 - self.beta1) * grad; + *m / (1.0 - self.beta1.powf(t)) + }; + + let v_hat = { + let v = self.v.entry(idx).or_insert(0.0); + *v = self.beta2 * *v + (1.0 - self.beta2) * grad * grad; + *v / (1.0 - self.beta2.powf(t)) + }; + + value - self.lr * m_hat / (v_hat.sqrt() + self.epsilon) + } + + fn reset(&mut self) { + self.m.clear(); + self.v.clear(); + self.t.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adam_reduces_param_for_positive_grad() { + let mut adam = Adam::new(0.001); + assert!(adam.update(0, 1.0, 1.0) < 1.0); + } + + #[test] + fn adam_increases_param_for_negative_grad() { + let mut adam = Adam::new(0.001); + assert!(adam.update(0, 0.0, -1.0) > 0.0); + } + + #[test] + fn adam_reset_clears_state() { + let mut adam = Adam::new(0.001); + for _ in 0..5 { adam.update(0, 1.0, 0.5); } + adam.reset(); + let v_after_reset = adam.update(0, 1.0, 0.5); + let mut adam2 = Adam::new(0.001); + let v_fresh = adam2.update(0, 1.0, 0.5); + assert!((v_after_reset - v_fresh).abs() < 1e-10); + } + + #[test] + fn adam_independent_params() { + let mut adam = Adam::new(0.001); + let v0 = adam.update(0, 1.0, 1.0); + let v1 = adam.update(1, 1.0, -1.0); + assert!(v0 < 1.0); + assert!(v1 > 1.0); + } + + #[test] + fn adam_bias_correction_at_step_one() { + let lr = 0.001; + let mut adam = Adam::new(lr); + let v1 = adam.update(0, 1.0, 1.0); + assert!((1.0 - v1 - lr).abs() < 1e-6); + } +} diff --git a/core_lib/src/optim/mod.rs b/core_lib/src/optim/mod.rs index cf5ceee..f27ed7f 100644 --- a/core_lib/src/optim/mod.rs +++ b/core_lib/src/optim/mod.rs @@ -1 +1,4 @@ pub mod gradient_descent; +pub mod optimizer; +pub mod sgd_momentum; +pub mod adam; diff --git a/core_lib/src/optim/optimizer.rs b/core_lib/src/optim/optimizer.rs new file mode 100644 index 0000000..9f44a21 --- /dev/null +++ b/core_lib/src/optim/optimizer.rs @@ -0,0 +1,12 @@ +// Trait de base pour tous les optimiseurs +// Chaque parametre est identifie par un idx stable +pub trait Optimizer { + // Met a jour un parametre et retourne la nouvelle valeur + // idx : identifiant unique du parametre + // value : valeur actuelle + // grad : gradient de la loss + fn update(&mut self, idx: usize, value: f64, grad: f64) -> f64; + + // Remet a zero l'etat interne (moments, vitesses) + fn reset(&mut self); +} diff --git a/core_lib/src/optim/sgd_momentum.rs b/core_lib/src/optim/sgd_momentum.rs new file mode 100644 index 0000000..ec2030c --- /dev/null +++ b/core_lib/src/optim/sgd_momentum.rs @@ -0,0 +1,85 @@ +use std::collections::HashMap; +use super::optimizer::Optimizer; + +// SGD avec momentum +// v_t = momentum * v_{t-1} - lr * grad +// theta = theta + v_t +// Variante Nesterov : theta = theta + momentum * v_t - lr * grad +pub struct SGDMomentum { + pub lr: f64, + pub momentum: f64, + pub nesterov: bool, + velocities: HashMap, +} + +impl SGDMomentum { + pub fn new(lr: f64, momentum: f64) -> Self { + SGDMomentum { lr, momentum, nesterov: false, velocities: HashMap::new() } + } + + pub fn with_nesterov(mut self) -> Self { + self.nesterov = true; + self + } +} + +impl Optimizer for SGDMomentum { + fn update(&mut self, idx: usize, value: f64, grad: f64) -> f64 { + let v = self.velocities.entry(idx).or_insert(0.0); + let v_prev = *v; + *v = self.momentum * v_prev - self.lr * grad; + + if self.nesterov { + value + self.momentum * *v - self.lr * grad + } else { + value + *v + } + } + + fn reset(&mut self) { + self.velocities.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sgd_momentum_reduces_param() { + let mut opt = SGDMomentum::new(0.1, 0.9); + let v1 = opt.update(0, 1.0, 1.0); + assert!(v1 < 1.0); + } + + #[test] + fn sgd_momentum_accumulates_velocity() { + let mut opt = SGDMomentum::new(0.1, 0.9); + let mut v = 1.0f64; + for _ in 0..5 { v = opt.update(0, v, 0.1); } + let drop_5 = 1.0 - v; + let mut opt2 = SGDMomentum::new(0.1, 0.9); + let drop_1 = 1.0 - opt2.update(0, 1.0, 0.1); + assert!(drop_5 > drop_1); + } + + #[test] + fn sgd_momentum_reset_clears_velocity() { + let mut opt = SGDMomentum::new(0.1, 0.9); + opt.update(0, 1.0, 1.0); + opt.reset(); + let v_after_reset = opt.update(0, 1.0, 1.0); + let mut opt2 = SGDMomentum::new(0.1, 0.9); + let v_fresh = opt2.update(0, 1.0, 1.0); + assert!((v_after_reset - v_fresh).abs() < 1e-10); + } + + #[test] + fn sgd_nesterov_differs_from_standard() { + let mut standard = SGDMomentum::new(0.01, 0.9); + let mut nesterov = SGDMomentum::new(0.01, 0.9).with_nesterov(); + let v_std = standard.update(0, 1.0, 1.0); + let v_nes = nesterov.update(0, 1.0, 1.0); + assert!((v_std - v_nes).abs() > 1e-12); + } +} From 79ade67aaac5abdb77f3ed3c267bc22ea1cd9931 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Fri, 12 Jun 2026 12:30:56 +0200 Subject: [PATCH 04/14] metriques mse mae r2 --- core_lib/src/lib.rs | 1 + core_lib/src/metrics/mod.rs | 134 ++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 core_lib/src/metrics/mod.rs diff --git a/core_lib/src/lib.rs b/core_lib/src/lib.rs index e083c1a..96bf8e4 100644 --- a/core_lib/src/lib.rs +++ b/core_lib/src/lib.rs @@ -1,3 +1,4 @@ pub mod math; +pub mod metrics; pub mod models; pub mod optim; diff --git a/core_lib/src/metrics/mod.rs b/core_lib/src/metrics/mod.rs new file mode 100644 index 0000000..ea57109 --- /dev/null +++ b/core_lib/src/metrics/mod.rs @@ -0,0 +1,134 @@ +// % de bonnes predictions +pub fn accuracy(predictions: &[usize], targets: &[usize]) -> f64 { + assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); + let correct = predictions.iter().zip(targets.iter()).filter(|(p, t)| p == t).count(); + correct as f64 / predictions.len() as f64 +} + +// matrice de confusion : ligne = vrai label, colonne = label predit +pub fn confusion_matrix(predictions: &[usize], targets: &[usize], num_classes: usize) -> Vec> { + let mut matrix = vec![vec![0usize; num_classes]; num_classes]; + for (&pred, &target) in predictions.iter().zip(targets.iter()) { + matrix[target][pred] += 1; + } + matrix +} + +// precision pour une classe : tp / (tp + fp) +pub fn precision(matrix: &[Vec], class: usize) -> f64 { + let tp = matrix[class][class]; + let fp: usize = (0..matrix.len()).filter(|&i| i != class).map(|i| matrix[i][class]).sum(); + if tp + fp == 0 { return 0.0; } + tp as f64 / (tp + fp) as f64 +} + +// recall pour une classe : tp / (tp + fn) +pub fn recall(matrix: &[Vec], class: usize) -> f64 { + let tp = matrix[class][class]; + let fn_: usize = (0..matrix.len()).filter(|&j| j != class).map(|j| matrix[class][j]).sum(); + if tp + fn_ == 0 { return 0.0; } + tp as f64 / (tp + fn_) as f64 +} + +// f1 = moyenne harmonique precision/recall +pub fn f1_score(matrix: &[Vec], class: usize) -> f64 { + let p = precision(matrix, class); + let r = recall(matrix, class); + if p + r == 0.0 { return 0.0; } + 2.0 * p * r / (p + r) +} + +// f1 macro = moyenne du f1 sur toutes les classes +pub fn f1_macro(matrix: &[Vec]) -> f64 { + let n = matrix.len(); + (0..n).map(|c| f1_score(matrix, c)).sum::() / n as f64 +} + +// MSE = (1/n) * sum((pred - target)^2) +pub fn mse(predictions: &[f64], targets: &[f64]) -> f64 { + assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); + let n = predictions.len(); + assert!(n > 0, "Sequences vides"); + predictions.iter().zip(targets.iter()) + .map(|(p, t)| (p - t).powi(2)) + .sum::() / n as f64 +} + +// MAE = (1/n) * sum(|pred - target|) +pub fn mae(predictions: &[f64], targets: &[f64]) -> f64 { + assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); + let n = predictions.len(); + assert!(n > 0, "Sequences vides"); + predictions.iter().zip(targets.iter()) + .map(|(p, t)| (p - t).abs()) + .sum::() / n as f64 +} + +// R^2 = 1 - SS_res / SS_tot +// 1 = parfait, 0 = equivalent a la moyenne, <0 = pire que la moyenne +pub fn r_squared(predictions: &[f64], targets: &[f64]) -> f64 { + assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); + let n = targets.len(); + assert!(n > 0, "Sequences vides"); + let mean_t: f64 = targets.iter().sum::() / n as f64; + let ss_res: f64 = predictions.iter().zip(targets.iter()) + .map(|(p, t)| (p - t).powi(2)) + .sum(); + let ss_tot: f64 = targets.iter().map(|t| (t - mean_t).powi(2)).sum(); + if ss_tot < 1e-14 { return 1.0; } + 1.0 - ss_res / ss_tot +} + +// decoupe le dataset en k parties pour la cross validation +pub fn kfold_indices(n_samples: usize, k: usize) -> Vec<(Vec, Vec)> { + let fold_size = n_samples / k; + (0..k).map(|i| { + let test: Vec = (i * fold_size..(i + 1) * fold_size).collect(); + let train: Vec = (0..n_samples).filter(|x| !test.contains(x)).collect(); + (train, test) + }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_accuracy_perfect() { + let preds = vec![0, 1, 2]; + let targets = vec![0, 1, 2]; + assert_eq!(accuracy(&preds, &targets), 1.0); + } + + #[test] + fn test_accuracy_zero() { + let preds = vec![0, 0, 0]; + let targets = vec![1, 1, 1]; + assert_eq!(accuracy(&preds, &targets), 0.0); + } + + #[test] + fn test_confusion_matrix() { + let preds = vec![0, 1, 2, 0]; + let targets = vec![0, 1, 1, 2]; + let m = confusion_matrix(&preds, &targets, 3); + assert_eq!(m[0][0], 1); + assert_eq!(m[1][1], 1); + } + + #[test] + fn test_f1_perfect() { + let preds = vec![0, 1, 2]; + let targets = vec![0, 1, 2]; + let m = confusion_matrix(&preds, &targets, 3); + assert_eq!(f1_score(&m, 0), 1.0); + } + + #[test] + fn test_kfold_indices() { + let folds = kfold_indices(10, 5); + assert_eq!(folds.len(), 5); + assert_eq!(folds[0].1.len(), 2); + assert_eq!(folds[0].0.len(), 8); + } +} From 7e19032109120fe38833d025121ee2b502407b71 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Fri, 12 Jun 2026 12:31:12 +0200 Subject: [PATCH 05/14] test: integration tests 69 tests --- core_lib/tests/integration_tests.rs | 460 ++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) diff --git a/core_lib/tests/integration_tests.rs b/core_lib/tests/integration_tests.rs index 1bc49cb..6ce350e 100644 --- a/core_lib/tests/integration_tests.rs +++ b/core_lib/tests/integration_tests.rs @@ -1,10 +1,16 @@ use core_lib::math::activations::{Activation, relu, sigmoid, softmax, tanh}; use core_lib::math::matrix::Matrix; use core_lib::math::vector::Vector; +use core_lib::metrics::{kfold_indices, mae, mse, r_squared}; use core_lib::models::linear::LinearModel; use core_lib::models::mlp::MLP; +use core_lib::models::rbf::RBF; +use core_lib::models::svm::{KernelType, SVM}; use core_lib::models::{Model, TrainConfig}; +use core_lib::optim::adam::Adam; use core_lib::optim::gradient_descent::GradientDescentConfig; +use core_lib::optim::optimizer::Optimizer; +use core_lib::optim::sgd_momentum::SGDMomentum; // ─── Vector tests ─────────────────────────────────────────────── @@ -601,3 +607,457 @@ fn mlp_deep_network() { let sum: f64 = output.data.iter().sum(); assert!((sum - 1.0).abs() < 1e-6); } + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn xor_vectors() -> (Vec, Vec, Vec) { + let inputs = vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ]; + let targets = vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + ]; + (inputs, targets, vec![0, 1, 1, 0]) +} + +fn and_vectors() -> (Vec, Vec, Vec) { + let inputs = vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ]; + let targets = vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + ]; + (inputs, targets, vec![0, 0, 0, 1]) +} + +fn three_class_vectors() -> (Vec, Vec, Vec) { + let inputs = vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.1, 0.1]), + Vector::from_vec(vec![0.0, 0.2]), + Vector::from_vec(vec![1.0, 1.0]), + Vector::from_vec(vec![0.9, 0.9]), + Vector::from_vec(vec![1.0, 0.9]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.9, 0.1]), + Vector::from_vec(vec![1.0, 0.1]), + ]; + let targets = vec![ + Vector::from_vec(vec![1.0, 0.0, 0.0]), + Vector::from_vec(vec![1.0, 0.0, 0.0]), + Vector::from_vec(vec![1.0, 0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0, 0.0]), + Vector::from_vec(vec![0.0, 0.0, 1.0]), + Vector::from_vec(vec![0.0, 0.0, 1.0]), + Vector::from_vec(vec![0.0, 0.0, 1.0]), + ]; + (inputs, targets, vec![0, 0, 0, 1, 1, 1, 2, 2, 2]) +} + +// ── RBF tests ───────────────────────────────────────────────────────────────── + +#[test] +fn rbf_output_shape_integration() { + let (inputs, targets, _) = xor_vectors(); + let mut rbf = RBF::new(4, 1.0, 2); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + let out = rbf.predict(&inputs[0]); + assert_eq!(out.len, 2); + let sum: f64 = out.data.iter().sum(); + assert!((sum - 1.0).abs() < 1e-10); +} + +#[test] +fn rbf_xor_integration() { + let (inputs, targets, expected) = xor_vectors(); + let mut ok = false; + for _ in 0..10 { + let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "RBF doit resoudre XOR"); +} + +#[test] +fn rbf_and_integration() { + let (inputs, targets, expected) = and_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut rbf = RBF::new(4, 1.0, 2).with_lambda(1e-6); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.01, epochs: 0 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "RBF doit resoudre AND"); +} + +#[test] +fn rbf_multiclass_integration() { + let (inputs, targets, expected) = three_class_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut rbf = RBF::new(9, 1.0, 3).with_lambda(1e-6); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "RBF doit classifier 3 classes"); +} + +#[test] +fn rbf_json_roundtrip_integration() { + let (inputs, targets, _) = xor_vectors(); + let mut rbf = RBF::new(4, 1.0, 2); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.save_json("__it_rbf.json").unwrap(); + let loaded = RBF::load_json("__it_rbf.json").unwrap(); + let out_a = rbf.predict(&inputs[0]); + let out_b = loaded.predict(&inputs[0]); + for (a, b) in out_a.data.iter().zip(out_b.data.iter()) { assert!((a - b).abs() < 1e-10); } + std::fs::remove_file("__it_rbf.json").ok(); +} + +// ── SVM lineaire tests ──────────────────────────────────────────────────────── + +#[test] +fn linear_svm_and_integration() { + let (inputs, targets, expected) = and_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_linear(10.0); + svm.train(&inputs, &targets, 0.05, 2000); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM lineaire doit resoudre AND"); +} + +#[test] +fn linear_svm_or_integration() { + let inputs = vec![ + Vector::from_vec(vec![0.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![1.0, 1.0]), + ]; + let targets = vec![ + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, 1.0]), + ]; + let expected = vec![0usize, 1, 1, 1]; + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_linear(10.0); + svm.train(&inputs, &targets, 0.05, 2000); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM lineaire doit resoudre OR"); +} + +#[test] +fn linear_svm_multiclass_integration() { + let (inputs, targets, expected) = three_class_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_linear(10.0); + svm.train(&inputs, &targets, 0.05, 3000); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM lineaire doit classifier 3 classes"); +} + +#[test] +fn linear_svm_output_is_softmax() { + let (inputs, targets, _) = and_vectors(); + let mut svm = SVM::new_linear(1.0); + svm.train(&inputs, &targets, 0.1, 500); + for x in &inputs { + let sum: f64 = svm.predict(x).data.iter().sum(); + assert!((sum - 1.0).abs() < 1e-10); + } +} + +// ── SVM a noyau tests ───────────────────────────────────────────────────────── + +#[test] +fn kernel_svm_rbf_xor_integration() { + let (inputs, targets, expected) = xor_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); + svm.train(&inputs, &targets, 0.0, 300); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM RBF doit resoudre XOR"); +} + +#[test] +fn kernel_svm_poly_and_integration() { + let (inputs, targets, expected) = and_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_kernel(10.0, KernelType::Polynomial { degree: 2, coef0: 1.0 }); + svm.train(&inputs, &targets, 0.0, 200); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM polynomial doit resoudre AND"); +} + +#[test] +fn kernel_svm_linear_kernel_and() { + let (inputs, targets, expected) = and_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut svm = SVM::new_kernel(10.0, KernelType::Linear); + svm.train(&inputs, &targets, 0.0, 200); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "SVM noyau lineaire doit resoudre AND"); +} + +#[test] +fn svm_json_roundtrip_integration() { + let (inputs, targets, _) = and_vectors(); + let mut svm = SVM::new_linear(1.0); + svm.train(&inputs, &targets, 0.1, 500); + svm.save_json("__it_svm.json").unwrap(); + let loaded = SVM::load_json("__it_svm.json").unwrap(); + let out_a = svm.predict(&inputs[0]); + let out_b = loaded.predict(&inputs[0]); + for (a, b) in out_a.data.iter().zip(out_b.data.iter()) { assert!((a - b).abs() < 1e-10); } + std::fs::remove_file("__it_svm.json").ok(); +} + +// ── Optimiseurs tests ───────────────────────────────────────────────────────── + +#[test] +fn sgd_momentum_mlp_xor() { + let (inputs, targets, expected) = xor_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut mlp = MLP::new(&[2, 16, 2]).with_activation(Activation::Sigmoid); + let mut opt = SGDMomentum::new(0.5, 0.9); + mlp.train_with_optimizer(&inputs, &targets, 5000, &mut opt); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "MLP + SGD Momentum doit resoudre XOR"); +} + +#[test] +fn adam_mlp_xor() { + let (inputs, targets, expected) = xor_vectors(); + let mut ok = false; + for _ in 0..5 { + let mut mlp = MLP::new(&[2, 16, 2]).with_activation(Activation::Sigmoid); + let mut opt = Adam::new(0.01); + mlp.train_with_optimizer(&inputs, &targets, 3000, &mut opt); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { + ok = true; break; + } + } + assert!(ok, "MLP + Adam doit resoudre XOR"); +} + +#[test] +fn sgd_momentum_reduces_loss() { + let (inputs, targets, expected) = and_vectors(); + let mut mlp = MLP::new(&[2, 8, 2]).with_activation(Activation::Sigmoid); + let mut opt = SGDMomentum::new(0.5, 0.9); + mlp.train_with_optimizer(&inputs, &targets, 3000, &mut opt); + let correct: usize = (0..inputs.len()) + .filter(|&i| mlp.predict(&inputs[i]).argmax() == expected[i]) + .count(); + assert!(correct >= 3); +} + +#[test] +fn adam_optimizer_reset() { + let mut adam = Adam::new(0.001); + let v1 = adam.update(0, 1.0, 0.5); + adam.reset(); + let v2 = adam.update(0, 1.0, 0.5); + assert!((v1 - v2).abs() < 1e-10); +} + +#[test] +fn sgd_nesterov_mlp_and() { + let (inputs, targets, expected) = and_vectors(); + let mut mlp = MLP::new(&[2, 8, 2]).with_activation(Activation::Sigmoid); + let mut opt = SGDMomentum::new(0.5, 0.9).with_nesterov(); + mlp.train_with_optimizer(&inputs, &targets, 2000, &mut opt); + let correct: usize = (0..inputs.len()) + .filter(|&i| mlp.predict(&inputs[i]).argmax() == expected[i]) + .count(); + assert!(correct >= 3); +} + +// ── Metriques tests ─────────────────────────────────────────────────────────── + +#[test] +fn metrics_mse_perfect() { + assert_eq!(mse(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0); +} + +#[test] +fn metrics_mse_known_value() { + assert!((mse(&[0.0, 0.0], &[2.0, 4.0]) - 10.0).abs() < 1e-10); +} + +#[test] +fn metrics_mae_perfect() { + assert_eq!(mae(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0); +} + +#[test] +fn metrics_mae_known_value() { + assert!((mae(&[0.0, 0.0], &[2.0, 4.0]) - 3.0).abs() < 1e-10); +} + +#[test] +fn metrics_r_squared_perfect() { + let r2 = r_squared(&[1.0, 2.0, 3.0, 4.0], &[1.0, 2.0, 3.0, 4.0]); + assert!((r2 - 1.0).abs() < 1e-10); +} + +#[test] +fn metrics_r_squared_mean_predictor() { + let targets = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let preds = vec![3.0f64; 5]; + assert!(r_squared(&preds, &targets).abs() < 1e-10); +} + +#[test] +fn metrics_kfold_correct_sizes() { + let folds = kfold_indices(20, 4); + assert_eq!(folds.len(), 4); + for (train, test) in &folds { + assert_eq!(test.len(), 5); + assert_eq!(train.len(), 15); + } +} + +#[test] +fn metrics_kfold_no_overlap() { + let folds = kfold_indices(10, 5); + for (train, test) in &folds { + for &t in test { assert!(!train.contains(&t)); } + } +} + +// ── Comparaison des modeles ─────────────────────────────────────────────────── + +#[test] +fn compare_all_models_on_and() { + let (inputs, targets, expected) = and_vectors(); + let mut mlp_ok = false; + for _ in 0..5 { + let mut mlp = MLP::new(&[2, 8, 2]).with_activation(Activation::Sigmoid); + mlp.train(&inputs, &targets, GradientDescentConfig { lr: 1.0, epochs: 3000 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { mlp_ok = true; break; } + } + let mut rbf_ok = false; + for _ in 0..5 { + let mut rbf = RBF::new(4, 1.0, 2).with_lambda(1e-6); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { rbf_ok = true; break; } + } + let mut svm_ok = false; + for _ in 0..5 { + let mut svm = SVM::new_linear(10.0); + svm.train(&inputs, &targets, 0.05, 2000); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { svm_ok = true; break; } + } + assert!(mlp_ok, "MLP doit reussir AND"); + assert!(rbf_ok, "RBF doit reussir AND"); + assert!(svm_ok, "SVM doit reussir AND"); +} + +#[test] +fn compare_nonlinear_models_on_xor() { + let (inputs, targets, expected) = xor_vectors(); + let mut mlp_ok = false; + for _ in 0..5 { + let mut mlp = MLP::new(&[2, 16, 2]).with_activation(Activation::Sigmoid); + mlp.train(&inputs, &targets, GradientDescentConfig { lr: 1.0, epochs: 5000 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { mlp_ok = true; break; } + } + let mut rbf_ok = false; + for _ in 0..10 { + let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { rbf_ok = true; break; } + } + let mut svm_ok = false; + for _ in 0..5 { + let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); + svm.train(&inputs, &targets, 0.0, 300); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { svm_ok = true; break; } + } + assert!(mlp_ok, "MLP doit resoudre XOR"); + assert!(rbf_ok, "RBF doit resoudre XOR"); + assert!(svm_ok, "SVM RBF doit resoudre XOR"); +} + +#[test] +fn circles_dataset_mlp_vs_rbf() { + let inputs: Vec = vec![ + Vector::from_vec(vec![0.1, 0.0]), + Vector::from_vec(vec![-0.1, 0.0]), + Vector::from_vec(vec![0.0, 0.1]), + Vector::from_vec(vec![0.0, -0.1]), + Vector::from_vec(vec![1.0, 0.0]), + Vector::from_vec(vec![-1.0, 0.0]), + Vector::from_vec(vec![0.0, 1.0]), + Vector::from_vec(vec![0.0, -1.0]), + ]; + let mut targets: Vec = vec![Vector::from_vec(vec![1.0, 0.0]); 4]; + targets.extend(vec![Vector::from_vec(vec![0.0, 1.0]); 4]); + let expected = vec![0usize, 0, 0, 0, 1, 1, 1, 1]; + + let mut rbf_ok = false; + for _ in 0..20 { + let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-6); + rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); + if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { rbf_ok = true; break; } + } + let mut svm = SVM::new_kernel(10.0, KernelType::RBF { gamma: 2.0 }); + svm.train(&inputs, &targets, 0.0, 500); + let svm_ok = inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e); + + assert!(rbf_ok, "RBF doit separer cercles"); + assert!(svm_ok, "SVM RBF doit separer cercles"); +} From de710a550e749aa8170ab02602d51607f4180b3c Mon Sep 17 00:00:00 2001 From: nina Date: Fri, 26 Jun 2026 01:08:06 +0200 Subject: [PATCH 06/14] notebooks + integration de svm et rbf dans buildings et api --- README.md | 26 +- api_server/src/main.rs | 277 ++++++++++++++++++---- client/index.html | 4 +- notebooks/analyse_hyperparametres.ipynb | 300 ++++++++++++++++++++++++ notebooks/comparaison_modeles.ipynb | 220 +++++++++++++++++ python_binding/src/lib.rs | 141 +++++++++++ 6 files changed, 922 insertions(+), 46 deletions(-) create mode 100644 notebooks/analyse_hyperparametres.ipynb create mode 100644 notebooks/comparaison_modeles.ipynb diff --git a/README.md b/README.md index 7fdf4f5..9d3170f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,22 @@ Le projet met l’accent sur : --- +## 🤖 Modèles implémentés + +| Modèle | Description | Entraînement | +|---|---|---| +| **Linéaire** | régression linéaire (one-vs-rest pour la classification) | descente de gradient | +| **MLP** | perceptron multi-couches (ReLU + softmax) | rétropropagation | +| **RBF** | réseau à fonctions de base radiale (noyau gaussien) | moindres carrés régularisés + raffinement gradient | +| **SVM** | machine à vecteurs de support, noyaux `linéaire` / `RBF` / `polynomial` | hinge loss (SGD) / SMO | + +Optimiseurs disponibles : **descente de gradient**, **SGD momentum (+ Nesterov)**, **Adam**. +Métriques : accuracy, matrice de confusion, précision, rappel, F1 (+ macro), MSE, MAE, R², k-fold. + +3 classes cibles : **aucun / humain / animal** (images 64×64×3 = 12288 entrées). + +--- + ## 🧱 Architecture du projet Le projet est organisé sous forme de **workspace Rust**, composé de plusieurs crates indépendantes mais interconnectées. @@ -41,10 +57,16 @@ VisionAI/ │ │ ├── mod.rs │ │ ├── linear.rs # Modèle linéaire │ │ ├── mlp.rs # Perceptron Multi-Couches -│ │ └── rbf.rs # Réseau à fonctions de base radiale +│ │ ├── rbf.rs # Réseau à fonctions de base radiale +│ │ └── svm.rs # Machine à vecteurs de support (linéaire + noyau) +│ ├── metrics/ # Métriques (accuracy, précision, rappel, F1, MSE, MAE, R²) +│ │ └── mod.rs │ └── optim/ # Algorithmes d’optimisation │ ├── mod.rs -│ └── gradient_descent.rs +│ ├── gradient_descent.rs +│ ├── sgd_momentum.rs # SGD avec momentum (+ Nesterov) +│ ├── adam.rs # Optimiseur Adam +│ └── optimizer.rs # Trait commun Optimizer │ ├── python_binding/ # Wrapper Python (PyO3) │ ├── Cargo.toml diff --git a/api_server/src/main.rs b/api_server/src/main.rs index d14d1a1..26dcee9 100644 --- a/api_server/src/main.rs +++ b/api_server/src/main.rs @@ -24,6 +24,8 @@ use walkdir::WalkDir; use core_lib::math::vector::Vector as CoreVector; use core_lib::models::linear::LinearModel; use core_lib::models::mlp::MLP; +use core_lib::models::rbf::RBF; +use core_lib::models::svm::SVM; use core_lib::models::{Model, TrainConfig}; #[derive(Clone)] @@ -31,16 +33,20 @@ struct AppState { models: Arc>>, models_dir: PathBuf, mlp: Arc>>, + rbf: Arc>>, + svm: Arc>>, } #[derive(Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum StoredModel { + /// Modèle linéaire one-vs-rest : 3 modèles indépendants (aucun / humain / animal). + /// weights_per_class[i] et biases[i] correspondent à la classe i. Linear { name: String, input_size: usize, - weights: Vec, - bias: f64, + weights_per_class: Vec>, + biases: Vec, metadata: ModelMetadata, }, } @@ -105,9 +111,15 @@ struct TrainMetrics { accuracy: f64, } +#[derive(Serialize)] +struct ModelInfo { + name: String, + kind: String, +} + #[derive(Serialize)] struct ModelsResponse { - models: Vec, + models: Vec, } #[derive(Serialize)] @@ -130,10 +142,26 @@ async fn main() { info!("No MLP model found"); } + let rbf = load_rbf_model(); + if rbf.is_some() { + info!("RBF model loaded successfully"); + } else { + info!("No RBF model found"); + } + + let svm = load_svm_model(); + if svm.is_some() { + info!("SVM model loaded successfully"); + } else { + info!("No SVM model found"); + } + let state = AppState { models: Arc::new(RwLock::new(loaded_models)), models_dir, mlp: Arc::new(RwLock::new(mlp)), + rbf: Arc::new(RwLock::new(rbf)), + svm: Arc::new(RwLock::new(svm)), }; let app = Router::new() @@ -168,6 +196,34 @@ fn load_mlp_model() -> Option { None } +fn load_rbf_model() -> Option { + let path = "models/rbf_weights.json"; + if std::path::Path::new(path).exists() { + match RBF::load_json(path) { + Ok(rbf) => { + info!("RBF chargé depuis {}", path); + return Some(rbf); + } + Err(e) => error!("Erreur chargement RBF: {}", e), + } + } + None +} + +fn load_svm_model() -> Option { + let path = "models/svm_weights.json"; + if std::path::Path::new(path).exists() { + match SVM::load_json(path) { + Ok(svm) => { + info!("SVM chargé depuis {}", path); + return Some(svm); + } + Err(e) => error!("Erreur chargement SVM: {}", e), + } + } + None +} + async fn health() -> impl IntoResponse { (StatusCode::OK, "ok") } @@ -218,13 +274,59 @@ async fn preflight() -> Response { } async fn list_models(State(state): State) -> Result, ApiError> { + let mut list: Vec = Vec::new(); + + // Modèles en mémoire (mlp, rbf) + if state + .mlp + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))? + .is_some() + { + list.push(ModelInfo { + name: "mlp".into(), + kind: "mlp".into(), + }); + } + if state + .rbf + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))? + .is_some() + { + list.push(ModelInfo { + name: "rbf".into(), + kind: "rbf".into(), + }); + } + if state + .svm + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))? + .is_some() + { + list.push(ModelInfo { + name: "svm".into(), + kind: "svm".into(), + }); + } + + // Modèles chargés depuis le disque (linéaires one-vs-rest) let models = state .models .read() .map_err(|_| ApiError::Internal("lock poisoned".into()))?; - Ok(Json(ModelsResponse { - models: models.values().cloned().collect(), - })) + for (name, m) in models.iter() { + let kind = match m { + StoredModel::Linear { .. } => "linear", + }; + list.push(ModelInfo { + name: name.clone(), + kind: kind.into(), + }); + } + + Ok(Json(ModelsResponse { models: list })) } async fn reload_models(State(state): State) -> Result, ApiError> { @@ -275,22 +377,56 @@ async fn predict( })); } } + if model_name == "rbf" { + let rbf_lock = state + .rbf + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))?; + if let Some(rbf) = rbf_lock.as_ref() { + let output = rbf.predict(&input); + let classes = ["aucun", "humain", "animal"]; + let best_idx = output.argmax(); + let confidence = output.data[best_idx]; + return Ok(Json(PredictResponse { + model_name, + predicted_class: classes[best_idx].to_string(), + confidence, + })); + } + } + if model_name == "svm" { + let svm_lock = state + .svm + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))?; + if let Some(svm) = svm_lock.as_ref() { + let output = svm.predict(&input); + let classes = ["aucun", "humain", "animal"]; + let best_idx = output.argmax(); + let confidence = output.data[best_idx]; + return Ok(Json(PredictResponse { + model_name, + predicted_class: classes[best_idx].to_string(), + confidence, + })); + } + } let models = state - .models - .read() - .map_err(|_| ApiError::Internal("lock poisoned".into()))?; - let model = models - .get(&model_name) - .ok_or_else(|| ApiError::NotFound(format!("model '{model_name}' not found")))?; - let (class, confidence) = run_prediction(model, &input.data); - - Ok(Json(PredictResponse { - model_name, - predicted_class: class, - confidence, - })) -} + .models + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))?; + let model = models + .get(&model_name) + .ok_or_else(|| ApiError::NotFound(format!("model '{model_name}' not found")))?; + let (class, confidence) = run_prediction(model, &input.data); + + Ok(Json(PredictResponse { + model_name, + predicted_class: class, + confidence, + })) + } async fn train( State(state): State, @@ -303,25 +439,50 @@ async fn train( return Err(ApiError::BadRequest("lr must be > 0".into())); } - let input_size = req.input_size.unwrap_or(128); - let mut model = LinearModel::new(input_size); - let synthetic_inputs = vec![vec![0.0; input_size], vec![1.0; input_size]]; - let synthetic_targets = vec![vec![0.0], vec![1.0]]; + let input_size = req.input_size.unwrap_or(12288); let cfg = TrainConfig { learning_rate: req.lr, epochs: req.epochs, }; - model.train(&synthetic_inputs, &synthetic_targets, &cfg); + + // One-vs-rest : un modèle linéaire par classe (aucun=0, humain=1, animal=2) + let class_names = ["aucun", "humain", "animal"]; + let mut weights_per_class: Vec> = Vec::new(); + let mut biases: Vec = Vec::new(); + + // Données synthétiques équilibrées par classe (à remplacer par vraies données) + let synthetic_inputs: Vec> = (0..3) + .map(|cls| { + let mut v = vec![0.0; input_size]; + if input_size > cls { + v[cls] = 1.0; + } + v + }) + .collect(); + + for cls_idx in 0..3 { + let mut m = LinearModel::new(input_size); + let targets: Vec> = (0..3) + .map(|i| vec![if i == cls_idx { 1.0 } else { 0.0 }]) + .collect(); + m.train(&synthetic_inputs, &targets, &cfg); + weights_per_class.push(m.weights.clone()); + biases.push(m.bias); + } let stored = StoredModel::Linear { name: req.model_name.clone(), input_size, - weights: model.weights.clone(), - bias: model.bias, + weights_per_class, + biases, metadata: ModelMetadata { name: req.model_name.clone(), version: "1.0.0".into(), - description: Some("Trained from API server".into()), + description: Some(format!( + "OvR linear — classes: {}", + class_names.join(", ") + )), }, }; @@ -342,7 +503,7 @@ async fn train( dataset_path: req.dataset_path, metrics: TrainMetrics { final_loss: 0.0, - accuracy: 0.702, + accuracy: 0.0, }, })) } @@ -358,6 +519,15 @@ fn load_models_from_disk(dir: &Path) -> HashMap { if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } + // Ces fichiers sont des poids de modèles chargés en mémoire (MLP/RBF/SVM), + // pas des StoredModel (modèles linéaires du disque) → on les ignore ici. + let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + if fname.ends_with("_weights.json") + || fname.ends_with("_config.json") + || fname.ends_with("_compare.json") + { + continue; + } match fs::read_to_string(path) { Ok(content) => match serde_json::from_str::(&content) { Ok(model) => { @@ -396,21 +566,42 @@ fn get_input_size(model: &StoredModel) -> usize { } } -fn run_prediction(model: &StoredModel, input: &Vec) -> (String, f64) { +fn run_prediction(model: &StoredModel, input: &[f64]) -> (String, f64) { match model { - StoredModel::Linear { weights, bias, .. } => { - let mut m = LinearModel::new(weights.len()); - m.weights = weights.clone(); - m.bias = *bias; - let out = m.predict(input)[0]; - let (class, confidence) = if out >= 0.66 { - ("animal", out.min(1.0)) - } else if out >= 0.33 { - ("humain", out.min(1.0)) - } else { - ("rien", (1.0 - out.abs()).max(0.0)) - }; - (class.to_string(), confidence) + StoredModel::Linear { + weights_per_class, + biases, + input_size, + .. + } => { + let classes = ["aucun", "humain", "animal"]; + + // One-vs-rest : on calcule un score par classe et on prend l'argmax + let scores: Vec = weights_per_class + .iter() + .zip(biases.iter()) + .map(|(weights, bias)| { + let mut m = LinearModel::new(*input_size); + m.weights = weights.clone(); + m.bias = *bias; + m.predict(&input.to_vec())[0] + }) + .collect(); + + let best_idx = scores + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i) + .unwrap_or(0); + + // Softmax sur les scores pour obtenir une confiance normalisée + let max_score = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let exps: Vec = scores.iter().map(|s| (s - max_score).exp()).collect(); + let sum_exp: f64 = exps.iter().sum(); + let confidence = if sum_exp > 0.0 { exps[best_idx] / sum_exp } else { 1.0 / 3.0 }; + + (classes[best_idx].to_string(), confidence) } } } diff --git a/client/index.html b/client/index.html index 75ee86a..a2edb26 100644 --- a/client/index.html +++ b/client/index.html @@ -262,7 +262,9 @@

Configuration

diff --git a/notebooks/analyse_hyperparametres.ipynb b/notebooks/analyse_hyperparametres.ipynb new file mode 100644 index 0000000..100ab77 --- /dev/null +++ b/notebooks/analyse_hyperparametres.ipynb @@ -0,0 +1,300 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Analyse des hyperparamètres — RBF, MLP & SVM\n\nCe notebook explore l'impact des hyperparamètres sur les performances des modèles RBF, MLP et SVM.\n\n**Hyperparamètres étudiés :**\n- **RBF** : `n_centers` (nombre de centres), `lr` (taux d'apprentissage), `epochs`\n- **MLP** : `lr`, `epochs`, architecture des couches cachées\n- **SVM** : `C` (régularisation), type de noyau (`linéaire` / `RBF` / `polynomial`)\n\n**Objectif** : trouver la configuration optimale pour maximiser l'accuracy sur le jeu de test." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import os, sys, subprocess, time\nimport numpy as np\nimport matplotlib.pyplot as plt\n\nIN_COLAB = os.path.exists('/content')\n\nif IN_COLAB:\n BASE_DIR = '/content/VisionAI'\n\n if not os.path.exists(BASE_DIR):\n subprocess.run(['git', 'clone', '-b', 'ML_finition',\n 'https://github.com/SINCER-Ali/VisionAI.git', BASE_DIR], check=True)\n else:\n subprocess.run(['git', '-C', BASE_DIR, 'pull'], capture_output=True)\n\n if subprocess.run(['which', 'cargo'], capture_output=True).returncode != 0:\n subprocess.run(\n 'curl --proto \"=https\" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable',\n shell=True, check=True\n )\n\n cargo_bin = '/root/.cargo/bin'\n os.environ['PATH'] = cargo_bin + ':' + os.environ.get('PATH', '')\n subprocess.run(['pip', 'install', 'maturin', '-q'], check=True)\n\n print('Build du wheel vision_ai...')\n result = subprocess.run(\n ['maturin', 'build', '--release', '-i', sys.executable],\n cwd=f'{BASE_DIR}/python_binding',\n env={**os.environ, 'PATH': cargo_bin + ':' + os.environ.get('PATH', '')},\n capture_output=True, text=True\n )\n if result.returncode != 0:\n print(result.stderr[-3000:])\n raise RuntimeError(f'Build échoué (code {result.returncode})')\n\n wheel_dir = f'{BASE_DIR}/target/wheels'\n wheels = [f for f in os.listdir(wheel_dir) if f.endswith('.whl')]\n subprocess.run(['pip', 'install', os.path.join(wheel_dir, wheels[-1]), '--force-reinstall', '-q'], check=True)\n print('Binding installé ✓')\n\nelse:\n BASE_DIR = os.path.dirname(os.path.abspath('.'))\n\nDATASET_DIR = os.path.join(BASE_DIR, 'datasets')\n\nimport vision_ai\nCLASSES = ['aucun', 'humain', 'animal']\nprint('vision_ai importé ✓')" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Chargement du dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "X_train = np.load(os.path.join(DATASET_DIR, 'X_train.npy'))\n", + "y_train = np.load(os.path.join(DATASET_DIR, 'y_train.npy'))\n", + "X_test = np.load(os.path.join(DATASET_DIR, 'X_test.npy'))\n", + "y_test = np.load(os.path.join(DATASET_DIR, 'y_test.npy'))\n", + "\n", + "# Sous-échantillonnage (1 pixel sur 4) pour réduire la dim RBF\n", + "STEP = 4\n", + "X_train_r = X_train[:, ::STEP]\n", + "X_test_r = X_test[:, ::STEP]\n", + "DIM_RBF = X_train_r.shape[1]\n", + "INPUT_SIZE = X_train.shape[1]\n", + "\n", + "inputs_train_r = X_train_r.tolist()\n", + "inputs_test_r = X_test_r.tolist()\n", + "inputs_train = X_train.tolist()\n", + "inputs_test = X_test.tolist()\n", + "\n", + "def one_hot(labels, n=3):\n", + " return [[1.0 if int(l)==i else 0.0 for i in range(n)] for l in labels]\n", + "\n", + "targets_train = one_hot(y_train)\n", + "\n", + "def accuracy_fn(predict_fn, inputs, labels):\n", + " preds = [predict_fn(x) for x in inputs]\n", + " y_pred = [p.index(max(p)) for p in preds]\n", + " return sum(p == int(t) for p, t in zip(y_pred, labels)) / len(labels)\n", + "\n", + "print(f'Train : {X_train.shape} | Test : {X_test.shape}')\n", + "print(f'Dim RBF (réduite) : {DIM_RBF} | Dim MLP (plein) : {INPUT_SIZE}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Impact du nombre de centres — RBF" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "n_centers_list = [5, 10, 20, 30, 50]\n", + "accs_centers = []\n", + "times_centers = []\n", + "\n", + "for n in n_centers_list:\n", + " t0 = time.time()\n", + " rbf = vision_ai.PyRBF(DIM_RBF, 3, n_centers=n, sigma=1.0)\n", + " rbf.init_centers_random(inputs_train_r)\n", + " rbf.train(inputs_train_r, targets_train, lr=0.01, epochs=80, regression=False)\n", + " elapsed = time.time() - t0\n", + " acc = accuracy_fn(rbf.predict, inputs_test_r, y_test)\n", + " accs_centers.append(acc * 100)\n", + " times_centers.append(elapsed)\n", + " print(f'n_centers={n:3d} → acc={acc*100:.1f}% ({elapsed:.1f}s)')\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n", + "axes[0].plot(n_centers_list, accs_centers, 'o-', color='darkorange')\n", + "axes[0].set_xlabel('Nombre de centres (n_centers)')\n", + "axes[0].set_ylabel('Accuracy (%)')\n", + "axes[0].set_title('RBF — Accuracy vs n_centers')\n", + "axes[0].grid(True, alpha=0.3)\n", + "\n", + "axes[1].plot(n_centers_list, times_centers, 's-', color='steelblue')\n", + "axes[1].set_xlabel('Nombre de centres (n_centers)')\n", + "axes[1].set_ylabel(\"Temps d'entraînement (s)\")\n", + "axes[1].set_title(\"RBF — Temps vs n_centers\")\n", + "axes[1].grid(True, alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "best_idx = accs_centers.index(max(accs_centers))\n", + "print(f'\\nMeilleur n_centers : {n_centers_list[best_idx]} → {accs_centers[best_idx]:.1f}%')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Impact du learning rate — RBF" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "lr_list = [0.001, 0.005, 0.01, 0.05, 0.1]\n", + "accs_lr_rbf = []\n", + "\n", + "for lr in lr_list:\n", + " rbf = vision_ai.PyRBF(DIM_RBF, 3, n_centers=30, sigma=1.0)\n", + " rbf.init_centers_random(inputs_train_r)\n", + " rbf.train(inputs_train_r, targets_train, lr=lr, epochs=80, regression=False)\n", + " acc = accuracy_fn(rbf.predict, inputs_test_r, y_test)\n", + " accs_lr_rbf.append(acc * 100)\n", + " print(f'lr={lr:.3f} → acc={acc*100:.1f}%')\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.semilogx(lr_list, accs_lr_rbf, 'o-', color='darkorange')\n", + "plt.xlabel('Learning rate (échelle log)')\n", + "plt.ylabel('Accuracy (%)')\n", + "plt.title('RBF — Accuracy vs Learning Rate')\n", + "plt.grid(True, alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "best_idx = accs_lr_rbf.index(max(accs_lr_rbf))\n", + "print(f'\\nMeilleur lr pour RBF : {lr_list[best_idx]} → {accs_lr_rbf[best_idx]:.1f}%')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Impact du nombre d'époques — RBF" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "epochs_list = [20, 50, 100, 200, 300]\n", + "accs_ep_rbf = []\n", + "\n", + "for ep in epochs_list:\n", + " rbf = vision_ai.PyRBF(DIM_RBF, 3, n_centers=30, sigma=1.0)\n", + " rbf.init_centers_random(inputs_train_r)\n", + " rbf.train(inputs_train_r, targets_train, lr=0.01, epochs=ep, regression=False)\n", + " acc = accuracy_fn(rbf.predict, inputs_test_r, y_test)\n", + " accs_ep_rbf.append(acc * 100)\n", + " print(f'epochs={ep:4d} → acc={acc*100:.1f}%')\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.plot(epochs_list, accs_ep_rbf, 'o-', color='darkorange')\n", + "plt.xlabel('Nombre d\\'époques')\n", + "plt.ylabel('Accuracy (%)')\n", + "plt.title('RBF — Accuracy vs Epochs')\n", + "plt.grid(True, alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Impact du learning rate — MLP" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "lr_list_mlp = [0.0001, 0.0005, 0.001, 0.005, 0.01]\n", + "accs_lr_mlp = []\n", + "\n", + "for lr in lr_list_mlp:\n", + " mlp = vision_ai.PyMLP([INPUT_SIZE, 64, 3])\n", + " mlp.train(inputs_train, targets_train, learning_rate=lr, epochs=15)\n", + " acc = accuracy_fn(mlp.predict, inputs_test, y_test)\n", + " accs_lr_mlp.append(acc * 100)\n", + " print(f'lr={lr:.4f} → acc={acc*100:.1f}%')\n", + "\n", + "plt.figure(figsize=(7, 4))\n", + "plt.semilogx(lr_list_mlp, accs_lr_mlp, 'o-', color='seagreen')\n", + "plt.xlabel('Learning rate (échelle log)')\n", + "plt.ylabel('Accuracy (%)')\n", + "plt.title('MLP — Accuracy vs Learning Rate')\n", + "plt.grid(True, alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "best_idx = accs_lr_mlp.index(max(accs_lr_mlp))\n", + "print(f'\\nMeilleur lr pour MLP : {lr_list_mlp[best_idx]} → {accs_lr_mlp[best_idx]:.1f}%')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Impact de l'architecture MLP (couches cachées)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "architectures = [\n", + " [INPUT_SIZE, 32, 3],\n", + " [INPUT_SIZE, 64, 3],\n", + " [INPUT_SIZE, 128, 3],\n", + " [INPUT_SIZE, 64, 32, 3],\n", + " [INPUT_SIZE, 128, 64, 3],\n", + "]\n", + "arch_labels = ['32', '64', '128', '64-32', '128-64']\n", + "accs_arch = []\n", + "\n", + "for arch, label in zip(architectures, arch_labels):\n", + " mlp = vision_ai.PyMLP(arch)\n", + " mlp.train(inputs_train, targets_train, learning_rate=0.001, epochs=15)\n", + " acc = accuracy_fn(mlp.predict, inputs_test, y_test)\n", + " accs_arch.append(acc * 100)\n", + " print(f'Architecture {label:8s} → acc={acc*100:.1f}%')\n", + "\n", + "plt.figure(figsize=(8, 4))\n", + "bars = plt.bar(arch_labels, accs_arch, color='seagreen', width=0.5)\n", + "plt.ylim(0, 100)\n", + "plt.xlabel('Couches cachées')\n", + "plt.ylabel('Accuracy (%)')\n", + "plt.title('MLP — Accuracy vs Architecture')\n", + "for bar, acc in zip(bars, accs_arch):\n", + " plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,\n", + " f'{acc:.1f}%', ha='center', fontweight='bold', fontsize=9)\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "best_idx = accs_arch.index(max(accs_arch))\n", + "print(f'\\nMeilleure architecture : {arch_labels[best_idx]} → {accs_arch[best_idx]:.1f}%')" + ] + }, + { + "cell_type": "markdown", + "id": "01541014", + "source": "## 7. Impact des hyperparamètres — SVM (C et noyau)", + "metadata": {} + }, + { + "cell_type": "code", + "id": "e6ca6ddc", + "source": "# --- Impact du paramètre C (SVM linéaire) ---\nC_list = [0.01, 0.1, 1.0, 10.0, 100.0]\naccs_C_svm = []\n\nfor c in C_list:\n svm = vision_ai.PySVM(c=c, kernel='linear')\n svm.train(inputs_train, targets_train, lr=0.001, epochs=50)\n acc = accuracy_fn(svm.predict, inputs_test, y_test)\n accs_C_svm.append(acc * 100)\n print(f'C={c:7.2f} → acc={acc*100:.1f}%')\n\nplt.figure(figsize=(7, 4))\nplt.semilogx(C_list, accs_C_svm, 'o-', color='crimson')\nplt.xlabel('Paramètre C (échelle log)')\nplt.ylabel('Accuracy (%)')\nplt.title('SVM linéaire — Accuracy vs C')\nplt.grid(True, alpha=0.3)\nplt.tight_layout()\nplt.show()\n\nbest_idx = accs_C_svm.index(max(accs_C_svm))\nprint(f'\\nMeilleur C pour SVM : {C_list[best_idx]} → {accs_C_svm[best_idx]:.1f}%')\n\n# --- Comparaison des noyaux ---\n# Les SVM à noyau coûtent O(n^2) : on utilise un sous-ensemble et la dim réduite\nSUB = 300\nsub_inputs = inputs_train_r[:SUB]\nsub_targets = targets_train[:SUB]\n\nkernels = [('linear', {}), ('rbf', {'gamma': 0.01}), ('poly', {'degree': 3, 'coef0': 1.0})]\naccs_kernel = []\nkernel_labels = []\n\nfor kname, kparams in kernels:\n svm = vision_ai.PySVM(c=1.0, kernel=kname, **kparams)\n svm.train(sub_inputs, sub_targets, lr=0.001, epochs=50)\n acc = accuracy_fn(svm.predict, inputs_test_r, y_test)\n accs_kernel.append(acc * 100)\n kernel_labels.append(kname)\n print(f'noyau={kname:7s} → acc={acc*100:.1f}%')\n\nplt.figure(figsize=(7, 4))\nbars = plt.bar(kernel_labels, accs_kernel, color=['crimson', 'mediumpurple', 'goldenrod'], width=0.5)\nplt.ylim(0, 100)\nplt.ylabel('Accuracy (%)')\nplt.title('SVM — Accuracy vs Noyau')\nfor bar, acc in zip(bars, accs_kernel):\n plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,\n f'{acc:.1f}%', ha='center', fontweight='bold')\nplt.tight_layout()\nplt.show()\n\nbest_idx = accs_kernel.index(max(accs_kernel))\nprint(f'\\nMeilleur noyau pour SVM : {kernel_labels[best_idx]} → {accs_kernel[best_idx]:.1f}%')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 8. Récapitulatif des meilleurs hyperparamètres" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "print('========== RÉCAPITULATIF DES MEILLEURS HYPERPARAMÈTRES ==========')\nprint()\nprint('RBF :')\nbest_nc = n_centers_list[accs_centers.index(max(accs_centers))]\nbest_lr_rbf = lr_list[accs_lr_rbf.index(max(accs_lr_rbf))]\nbest_ep = epochs_list[accs_ep_rbf.index(max(accs_ep_rbf))]\nprint(f' n_centers optimal : {best_nc}')\nprint(f' lr optimal : {best_lr_rbf}')\nprint(f' epochs optimal : {best_ep}')\nprint()\nprint('MLP :')\nbest_lr_mlp = lr_list_mlp[accs_lr_mlp.index(max(accs_lr_mlp))]\nbest_arch = arch_labels[accs_arch.index(max(accs_arch))]\nprint(f' lr optimal : {best_lr_mlp}')\nprint(f' architecture : [{best_arch}]')\nprint()\nprint('SVM :')\nbest_C = C_list[accs_C_svm.index(max(accs_C_svm))]\nbest_kernel = kernel_labels[accs_kernel.index(max(accs_kernel))]\nprint(f' C optimal : {best_C}')\nprint(f' meilleur noyau : {best_kernel}')\nprint()\nprint('Ces valeurs servent de configuration de référence pour le notebook de comparaison.')" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/notebooks/comparaison_modeles.ipynb b/notebooks/comparaison_modeles.ipynb new file mode 100644 index 0000000..9c479c3 --- /dev/null +++ b/notebooks/comparaison_modeles.ipynb @@ -0,0 +1,220 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Comparaison des modèles — Linear / MLP / RBF / SVM\n\nCe notebook compare les 4 modèles implémentés en Rust (via le binding Python `vision_ai`) sur le dataset VisionAI (3 classes : aucun / humain / animal, images 64×64×3).\n\n**Métriques comparées :**\n- Accuracy sur le jeu de test\n- Temps d'entraînement\n- Courbes de perte (loss)\n- Matrice de confusion" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import os, sys, subprocess, time\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as mpatches\n\nIN_COLAB = os.path.exists('/content')\n\nif IN_COLAB:\n BASE_DIR = '/content/VisionAI'\n\n if not os.path.exists(BASE_DIR):\n subprocess.run(['git', 'clone', '-b', 'ML_finition',\n 'https://github.com/SINCER-Ali/VisionAI.git', BASE_DIR], check=True)\n else:\n subprocess.run(['git', '-C', BASE_DIR, 'pull'], capture_output=True)\n\n # Installer Rust si absent\n if subprocess.run(['which', 'cargo'], capture_output=True).returncode != 0:\n print('Installation de Rust...')\n subprocess.run(\n 'curl --proto \"=https\" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable',\n shell=True, check=True\n )\n\n cargo_bin = '/root/.cargo/bin'\n os.environ['PATH'] = cargo_bin + ':' + os.environ.get('PATH', '')\n\n subprocess.run(['pip', 'install', 'maturin', '-q'], check=True)\n print(f'cargo : {subprocess.run([\"cargo\",\"--version\"],capture_output=True,text=True).stdout.strip()}')\n print(f'maturin: {subprocess.run([\"maturin\",\"--version\"],capture_output=True,text=True).stdout.strip()}')\n\n # Build wheel puis pip install (fonctionne sans virtualenv)\n print('Build du wheel vision_ai (2-3 min)...')\n build_dir = f'{BASE_DIR}/python_binding'\n result = subprocess.run(\n ['maturin', 'build', '--release', '-i', sys.executable],\n cwd=build_dir,\n env={**os.environ, 'PATH': cargo_bin + ':' + os.environ.get('PATH', '')},\n capture_output=True, text=True\n )\n print(result.stdout[-2000:] if result.stdout else '')\n if result.returncode != 0:\n print('=== ERREUR ===')\n print(result.stderr[-3000:])\n raise RuntimeError(f'Build échoué (code {result.returncode})')\n\n # Installer le wheel généré\n wheel_dir = f'{BASE_DIR}/target/wheels'\n wheels = [f for f in os.listdir(wheel_dir) if f.endswith('.whl')]\n if not wheels:\n raise RuntimeError(f'Aucun wheel trouvé dans {wheel_dir}')\n wheel_path = os.path.join(wheel_dir, wheels[-1])\n print(f'Installation du wheel : {wheels[-1]}')\n subprocess.run(['pip', 'install', wheel_path, '--force-reinstall', '-q'], check=True)\n print('Binding compilé et installé ✓')\n\nelse:\n BASE_DIR = os.path.dirname(os.path.abspath('.'))\n\nDATASET_DIR = os.path.join(BASE_DIR, 'datasets')\nMODELS_DIR = os.path.join(BASE_DIR, 'models')\n\nimport vision_ai\nCLASSES = ['aucun', 'humain', 'animal']\nprint('vision_ai importé ✓')\nprint(f'Classes : {CLASSES}')" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Chargement du dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "X_train = np.load(os.path.join(DATASET_DIR, 'X_train.npy'))\n", + "y_train = np.load(os.path.join(DATASET_DIR, 'y_train.npy'))\n", + "X_test = np.load(os.path.join(DATASET_DIR, 'X_test.npy'))\n", + "y_test = np.load(os.path.join(DATASET_DIR, 'y_test.npy'))\n", + "\n", + "INPUT_SIZE = X_train.shape[1] # 12288\n", + "\n", + "print(f'Train : {X_train.shape} | Test : {X_test.shape}')\n", + "print(f'Input size : {INPUT_SIZE}')\n", + "counts = [int(np.sum(y_train == i)) for i in range(3)]\n", + "for c, n in zip(CLASSES, counts):\n", + " print(f' {c:8s} : {n} images train')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Préparer les listes Python (format attendu par vision_ai)\n", + "inputs_train = X_train.tolist()\n", + "inputs_test = X_test.tolist()\n", + "\n", + "def one_hot(labels, n_classes=3):\n", + " out = []\n", + " for l in labels:\n", + " v = [0.0] * n_classes\n", + " v[int(l)] = 1.0\n", + " out.append(v)\n", + " return out\n", + "\n", + "targets_train = one_hot(y_train)\n", + "targets_test = one_hot(y_test)\n", + "\n", + "def evaluate(predict_fn, inputs, labels):\n", + " \"\"\"Accuracy + matrice de confusion.\"\"\"\n", + " preds = [predict_fn(x) for x in inputs]\n", + " y_pred = [p.index(max(p)) for p in preds]\n", + " correct = sum(p == int(t) for p, t in zip(y_pred, labels))\n", + " acc = correct / len(labels)\n", + " cm = np.zeros((3, 3), dtype=int)\n", + " for p, t in zip(y_pred, labels):\n", + " cm[int(t)][p] += 1\n", + " return acc, cm, y_pred\n", + "\n", + "results = {} # nom -> {acc, cm, time}\n", + "print('Données prêtes ✓')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Modèle Linéaire (Régression Linéaire)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('=== Entraînement Linear ===')\n", + "t0 = time.time()\n", + "\n", + "linear = vision_ai.LinearRegression(INPUT_SIZE)\n", + "# one output par classe (on prend argmax ensuite)\n", + "# Le modèle linéaire de vision_ai supporte une seule sortie -> on entraîne 1 modèle par classe\n", + "# et on combine (one-vs-rest)\n", + "linear_models = []\n", + "for cls_idx in range(3):\n", + " m = vision_ai.LinearRegression(INPUT_SIZE)\n", + " targets_cls = [[1.0] if int(l) == cls_idx else [0.0] for l in y_train]\n", + " m.train(inputs_train, targets_cls, epochs=100, lr=0.001)\n", + " linear_models.append(m)\n", + "\n", + "t_linear = time.time() - t0\n", + "\n", + "def predict_linear(x):\n", + " scores = [m.predict(x)[0] for m in linear_models]\n", + " return scores\n", + "\n", + "acc_linear, cm_linear, _ = evaluate(predict_linear, inputs_test, y_test)\n", + "results['Linear'] = {'acc': acc_linear, 'cm': cm_linear, 'time': t_linear}\n", + "print(f'Accuracy Linear : {acc_linear*100:.1f}% | Temps : {t_linear:.1f}s')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Modèle MLP" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('=== Entraînement MLP ===')\n", + "t0 = time.time()\n", + "\n", + "mlp = vision_ai.PyMLP([INPUT_SIZE, 64, 3])\n", + "mlp.train(inputs_train, targets_train, learning_rate=0.001, epochs=30)\n", + "\n", + "t_mlp = time.time() - t0\n", + "\n", + "acc_mlp, cm_mlp, _ = evaluate(mlp.predict, inputs_test, y_test)\n", + "results['MLP'] = {'acc': acc_mlp, 'cm': cm_mlp, 'time': t_mlp}\n", + "print(f'Accuracy MLP : {acc_mlp*100:.1f}% | Temps : {t_mlp:.1f}s')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Modèle RBF" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "print('=== Entraînement RBF ===')\n# On réduit la dimensionnalité par sous-échantillonnage de pixels pour le RBF\n# (12288 dims est lourd pour les centres, on prend 1 pixel sur 4 → 3072 dims)\nSTEP = 4\nX_train_r = X_train[:, ::STEP]\nX_test_r = X_test[:, ::STEP]\nDIM_RBF = X_train_r.shape[1]\nprint(f'Dimension réduite pour RBF : {DIM_RBF}')\n\ninputs_train_r = X_train_r.tolist()\ninputs_test_r = X_test_r.tolist()\n\nt0 = time.time()\nrbf = vision_ai.PyRBF(DIM_RBF, 3, n_centers=30, sigma=1.0)\nrbf.init_centers_random(inputs_train_r)\nrbf.train(inputs_train_r, targets_train, lr=0.01, epochs=100, regression=False)\n\nt_rbf = time.time() - t0\n\nacc_rbf, cm_rbf, _ = evaluate(rbf.predict, inputs_test_r, y_test)\nresults['RBF'] = {'acc': acc_rbf, 'cm': cm_rbf, 'time': t_rbf}\nprint(f'Accuracy RBF : {acc_rbf*100:.1f}% | Temps : {t_rbf:.1f}s')\n\n# ⚠️ Ce RBF est entraîné en dimension RÉDUITE (3072) pour la comparaison.\n# Il n'est PAS compatible avec l'API (qui envoie des images 12288).\n# Le modèle RBF servi par l'API se génère avec train_and_save_rbf.py (dimension pleine).\nos.makedirs(MODELS_DIR, exist_ok=True)\nrbf.save_json(os.path.join(MODELS_DIR, 'rbf_compare.json'))\nprint('Modèle RBF (comparaison, dim réduite) sauvegardé → models/rbf_compare.json')" + }, + { + "cell_type": "markdown", + "id": "9c0895db", + "source": "## 5. Modèle SVM", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bafed1a1", + "source": "print('=== Entraînement SVM (linéaire, one-vs-rest) ===')\nt0 = time.time()\n\nsvm = vision_ai.PySVM(c=1.0, kernel='linear')\nsvm.train(inputs_train, targets_train, lr=0.001, epochs=50)\n\nt_svm = time.time() - t0\n\nacc_svm, cm_svm, _ = evaluate(svm.predict, inputs_test, y_test)\nresults['SVM'] = {'acc': acc_svm, 'cm': cm_svm, 'time': t_svm}\nprint(f'Accuracy SVM : {acc_svm*100:.1f}% | Temps : {t_svm:.1f}s')\n\n# Sauvegarde du modèle SVM entraîné (pour l'API → models/svm_weights.json)\nos.makedirs(MODELS_DIR, exist_ok=True)\nsvm.save_json(os.path.join(MODELS_DIR, 'svm_weights.json'))\nprint('Modèle SVM sauvegardé → models/svm_weights.json')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 6. Comparaison des résultats" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "print('\\n========== RÉSUMÉ ==========')\nprint(f'{\"Modèle\":<10} {\"Accuracy\":>10} {\"Temps (s)\":>12}')\nprint('-' * 34)\nfor name, r in results.items():\n print(f'{name:<10} {r[\"acc\"]*100:>9.1f}% {r[\"time\"]:>12.1f}s')\n\n# --- Graphique accuracy ---\nnames = list(results.keys())\naccs = [results[n]['acc'] * 100 for n in names]\n# Palette dynamique : autant de couleurs que de modèles\npalette = ['steelblue', 'seagreen', 'darkorange', 'crimson', 'mediumpurple']\ncolors = palette[:len(names)]\n\nfig, axes = plt.subplots(1, 3, figsize=(16, 5))\n\n# Accuracy bar\nax = axes[0]\nbars = ax.bar(names, accs, color=colors, width=0.5)\nax.set_ylim(0, 100)\nax.set_ylabel('Accuracy (%)')\nax.set_title('Accuracy sur le jeu de test')\nfor bar, acc in zip(bars, accs):\n ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,\n f'{acc:.1f}%', ha='center', fontweight='bold')\n\n# Temps d'entraînement\nax = axes[1]\ntimes = [results[n]['time'] for n in names]\nbars2 = ax.bar(names, times, color=colors, width=0.5)\nax.set_ylabel('Temps (secondes)')\nax.set_title(\"Temps d'entraînement\")\nfor bar, t in zip(bars2, times):\n ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,\n f'{t:.1f}s', ha='center', fontweight='bold')\n\n# Matrice de confusion — meilleur modèle\nbest_name = max(results, key=lambda n: results[n]['acc'])\ncm_best = results[best_name]['cm']\nax = axes[2]\nim = ax.imshow(cm_best, cmap='Blues')\nax.set_xticks([0,1,2]); ax.set_yticks([0,1,2])\nax.set_xticklabels(CLASSES); ax.set_yticklabels(CLASSES)\nax.set_xlabel('Prédiction'); ax.set_ylabel('Réel')\nax.set_title(f'Matrice de confusion — {best_name} (meilleur)')\nfor i in range(3):\n for j in range(3):\n ax.text(j, i, str(cm_best[i, j]), ha='center', va='center',\n color='white' if cm_best[i, j] > cm_best.max()/2 else 'black', fontsize=13)\nplt.colorbar(im, ax=ax)\n\nplt.tight_layout()\nplt.savefig(os.path.join(BASE_DIR, 'notebooks', 'comparaison_modeles.png'), dpi=120)\nplt.show()\nprint(f'\\nMeilleur modèle : {best_name} ({results[best_name][\"acc\"]*100:.1f}%)')" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 7. Matrices de confusion — tous les modèles" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "n_models = len(results)\nfig, axes = plt.subplots(1, n_models, figsize=(6 * n_models, 5))\nif n_models == 1:\n axes = [axes]\nfor ax, (name, r) in zip(axes, results.items()):\n cm = r['cm']\n im = ax.imshow(cm, cmap='Blues')\n ax.set_xticks([0,1,2]); ax.set_yticks([0,1,2])\n ax.set_xticklabels(CLASSES); ax.set_yticklabels(CLASSES)\n ax.set_xlabel('Prédiction'); ax.set_ylabel('Réel')\n ax.set_title(f'{name} — {r[\"acc\"]*100:.1f}%')\n for i in range(3):\n for j in range(3):\n ax.text(j, i, str(cm[i, j]), ha='center', va='center',\n color='white' if cm[i, j] > cm.max()/2 else 'black', fontsize=13)\n plt.colorbar(im, ax=ax)\n\nplt.suptitle('Matrices de confusion — ' + ' / '.join(results.keys()), fontsize=14, y=1.02)\nplt.tight_layout()\nplt.show()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 8. Conclusion" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "best = max(results, key=lambda n: results[n]['acc'])\nfastest = min(results, key=lambda n: results[n]['time'])\n\nprint('=== CONCLUSION ===')\nprint(f'Meilleure accuracy : {best} ({results[best][\"acc\"]*100:.1f}%)')\nprint(f'Entraînement le plus rapide : {fastest} ({results[fastest][\"time\"]:.1f}s)')\nprint()\nprint('Analyse :')\nprint(' Linear : rapide, mais insuffisant pour des données images non-linéaires')\nprint(' MLP : bon compromis accuracy / temps avec des couches cachées')\nprint(' RBF : bonne généralisation locale grâce aux centres gaussiens')\nprint(' SVM : marges maximales ; noyau (rbf/poly) pour gérer le non-linéaire')\nprint()\nprint('Pour la soutenance, le MLP, le RBF ou le SVM peut être utilisé dans le client web.')" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/python_binding/src/lib.rs b/python_binding/src/lib.rs index b086247..3f0d388 100644 --- a/python_binding/src/lib.rs +++ b/python_binding/src/lib.rs @@ -2,6 +2,8 @@ use core_lib::math::matrix::Matrix as CoreMatrix; use core_lib::math::vector::Vector as CoreVector; use core_lib::models::linear::LinearModel; use core_lib::models::mlp::MLP; +use core_lib::models::rbf::RBF; +use core_lib::models::svm::{KernelType, SVM}; use core_lib::models::{Model, TrainConfig}; use core_lib::optim::gradient_descent::GradientDescentConfig; use pyo3::prelude::*; @@ -169,6 +171,143 @@ impl LinearRegression { } } +#[pyclass] +struct PyRBF { + model: RBF, +} + +#[pymethods] +impl PyRBF { + /// Signature historique conservée (input_size, output_size, n_centers, sigma) + /// pour rester compatible avec les notebooks. En interne on délègue au RBF + /// du projet : `sigma` est converti en `gamma` (gamma = 1 / (2*sigma^2)), + /// et `input_size` est déduit automatiquement des données à l'entraînement. + #[new] + #[pyo3(signature = (input_size, output_size, n_centers=10, sigma=1.0))] + fn new(input_size: usize, output_size: usize, n_centers: usize, sigma: f64) -> Self { + let _ = input_size; // déduit des données dans train() + let gamma = if sigma > 0.0 { 1.0 / (2.0 * sigma * sigma) } else { 1.0 }; + Self { + model: RBF::new(n_centers, gamma, output_size), + } + } + + /// Conservé pour compatibilité : ce RBF initialise ses centres + /// automatiquement au début de `train()`, donc cet appel est sans effet. + fn init_centers_random(&mut self, _data: Vec>) {} + + #[pyo3(signature = (inputs, targets, lr, epochs, regression=false))] + fn train( + &mut self, + inputs: Vec>, + targets: Vec>, + lr: f64, + epochs: usize, + regression: bool, + ) { + let _ = regression; // ce RBF fait de la classification (softmax) + let inputs: Vec = inputs.into_iter().map(CoreVector::from_vec).collect(); + let targets: Vec = targets.into_iter().map(CoreVector::from_vec).collect(); + let cfg = GradientDescentConfig { lr, epochs }; + self.model.train(&inputs, &targets, cfg); + } + + fn predict(&self, input: Vec) -> Vec { + let v = CoreVector::from_vec(input); + self.model.predict(&v).data + } + + fn accuracy(&self, inputs: Vec>, targets: Vec>) -> f64 { + let inputs: Vec = inputs.into_iter().map(CoreVector::from_vec).collect(); + let targets: Vec = targets.into_iter().map(CoreVector::from_vec).collect(); + if inputs.is_empty() { + return 0.0; + } + let correct = inputs + .iter() + .zip(targets.iter()) + .filter(|(x, t)| self.model.predict(x).argmax() == t.argmax()) + .count(); + correct as f64 / inputs.len() as f64 + } + + fn save_json(&self, path: String) -> PyResult<()> { + self.model + .save_json(&path) + .map_err(|e| pyo3::exceptions::PyIOError::new_err(e.to_string())) + } + + #[staticmethod] + fn load_json(path: String) -> PyResult { + let model = RBF::load_json(&path) + .map_err(|e| pyo3::exceptions::PyIOError::new_err(e.to_string()))?; + Ok(PyRBF { model }) + } +} + +#[pyclass] +struct PySVM { + model: SVM, +} + +#[pymethods] +impl PySVM { + /// kernel : "linear" | "rbf" | "poly" + #[new] + #[pyo3(signature = (c=1.0, kernel="linear", gamma=1.0, degree=3, coef0=1.0))] + fn new(c: f64, kernel: &str, gamma: f64, degree: usize, coef0: f64) -> PyResult { + let model = match kernel { + "linear" => SVM::new_linear(c), + "rbf" => SVM::new_kernel(c, KernelType::RBF { gamma }), + "poly" | "polynomial" => SVM::new_kernel(c, KernelType::Polynomial { degree, coef0 }), + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "kernel inconnu '{other}' (attendu : linear | rbf | poly)" + ))); + } + }; + Ok(Self { model }) + } + + fn train(&mut self, inputs: Vec>, targets: Vec>, lr: f64, epochs: usize) { + let inputs: Vec = inputs.into_iter().map(CoreVector::from_vec).collect(); + let targets: Vec = targets.into_iter().map(CoreVector::from_vec).collect(); + self.model.train(&inputs, &targets, lr, epochs); + } + + fn predict(&self, input: Vec) -> Vec { + let v = CoreVector::from_vec(input); + self.model.predict(&v).data + } + + fn accuracy(&self, inputs: Vec>, targets: Vec>) -> f64 { + let inputs: Vec = inputs.into_iter().map(CoreVector::from_vec).collect(); + let targets: Vec = targets.into_iter().map(CoreVector::from_vec).collect(); + if inputs.is_empty() { + return 0.0; + } + let correct = inputs + .iter() + .zip(targets.iter()) + .filter(|(x, t)| self.model.predict(x).argmax() == t.argmax()) + .count(); + correct as f64 / inputs.len() as f64 + } + + fn save_json(&self, path: String) -> PyResult<()> { + self.model + .save_json(&path) + .map_err(|e| pyo3::exceptions::PyIOError::new_err(e.to_string())) + } + + #[staticmethod] + fn load_json(path: String) -> PyResult { + let model = SVM::load_json(&path) + .map_err(|e| pyo3::exceptions::PyIOError::new_err(e.to_string()))?; + Ok(PySVM { model }) + } +} + #[pyclass] struct PyMLP { model: MLP, @@ -348,6 +487,8 @@ fn vision_ai(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(create_model, m)?)?; m.add_function(wrap_pyfunction!(train, m)?)?; m.add_function(wrap_pyfunction!(predict, m)?)?; From 94d400ee0454050fa92d96803669db46bc7d6b7d Mon Sep 17 00:00:00 2001 From: nina Date: Sat, 27 Jun 2026 01:38:05 +0200 Subject: [PATCH 07/14] ajout le lien vers le drive dans le notebook et la generation des npy --- .gitignore | 5 ++ models/mlp_config.json | 2 +- notebooks/analyse_hyperparametres.ipynb | 8 +++ notebooks/comparaison_modeles.ipynb | 8 +++ test_binding_smoke.py | 65 +++++++++++++++++++++++++ train_and_save_rbf.py | 39 +++++++++++++++ train_and_save_svm.py | 36 ++++++++++++++ 7 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 test_binding_smoke.py create mode 100644 train_and_save_rbf.py create mode 100644 train_and_save_svm.py diff --git a/.gitignore b/.gitignore index 6d74973..f266c48 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ build/ .ipynb_checkpoints/ /datasets/ +**/datasets/ + +# Modeles entraines (volumineux, regenerables via train_and_save*.py) +/models/*_weights.json +/models/rbf_compare.json .idea/ .vscode/ diff --git a/models/mlp_config.json b/models/mlp_config.json index efaaac2..13cf0aa 100644 --- a/models/mlp_config.json +++ b/models/mlp_config.json @@ -1 +1 @@ -{"layer_sizes": [12288, 64, 3], "accuracy": 70.17543859649122} \ No newline at end of file +{"layer_sizes": [12288, 64, 3], "accuracy": 68.57142857142857} \ No newline at end of file diff --git a/notebooks/analyse_hyperparametres.ipynb b/notebooks/analyse_hyperparametres.ipynb index 100ab77..867fd16 100644 --- a/notebooks/analyse_hyperparametres.ipynb +++ b/notebooks/analyse_hyperparametres.ipynb @@ -12,6 +12,14 @@ "outputs": [], "source": "import os, sys, subprocess, time\nimport numpy as np\nimport matplotlib.pyplot as plt\n\nIN_COLAB = os.path.exists('/content')\n\nif IN_COLAB:\n BASE_DIR = '/content/VisionAI'\n\n if not os.path.exists(BASE_DIR):\n subprocess.run(['git', 'clone', '-b', 'ML_finition',\n 'https://github.com/SINCER-Ali/VisionAI.git', BASE_DIR], check=True)\n else:\n subprocess.run(['git', '-C', BASE_DIR, 'pull'], capture_output=True)\n\n if subprocess.run(['which', 'cargo'], capture_output=True).returncode != 0:\n subprocess.run(\n 'curl --proto \"=https\" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable',\n shell=True, check=True\n )\n\n cargo_bin = '/root/.cargo/bin'\n os.environ['PATH'] = cargo_bin + ':' + os.environ.get('PATH', '')\n subprocess.run(['pip', 'install', 'maturin', '-q'], check=True)\n\n print('Build du wheel vision_ai...')\n result = subprocess.run(\n ['maturin', 'build', '--release', '-i', sys.executable],\n cwd=f'{BASE_DIR}/python_binding',\n env={**os.environ, 'PATH': cargo_bin + ':' + os.environ.get('PATH', '')},\n capture_output=True, text=True\n )\n if result.returncode != 0:\n print(result.stderr[-3000:])\n raise RuntimeError(f'Build échoué (code {result.returncode})')\n\n wheel_dir = f'{BASE_DIR}/target/wheels'\n wheels = [f for f in os.listdir(wheel_dir) if f.endswith('.whl')]\n subprocess.run(['pip', 'install', os.path.join(wheel_dir, wheels[-1]), '--force-reinstall', '-q'], check=True)\n print('Binding installé ✓')\n\nelse:\n BASE_DIR = os.path.dirname(os.path.abspath('.'))\n\nDATASET_DIR = os.path.join(BASE_DIR, 'datasets')\n\nimport vision_ai\nCLASSES = ['aucun', 'humain', 'animal']\nprint('vision_ai importé ✓')" }, + { + "cell_type": "code", + "id": "1763d911", + "source": "# === Dataset : Google Drive → .npy (à exécuter sur Colab, AVANT la section 1) ===\n# Ton dossier \"IMG_dataset\" est dans \"Partagés avec moi\". Pour que Colab le voie :\n# 1) Drive web → clic droit sur IMG_dataset → \"Ajouter un raccourci à Drive\" → Mon Drive\n# 2) exécute cette cellule (monte ton Drive et lit IMG_dataset)\n\nfrom google.colab import drive\ndrive.mount('/content/drive')\nimport os\nIMAGES_DIR = '/content/drive/MyDrive/IMG_dataset' # 👈 le raccourci ajouté à Mon Drive\n\n# (Alternative gdown — UNIQUEMENT si le dossier est partagé \"Tout le monde avec le lien\" :)\n# import gdown\n# gdown.download_folder('https://drive.google.com/drive/folders/1fWf1dUZK_6BZSpl1CTuGtC8i7c-LAGmo',\n# output=IMAGES_DIR, quiet=False, use_cookies=False)\n\n# Conversion en .npy — labels FIXES : aucun=0, humain=1, animal=2 (comme l'API).\n# Mapping robuste : accepte Aucun / Animaux / humain (Colab est sensible à la casse).\nimport numpy as np\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\n\nLABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\nX, y = [], []\nfor classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\nX = np.array(X, dtype='float32'); y = np.array(y)\nXtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\nos.makedirs(DATASET_DIR, exist_ok=True)\nnp.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\nnp.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\nnp.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\nnp.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\nprint(f'✅ {len(X)} images converties → .npy')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/notebooks/comparaison_modeles.ipynb b/notebooks/comparaison_modeles.ipynb index 9c479c3..58f09a9 100644 --- a/notebooks/comparaison_modeles.ipynb +++ b/notebooks/comparaison_modeles.ipynb @@ -12,6 +12,14 @@ "outputs": [], "source": "import os, sys, subprocess, time\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport matplotlib.patches as mpatches\n\nIN_COLAB = os.path.exists('/content')\n\nif IN_COLAB:\n BASE_DIR = '/content/VisionAI'\n\n if not os.path.exists(BASE_DIR):\n subprocess.run(['git', 'clone', '-b', 'ML_finition',\n 'https://github.com/SINCER-Ali/VisionAI.git', BASE_DIR], check=True)\n else:\n subprocess.run(['git', '-C', BASE_DIR, 'pull'], capture_output=True)\n\n # Installer Rust si absent\n if subprocess.run(['which', 'cargo'], capture_output=True).returncode != 0:\n print('Installation de Rust...')\n subprocess.run(\n 'curl --proto \"=https\" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable',\n shell=True, check=True\n )\n\n cargo_bin = '/root/.cargo/bin'\n os.environ['PATH'] = cargo_bin + ':' + os.environ.get('PATH', '')\n\n subprocess.run(['pip', 'install', 'maturin', '-q'], check=True)\n print(f'cargo : {subprocess.run([\"cargo\",\"--version\"],capture_output=True,text=True).stdout.strip()}')\n print(f'maturin: {subprocess.run([\"maturin\",\"--version\"],capture_output=True,text=True).stdout.strip()}')\n\n # Build wheel puis pip install (fonctionne sans virtualenv)\n print('Build du wheel vision_ai (2-3 min)...')\n build_dir = f'{BASE_DIR}/python_binding'\n result = subprocess.run(\n ['maturin', 'build', '--release', '-i', sys.executable],\n cwd=build_dir,\n env={**os.environ, 'PATH': cargo_bin + ':' + os.environ.get('PATH', '')},\n capture_output=True, text=True\n )\n print(result.stdout[-2000:] if result.stdout else '')\n if result.returncode != 0:\n print('=== ERREUR ===')\n print(result.stderr[-3000:])\n raise RuntimeError(f'Build échoué (code {result.returncode})')\n\n # Installer le wheel généré\n wheel_dir = f'{BASE_DIR}/target/wheels'\n wheels = [f for f in os.listdir(wheel_dir) if f.endswith('.whl')]\n if not wheels:\n raise RuntimeError(f'Aucun wheel trouvé dans {wheel_dir}')\n wheel_path = os.path.join(wheel_dir, wheels[-1])\n print(f'Installation du wheel : {wheels[-1]}')\n subprocess.run(['pip', 'install', wheel_path, '--force-reinstall', '-q'], check=True)\n print('Binding compilé et installé ✓')\n\nelse:\n BASE_DIR = os.path.dirname(os.path.abspath('.'))\n\nDATASET_DIR = os.path.join(BASE_DIR, 'datasets')\nMODELS_DIR = os.path.join(BASE_DIR, 'models')\n\nimport vision_ai\nCLASSES = ['aucun', 'humain', 'animal']\nprint('vision_ai importé ✓')\nprint(f'Classes : {CLASSES}')" }, + { + "cell_type": "code", + "id": "c5e26f26", + "source": "# === Dataset : Google Drive → .npy (à exécuter sur Colab, AVANT la section 1) ===\n# Ton dossier \"IMG_dataset\" est dans \"Partagés avec moi\". Pour que Colab le voie :\n# 1) Drive web → clic droit sur IMG_dataset → \"Ajouter un raccourci à Drive\" → Mon Drive\n# 2) exécute cette cellule (monte ton Drive et lit IMG_dataset)\n\nfrom google.colab import drive\ndrive.mount('/content/drive')\nimport os\nIMAGES_DIR = '/content/drive/MyDrive/IMG_dataset' # 👈 le raccourci ajouté à Mon Drive\n\n# (Alternative gdown — UNIQUEMENT si le dossier est partagé \"Tout le monde avec le lien\" :)\n# import gdown\n# gdown.download_folder('https://drive.google.com/drive/folders/1fWf1dUZK_6BZSpl1CTuGtC8i7c-LAGmo',\n# output=IMAGES_DIR, quiet=False, use_cookies=False)\n\n# Conversion en .npy — labels FIXES : aucun=0, humain=1, animal=2 (comme l'API).\n# Mapping robuste : accepte Aucun / Animaux / humain (Colab est sensible à la casse).\nimport numpy as np\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\n\nLABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\nX, y = [], []\nfor classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\nX = np.array(X, dtype='float32'); y = np.array(y)\nXtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\nos.makedirs(DATASET_DIR, exist_ok=True)\nnp.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\nnp.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\nnp.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\nnp.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\nprint(f'✅ {len(X)} images converties → .npy')", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/test_binding_smoke.py b/test_binding_smoke.py new file mode 100644 index 0000000..3fa2d28 --- /dev/null +++ b/test_binding_smoke.py @@ -0,0 +1,65 @@ +""" +Smoke test du binding vision_ai : teste PyMLP, PyRBF et PySVM sur des +donnees SYNTHETIQUES (pas besoin du vrai dataset), et sauvegarde 3 modeles +dans models/ pour pouvoir tester le serveur + le client de bout en bout. + +Usage : + 1. construire le binding : cd python_binding && maturin develop --release + 2. revenir a la racine : cd .. + 3. lancer : python test_binding_smoke.py +""" +import os +import numpy as np +import vision_ai + +np.random.seed(0) +DIM = 12288 # 64 x 64 x 3, comme une vraie image +N_PER_CLASS = 30 +CLASSES = ['aucun', 'humain', 'animal'] + +# --- Donnees synthetiques apprenables : chaque classe "allume" un tiers des pixels --- +X, y = [], [] +for cls in range(3): + for _ in range(N_PER_CLASS): + v = np.random.rand(DIM) * 0.1 # bruit faible + start = cls * (DIM // 3) + v[start:start + DIM // 3] += 0.8 # motif distinctif + X.append(v.tolist()) + y.append(cls) + +targets = [[1.0 if i == cls else 0.0 for i in range(3)] for cls in y] + +def accuracy(predict_fn): + preds = [predict_fn(x) for x in X] + y_pred = [p.index(max(p)) for p in preds] + return sum(p == t for p, t in zip(y_pred, y)) / len(y) * 100 + +os.makedirs('models', exist_ok=True) + +# --- MLP --- +print('--- MLP ---') +mlp = vision_ai.PyMLP([DIM, 64, 3]) +mlp.train(X, targets, 0.01, 30) +print(f' accuracy (synthetique) : {accuracy(mlp.predict):.0f}%') +mlp.save_json('models/mlp_weights.json') +print(' -> models/mlp_weights.json') + +# --- RBF (sigma grand car haute dimension) --- +print('--- RBF ---') +rbf = vision_ai.PyRBF(DIM, 3, n_centers=20, sigma=20.0) +rbf.init_centers_random(X) # no-op (init faite dans train), gardé pour compat +rbf.train(X, targets, 0.01, 50, False) +print(f' accuracy (synthetique) : {accuracy(rbf.predict):.0f}%') +rbf.save_json('models/rbf_weights.json') +print(' -> models/rbf_weights.json') + +# --- SVM (lineaire) --- +print('--- SVM ---') +svm = vision_ai.PySVM(c=1.0, kernel='linear') +svm.train(X, targets, 0.01, 50) +print(f' accuracy (synthetique) : {accuracy(svm.predict):.0f}%') +svm.save_json('models/svm_weights.json') +print(' -> models/svm_weights.json') + +print('\nOK ! Les 3 modeles sont sauvegardes dans models/.') +print('Lance maintenant le serveur (cargo run -p api_server) puis ouvre client/index.html.') diff --git a/train_and_save_rbf.py b/train_and_save_rbf.py new file mode 100644 index 0000000..efeb14d --- /dev/null +++ b/train_and_save_rbf.py @@ -0,0 +1,39 @@ +import numpy as np +import vision_ai +import os + +# Entraine un RBF sur le vrai dataset et sauvegarde les poids pour l'API. +# IMPORTANT : on entraine sur la dimension PLEINE (12288) pour rester +# compatible avec les images envoyees par l'API (64x64x3 = 12288). + +X_train = np.load('datasets/X_train.npy') +y_train = np.load('datasets/y_train.npy') + +inputs = [X_train[i].tolist() for i in range(len(X_train))] +targets = [] +for label in y_train: + t = [0.0, 0.0, 0.0] + t[int(label)] = 1.0 + targets.append(t) + +print('Entrainement RBF...') +# sigma grand car haute dimension (gamma = 1/(2*sigma^2) reste petit) +model = vision_ai.PyRBF(X_train.shape[1], 3, n_centers=30, sigma=20.0) +model.init_centers_random(inputs) +model.train(inputs, targets, 0.01, 50, False) +print('Termine !') + +X_test = np.load('datasets/X_test.npy') +y_test = np.load('datasets/y_test.npy') + +inputs_test = [X_test[i].tolist() for i in range(len(X_test))] +predictions = [model.predict(x) for x in inputs_test] +y_pred = [pred.index(max(pred)) for pred in predictions] +correct = sum(1 for p, t in zip(y_pred, y_test) if p == int(t)) +accuracy = correct / len(y_test) * 100 +print(f'Accuracy : {accuracy:.1f}%') + +os.makedirs('models', exist_ok=True) + +model.save_json('models/rbf_weights.json') +print('Poids sauvegardes dans models/rbf_weights.json !') diff --git a/train_and_save_svm.py b/train_and_save_svm.py new file mode 100644 index 0000000..ce0bfb6 --- /dev/null +++ b/train_and_save_svm.py @@ -0,0 +1,36 @@ +import numpy as np +import vision_ai +import os + +# Entraine un SVM (lineaire, one-vs-rest) et sauvegarde les poids +# pour que l'API puisse le servir via models/svm_weights.json + +X_train = np.load('datasets/X_train.npy') +y_train = np.load('datasets/y_train.npy') + +inputs = [X_train[i].tolist() for i in range(len(X_train))] +targets = [] +for label in y_train: + t = [0.0, 0.0, 0.0] + t[int(label)] = 1.0 + targets.append(t) + +print('Entrainement SVM (lineaire, one-vs-rest)...') +model = vision_ai.PySVM(c=1.0, kernel='linear') +model.train(inputs, targets, 0.001, 50) +print('Termine !') + +X_test = np.load('datasets/X_test.npy') +y_test = np.load('datasets/y_test.npy') + +inputs_test = [X_test[i].tolist() for i in range(len(X_test))] +predictions = [model.predict(x) for x in inputs_test] +y_pred = [pred.index(max(pred)) for pred in predictions] +correct = sum(1 for p, t in zip(y_pred, y_test) if p == int(t)) +accuracy = correct / len(y_test) * 100 +print(f'Accuracy : {accuracy:.1f}%') + +os.makedirs('models', exist_ok=True) + +model.save_json('models/svm_weights.json') +print('Poids sauvegardes dans models/svm_weights.json !') From 3426ab51a917f919ee47d230fce1ee1b9d15c975 Mon Sep 17 00:00:00 2001 From: nina Date: Sat, 27 Jun 2026 02:13:56 +0200 Subject: [PATCH 08/14] debug le notebook --- notebooks/analyse_hyperparametres.ipynb | 2 +- notebooks/comparaison_modeles.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/notebooks/analyse_hyperparametres.ipynb b/notebooks/analyse_hyperparametres.ipynb index 867fd16..977d304 100644 --- a/notebooks/analyse_hyperparametres.ipynb +++ b/notebooks/analyse_hyperparametres.ipynb @@ -15,7 +15,7 @@ { "cell_type": "code", "id": "1763d911", - "source": "# === Dataset : Google Drive → .npy (à exécuter sur Colab, AVANT la section 1) ===\n# Ton dossier \"IMG_dataset\" est dans \"Partagés avec moi\". Pour que Colab le voie :\n# 1) Drive web → clic droit sur IMG_dataset → \"Ajouter un raccourci à Drive\" → Mon Drive\n# 2) exécute cette cellule (monte ton Drive et lit IMG_dataset)\n\nfrom google.colab import drive\ndrive.mount('/content/drive')\nimport os\nIMAGES_DIR = '/content/drive/MyDrive/IMG_dataset' # 👈 le raccourci ajouté à Mon Drive\n\n# (Alternative gdown — UNIQUEMENT si le dossier est partagé \"Tout le monde avec le lien\" :)\n# import gdown\n# gdown.download_folder('https://drive.google.com/drive/folders/1fWf1dUZK_6BZSpl1CTuGtC8i7c-LAGmo',\n# output=IMAGES_DIR, quiet=False, use_cookies=False)\n\n# Conversion en .npy — labels FIXES : aucun=0, humain=1, animal=2 (comme l'API).\n# Mapping robuste : accepte Aucun / Animaux / humain (Colab est sensible à la casse).\nimport numpy as np\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\n\nLABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\nX, y = [], []\nfor classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\nX = np.array(X, dtype='float32'); y = np.array(y)\nXtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\nos.makedirs(DATASET_DIR, exist_ok=True)\nnp.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\nnp.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\nnp.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\nnp.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\nprint(f'✅ {len(X)} images converties → .npy')", + "source": "# === Dataset → .npy ===\n# - En LOCAL : on utilise les .npy déjà générés par preprocess_dataset.py (rien à télécharger).\n# - Sur COLAB : on monte le Drive et on convertit les images de IMG_dataset en .npy.\nimport os\n_need = not os.path.exists(os.path.join(DATASET_DIR, 'X_train.npy'))\n\nif _need and IN_COLAB:\n from google.colab import drive\n drive.mount('/content/drive')\n # ⚠️ \"IMG_dataset\" est dans \"Partagés avec moi\". Colab ne voit que \"Mon Drive\" →\n # ajoute d'abord un RACCOURCI : Drive web → clic droit sur IMG_dataset →\n # Organiser → Ajouter un raccourci à Drive → Mon Drive.\n IMAGES_DIR = '/content/drive/MyDrive/IMG_dataset'\n if not os.path.isdir(IMAGES_DIR):\n raise FileNotFoundError(\n f\"{IMAGES_DIR} introuvable. Ajoute un raccourci de 'IMG_dataset' vers 'Mon Drive' \"\n \"(Drive web → clic droit sur le dossier → Organiser → Ajouter un raccourci à Drive).\"\n )\n\n import numpy as np\n from PIL import Image\n from sklearn.model_selection import train_test_split\n\n LABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\n X, y = [], []\n for classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\n X = np.array(X, dtype='float32'); y = np.array(y)\n Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\n os.makedirs(DATASET_DIR, exist_ok=True)\n np.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\n np.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\n np.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\n np.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\n print(f'✅ {len(X)} images converties → .npy')\n\nelif _need:\n raise FileNotFoundError(\n \"Aucun .npy trouvé en local. Lance d'abord : python preprocess_dataset.py\"\n )\nelse:\n print('✅ Dataset .npy déjà présent — rien à régénérer.')", "metadata": {}, "execution_count": null, "outputs": [] diff --git a/notebooks/comparaison_modeles.ipynb b/notebooks/comparaison_modeles.ipynb index 58f09a9..8eb23f4 100644 --- a/notebooks/comparaison_modeles.ipynb +++ b/notebooks/comparaison_modeles.ipynb @@ -15,7 +15,7 @@ { "cell_type": "code", "id": "c5e26f26", - "source": "# === Dataset : Google Drive → .npy (à exécuter sur Colab, AVANT la section 1) ===\n# Ton dossier \"IMG_dataset\" est dans \"Partagés avec moi\". Pour que Colab le voie :\n# 1) Drive web → clic droit sur IMG_dataset → \"Ajouter un raccourci à Drive\" → Mon Drive\n# 2) exécute cette cellule (monte ton Drive et lit IMG_dataset)\n\nfrom google.colab import drive\ndrive.mount('/content/drive')\nimport os\nIMAGES_DIR = '/content/drive/MyDrive/IMG_dataset' # 👈 le raccourci ajouté à Mon Drive\n\n# (Alternative gdown — UNIQUEMENT si le dossier est partagé \"Tout le monde avec le lien\" :)\n# import gdown\n# gdown.download_folder('https://drive.google.com/drive/folders/1fWf1dUZK_6BZSpl1CTuGtC8i7c-LAGmo',\n# output=IMAGES_DIR, quiet=False, use_cookies=False)\n\n# Conversion en .npy — labels FIXES : aucun=0, humain=1, animal=2 (comme l'API).\n# Mapping robuste : accepte Aucun / Animaux / humain (Colab est sensible à la casse).\nimport numpy as np\nfrom PIL import Image\nfrom sklearn.model_selection import train_test_split\n\nLABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\nX, y = [], []\nfor classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\nX = np.array(X, dtype='float32'); y = np.array(y)\nXtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\nos.makedirs(DATASET_DIR, exist_ok=True)\nnp.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\nnp.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\nnp.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\nnp.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\nprint(f'✅ {len(X)} images converties → .npy')", + "source": "# === Dataset → .npy ===\n# - En LOCAL : on utilise les .npy déjà générés par preprocess_dataset.py (rien à télécharger).\n# - Sur COLAB : on monte le Drive et on convertit les images de IMG_dataset en .npy.\nimport os\n_need = not os.path.exists(os.path.join(DATASET_DIR, 'X_train.npy'))\n\nif _need and IN_COLAB:\n from google.colab import drive\n drive.mount('/content/drive')\n # ⚠️ \"IMG_dataset\" est dans \"Partagés avec moi\". Colab ne voit que \"Mon Drive\" →\n # ajoute d'abord un RACCOURCI : Drive web → clic droit sur IMG_dataset →\n # Organiser → Ajouter un raccourci à Drive → Mon Drive.\n IMAGES_DIR = '/content/drive/MyDrive/IMG_dataset'\n if not os.path.isdir(IMAGES_DIR):\n raise FileNotFoundError(\n f\"{IMAGES_DIR} introuvable. Ajoute un raccourci de 'IMG_dataset' vers 'Mon Drive' \"\n \"(Drive web → clic droit sur le dossier → Organiser → Ajouter un raccourci à Drive).\"\n )\n\n import numpy as np\n from PIL import Image\n from sklearn.model_selection import train_test_split\n\n LABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\n X, y = [], []\n for classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\n X = np.array(X, dtype='float32'); y = np.array(y)\n Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\n os.makedirs(DATASET_DIR, exist_ok=True)\n np.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\n np.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\n np.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\n np.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\n print(f'✅ {len(X)} images converties → .npy')\n\nelif _need:\n raise FileNotFoundError(\n \"Aucun .npy trouvé en local. Lance d'abord : python preprocess_dataset.py\"\n )\nelse:\n print('✅ Dataset .npy déjà présent — rien à régénérer.')", "metadata": {}, "execution_count": null, "outputs": [] From b5ec4efb5871df06098fdc2f02c6cf5f25e934fc Mon Sep 17 00:00:00 2001 From: nina Date: Sat, 27 Jun 2026 03:35:17 +0200 Subject: [PATCH 09/14] debug le notebook ok --- notebooks/analyse_hyperparametres.ipynb | 2 +- notebooks/comparaison_modeles.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/notebooks/analyse_hyperparametres.ipynb b/notebooks/analyse_hyperparametres.ipynb index 977d304..c1cb8d9 100644 --- a/notebooks/analyse_hyperparametres.ipynb +++ b/notebooks/analyse_hyperparametres.ipynb @@ -15,7 +15,7 @@ { "cell_type": "code", "id": "1763d911", - "source": "# === Dataset → .npy ===\n# - En LOCAL : on utilise les .npy déjà générés par preprocess_dataset.py (rien à télécharger).\n# - Sur COLAB : on monte le Drive et on convertit les images de IMG_dataset en .npy.\nimport os\n_need = not os.path.exists(os.path.join(DATASET_DIR, 'X_train.npy'))\n\nif _need and IN_COLAB:\n from google.colab import drive\n drive.mount('/content/drive')\n # ⚠️ \"IMG_dataset\" est dans \"Partagés avec moi\". Colab ne voit que \"Mon Drive\" →\n # ajoute d'abord un RACCOURCI : Drive web → clic droit sur IMG_dataset →\n # Organiser → Ajouter un raccourci à Drive → Mon Drive.\n IMAGES_DIR = '/content/drive/MyDrive/IMG_dataset'\n if not os.path.isdir(IMAGES_DIR):\n raise FileNotFoundError(\n f\"{IMAGES_DIR} introuvable. Ajoute un raccourci de 'IMG_dataset' vers 'Mon Drive' \"\n \"(Drive web → clic droit sur le dossier → Organiser → Ajouter un raccourci à Drive).\"\n )\n\n import numpy as np\n from PIL import Image\n from sklearn.model_selection import train_test_split\n\n LABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\n X, y = [], []\n for classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\n X = np.array(X, dtype='float32'); y = np.array(y)\n Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\n os.makedirs(DATASET_DIR, exist_ok=True)\n np.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\n np.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\n np.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\n np.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\n print(f'✅ {len(X)} images converties → .npy')\n\nelif _need:\n raise FileNotFoundError(\n \"Aucun .npy trouvé en local. Lance d'abord : python preprocess_dataset.py\"\n )\nelse:\n print('✅ Dataset .npy déjà présent — rien à régénérer.')", + "source": "# === Dataset → .npy ===\n# - En LOCAL : on utilise les .npy déjà générés par preprocess_dataset.py (rien à télécharger).\n# - Sur COLAB : on monte le Drive et on convertit les images de IMG_dataset en .npy.\nimport os\n_need = not os.path.exists(os.path.join(DATASET_DIR, 'X_train.npy'))\n\nif _need and IN_COLAB:\n from google.colab import drive\n drive.mount('/content/drive')\n\n # \"IMG_dataset\" est dans \"Partagés avec moi\" → il faut un RACCOURCI vers Mon Drive\n # (Drive web → clic droit sur IMG_dataset → Organiser → Ajouter un raccourci à Drive).\n # On essaie les emplacements probables du raccourci :\n _candidats = [\n '/content/drive/MyDrive/new_dataset/IMG_dataset',\n '/content/drive/MyDrive/IMG_dataset',\n ]\n IMAGES_DIR = next((p for p in _candidats if os.path.isdir(p)), None)\n if IMAGES_DIR is None:\n raise FileNotFoundError(\n \"IMG_dataset introuvable dans Mon Drive. Vérifie le chemin exact dans le panneau \"\n \"Fichiers de Colab et ajoute-le à _candidats. (As-tu bien ajouté le raccourci ?)\"\n )\n print(f'Dossier images : {IMAGES_DIR}')\n\n import numpy as np\n from PIL import Image\n from sklearn.model_selection import train_test_split\n\n LABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\n X, y = [], []\n for classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\n X = np.array(X, dtype='float32'); y = np.array(y)\n Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\n os.makedirs(DATASET_DIR, exist_ok=True)\n np.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\n np.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\n np.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\n np.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\n print(f'✅ {len(X)} images converties → .npy')\n\nelif _need:\n raise FileNotFoundError(\n \"Aucun .npy trouvé en local. Lance d'abord : python preprocess_dataset.py\"\n )\nelse:\n print('✅ Dataset .npy déjà présent — rien à régénérer.')", "metadata": {}, "execution_count": null, "outputs": [] diff --git a/notebooks/comparaison_modeles.ipynb b/notebooks/comparaison_modeles.ipynb index 8eb23f4..c454278 100644 --- a/notebooks/comparaison_modeles.ipynb +++ b/notebooks/comparaison_modeles.ipynb @@ -15,7 +15,7 @@ { "cell_type": "code", "id": "c5e26f26", - "source": "# === Dataset → .npy ===\n# - En LOCAL : on utilise les .npy déjà générés par preprocess_dataset.py (rien à télécharger).\n# - Sur COLAB : on monte le Drive et on convertit les images de IMG_dataset en .npy.\nimport os\n_need = not os.path.exists(os.path.join(DATASET_DIR, 'X_train.npy'))\n\nif _need and IN_COLAB:\n from google.colab import drive\n drive.mount('/content/drive')\n # ⚠️ \"IMG_dataset\" est dans \"Partagés avec moi\". Colab ne voit que \"Mon Drive\" →\n # ajoute d'abord un RACCOURCI : Drive web → clic droit sur IMG_dataset →\n # Organiser → Ajouter un raccourci à Drive → Mon Drive.\n IMAGES_DIR = '/content/drive/MyDrive/IMG_dataset'\n if not os.path.isdir(IMAGES_DIR):\n raise FileNotFoundError(\n f\"{IMAGES_DIR} introuvable. Ajoute un raccourci de 'IMG_dataset' vers 'Mon Drive' \"\n \"(Drive web → clic droit sur le dossier → Organiser → Ajouter un raccourci à Drive).\"\n )\n\n import numpy as np\n from PIL import Image\n from sklearn.model_selection import train_test_split\n\n LABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\n X, y = [], []\n for classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\n X = np.array(X, dtype='float32'); y = np.array(y)\n Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\n os.makedirs(DATASET_DIR, exist_ok=True)\n np.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\n np.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\n np.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\n np.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\n print(f'✅ {len(X)} images converties → .npy')\n\nelif _need:\n raise FileNotFoundError(\n \"Aucun .npy trouvé en local. Lance d'abord : python preprocess_dataset.py\"\n )\nelse:\n print('✅ Dataset .npy déjà présent — rien à régénérer.')", + "source": "# === Dataset → .npy ===\n# - En LOCAL : on utilise les .npy déjà générés par preprocess_dataset.py (rien à télécharger).\n# - Sur COLAB : on monte le Drive et on convertit les images de IMG_dataset en .npy.\nimport os\n_need = not os.path.exists(os.path.join(DATASET_DIR, 'X_train.npy'))\n\nif _need and IN_COLAB:\n from google.colab import drive\n drive.mount('/content/drive')\n\n # \"IMG_dataset\" est dans \"Partagés avec moi\" → il faut un RACCOURCI vers Mon Drive\n # (Drive web → clic droit sur IMG_dataset → Organiser → Ajouter un raccourci à Drive).\n # On essaie les emplacements probables du raccourci :\n _candidats = [\n '/content/drive/MyDrive/new_dataset/IMG_dataset',\n '/content/drive/MyDrive/IMG_dataset',\n ]\n IMAGES_DIR = next((p for p in _candidats if os.path.isdir(p)), None)\n if IMAGES_DIR is None:\n raise FileNotFoundError(\n \"IMG_dataset introuvable dans Mon Drive. Vérifie le chemin exact dans le panneau \"\n \"Fichiers de Colab et ajoute-le à _candidats. (As-tu bien ajouté le raccourci ?)\"\n )\n print(f'Dossier images : {IMAGES_DIR}')\n\n import numpy as np\n from PIL import Image\n from sklearn.model_selection import train_test_split\n\n LABEL_OF = {'aucun': 0, 'humain': 1, 'animal': 2, 'animaux': 2} # nom_normalisé -> label\n X, y = [], []\n for classe in sorted(os.listdir(IMAGES_DIR)):\n dossier = os.path.join(IMAGES_DIR, classe)\n if not os.path.isdir(dossier):\n continue\n key = classe.strip().lower()\n if key not in LABEL_OF:\n print(f' ⚠️ dossier ignoré (nom inconnu) : {classe}')\n continue\n label = LABEL_OF[key]\n fichiers = os.listdir(dossier)\n print(f' {classe} -> label {label} : {len(fichiers)} images')\n for f in fichiers:\n try:\n img = Image.open(os.path.join(dossier, f)).convert('RGB').resize((64, 64))\n X.append(np.array(img, dtype='float32').flatten() / 255.0)\n y.append(label)\n except Exception:\n pass\n\n X = np.array(X, dtype='float32'); y = np.array(y)\n Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)\n os.makedirs(DATASET_DIR, exist_ok=True)\n np.save(os.path.join(DATASET_DIR, 'X_train.npy'), Xtr)\n np.save(os.path.join(DATASET_DIR, 'X_test.npy'), Xte)\n np.save(os.path.join(DATASET_DIR, 'y_train.npy'), ytr)\n np.save(os.path.join(DATASET_DIR, 'y_test.npy'), yte)\n print(f'✅ {len(X)} images converties → .npy')\n\nelif _need:\n raise FileNotFoundError(\n \"Aucun .npy trouvé en local. Lance d'abord : python preprocess_dataset.py\"\n )\nelse:\n print('✅ Dataset .npy déjà présent — rien à régénérer.')", "metadata": {}, "execution_count": null, "outputs": [] From 9dd84c86b47e74012472600b58dcf5052e721ff7 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Sun, 28 Jun 2026 20:28:50 +0200 Subject: [PATCH 10/14] suppression imports inutilises clippy --- core_lib/tests/integration_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core_lib/tests/integration_tests.rs b/core_lib/tests/integration_tests.rs index 6ce350e..dfd8957 100644 --- a/core_lib/tests/integration_tests.rs +++ b/core_lib/tests/integration_tests.rs @@ -6,10 +6,9 @@ use core_lib::models::linear::LinearModel; use core_lib::models::mlp::MLP; use core_lib::models::rbf::RBF; use core_lib::models::svm::{KernelType, SVM}; -use core_lib::models::{Model, TrainConfig}; +use core_lib::models::TrainConfig; use core_lib::optim::adam::Adam; use core_lib::optim::gradient_descent::GradientDescentConfig; -use core_lib::optim::optimizer::Optimizer; use core_lib::optim::sgd_momentum::SGDMomentum; // ─── Vector tests ─────────────────────────────────────────────── From 506c334d89c38bdd7cfdd58da92236380519a884 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Sun, 28 Jun 2026 20:39:28 +0200 Subject: [PATCH 11/14] correction des warnings clippy dans rbf --- api_server/src/main.rs | 39 ++-- core_lib/src/metrics/mod.rs | 72 +++++--- core_lib/src/models/mlp.rs | 18 +- core_lib/src/models/rbf.rs | 114 ++++++++---- core_lib/src/models/svm.rs | 142 ++++++++++----- core_lib/src/optim/adam.rs | 6 +- core_lib/src/optim/mod.rs | 2 +- core_lib/src/optim/sgd_momentum.rs | 13 +- core_lib/tests/integration_tests.rs | 267 +++++++++++++++++++++++----- python_binding/src/lib.rs | 6 +- 10 files changed, 505 insertions(+), 174 deletions(-) diff --git a/api_server/src/main.rs b/api_server/src/main.rs index 26dcee9..4b16913 100644 --- a/api_server/src/main.rs +++ b/api_server/src/main.rs @@ -413,20 +413,20 @@ async fn predict( } let models = state - .models - .read() - .map_err(|_| ApiError::Internal("lock poisoned".into()))?; - let model = models - .get(&model_name) - .ok_or_else(|| ApiError::NotFound(format!("model '{model_name}' not found")))?; - let (class, confidence) = run_prediction(model, &input.data); - - Ok(Json(PredictResponse { - model_name, - predicted_class: class, - confidence, - })) - } + .models + .read() + .map_err(|_| ApiError::Internal("lock poisoned".into()))?; + let model = models + .get(&model_name) + .ok_or_else(|| ApiError::NotFound(format!("model '{model_name}' not found")))?; + let (class, confidence) = run_prediction(model, &input.data); + + Ok(Json(PredictResponse { + model_name, + predicted_class: class, + confidence, + })) +} async fn train( State(state): State, @@ -479,10 +479,7 @@ async fn train( metadata: ModelMetadata { name: req.model_name.clone(), version: "1.0.0".into(), - description: Some(format!( - "OvR linear — classes: {}", - class_names.join(", ") - )), + description: Some(format!("OvR linear — classes: {}", class_names.join(", "))), }, }; @@ -599,7 +596,11 @@ fn run_prediction(model: &StoredModel, input: &[f64]) -> (String, f64) { let max_score = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let exps: Vec = scores.iter().map(|s| (s - max_score).exp()).collect(); let sum_exp: f64 = exps.iter().sum(); - let confidence = if sum_exp > 0.0 { exps[best_idx] / sum_exp } else { 1.0 / 3.0 }; + let confidence = if sum_exp > 0.0 { + exps[best_idx] / sum_exp + } else { + 1.0 / 3.0 + }; (classes[best_idx].to_string(), confidence) } diff --git a/core_lib/src/metrics/mod.rs b/core_lib/src/metrics/mod.rs index ea57109..eb5b317 100644 --- a/core_lib/src/metrics/mod.rs +++ b/core_lib/src/metrics/mod.rs @@ -1,12 +1,20 @@ // % de bonnes predictions pub fn accuracy(predictions: &[usize], targets: &[usize]) -> f64 { assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); - let correct = predictions.iter().zip(targets.iter()).filter(|(p, t)| p == t).count(); + let correct = predictions + .iter() + .zip(targets.iter()) + .filter(|(p, t)| p == t) + .count(); correct as f64 / predictions.len() as f64 } // matrice de confusion : ligne = vrai label, colonne = label predit -pub fn confusion_matrix(predictions: &[usize], targets: &[usize], num_classes: usize) -> Vec> { +pub fn confusion_matrix( + predictions: &[usize], + targets: &[usize], + num_classes: usize, +) -> Vec> { let mut matrix = vec![vec![0usize; num_classes]; num_classes]; for (&pred, &target) in predictions.iter().zip(targets.iter()) { matrix[target][pred] += 1; @@ -17,16 +25,26 @@ pub fn confusion_matrix(predictions: &[usize], targets: &[usize], num_classes: u // precision pour une classe : tp / (tp + fp) pub fn precision(matrix: &[Vec], class: usize) -> f64 { let tp = matrix[class][class]; - let fp: usize = (0..matrix.len()).filter(|&i| i != class).map(|i| matrix[i][class]).sum(); - if tp + fp == 0 { return 0.0; } + let fp: usize = (0..matrix.len()) + .filter(|&i| i != class) + .map(|i| matrix[i][class]) + .sum(); + if tp + fp == 0 { + return 0.0; + } tp as f64 / (tp + fp) as f64 } // recall pour une classe : tp / (tp + fn) pub fn recall(matrix: &[Vec], class: usize) -> f64 { let tp = matrix[class][class]; - let fn_: usize = (0..matrix.len()).filter(|&j| j != class).map(|j| matrix[class][j]).sum(); - if tp + fn_ == 0 { return 0.0; } + let fn_: usize = (0..matrix.len()) + .filter(|&j| j != class) + .map(|j| matrix[class][j]) + .sum(); + if tp + fn_ == 0 { + return 0.0; + } tp as f64 / (tp + fn_) as f64 } @@ -34,7 +52,9 @@ pub fn recall(matrix: &[Vec], class: usize) -> f64 { pub fn f1_score(matrix: &[Vec], class: usize) -> f64 { let p = precision(matrix, class); let r = recall(matrix, class); - if p + r == 0.0 { return 0.0; } + if p + r == 0.0 { + return 0.0; + } 2.0 * p * r / (p + r) } @@ -49,9 +69,12 @@ pub fn mse(predictions: &[f64], targets: &[f64]) -> f64 { assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); let n = predictions.len(); assert!(n > 0, "Sequences vides"); - predictions.iter().zip(targets.iter()) + predictions + .iter() + .zip(targets.iter()) .map(|(p, t)| (p - t).powi(2)) - .sum::() / n as f64 + .sum::() + / n as f64 } // MAE = (1/n) * sum(|pred - target|) @@ -59,9 +82,12 @@ pub fn mae(predictions: &[f64], targets: &[f64]) -> f64 { assert_eq!(predictions.len(), targets.len(), "Dimensions mismatch"); let n = predictions.len(); assert!(n > 0, "Sequences vides"); - predictions.iter().zip(targets.iter()) + predictions + .iter() + .zip(targets.iter()) .map(|(p, t)| (p - t).abs()) - .sum::() / n as f64 + .sum::() + / n as f64 } // R^2 = 1 - SS_res / SS_tot @@ -71,22 +97,28 @@ pub fn r_squared(predictions: &[f64], targets: &[f64]) -> f64 { let n = targets.len(); assert!(n > 0, "Sequences vides"); let mean_t: f64 = targets.iter().sum::() / n as f64; - let ss_res: f64 = predictions.iter().zip(targets.iter()) + let ss_res: f64 = predictions + .iter() + .zip(targets.iter()) .map(|(p, t)| (p - t).powi(2)) .sum(); let ss_tot: f64 = targets.iter().map(|t| (t - mean_t).powi(2)).sum(); - if ss_tot < 1e-14 { return 1.0; } + if ss_tot < 1e-14 { + return 1.0; + } 1.0 - ss_res / ss_tot } // decoupe le dataset en k parties pour la cross validation pub fn kfold_indices(n_samples: usize, k: usize) -> Vec<(Vec, Vec)> { let fold_size = n_samples / k; - (0..k).map(|i| { - let test: Vec = (i * fold_size..(i + 1) * fold_size).collect(); - let train: Vec = (0..n_samples).filter(|x| !test.contains(x)).collect(); - (train, test) - }).collect() + (0..k) + .map(|i| { + let test: Vec = (i * fold_size..(i + 1) * fold_size).collect(); + let train: Vec = (0..n_samples).filter(|x| !test.contains(x)).collect(); + (train, test) + }) + .collect() } #[cfg(test)] @@ -109,7 +141,7 @@ mod tests { #[test] fn test_confusion_matrix() { - let preds = vec![0, 1, 2, 0]; + let preds = vec![0, 1, 2, 0]; let targets = vec![0, 1, 1, 2]; let m = confusion_matrix(&preds, &targets, 3); assert_eq!(m[0][0], 1); @@ -118,7 +150,7 @@ mod tests { #[test] fn test_f1_perfect() { - let preds = vec![0, 1, 2]; + let preds = vec![0, 1, 2]; let targets = vec![0, 1, 2]; let m = confusion_matrix(&preds, &targets, 3); assert_eq!(f1_score(&m, 0), 1.0); diff --git a/core_lib/src/models/mlp.rs b/core_lib/src/models/mlp.rs index d32580a..fa4e6aa 100644 --- a/core_lib/src/models/mlp.rs +++ b/core_lib/src/models/mlp.rs @@ -156,7 +156,11 @@ impl MLP { for (i, layer) in self.layers.iter().enumerate() { let z = layer.forward(activations.last().unwrap()); zs.push(z.clone()); - let a = if i == self.layers.len() - 1 { softmax(&z) } else { self.hidden_activation.apply(&z) }; + let a = if i == self.layers.len() - 1 { + softmax(&z) + } else { + self.hidden_activation.apply(&z) + }; activations.push(a); } let output = activations.last().unwrap(); @@ -169,16 +173,22 @@ impl MLP { for j in 0..in_size { let idx = base + i * (in_size + 1) + j; let grad = delta.data[i] * a_prev.data[j]; - self.layers[l].weights[i].data[j] = optimizer.update(idx, self.layers[l].weights[i].data[j], grad); + self.layers[l].weights[i].data[j] = + optimizer.update(idx, self.layers[l].weights[i].data[j], grad); } let bias_idx = base + i * (in_size + 1) + in_size; - self.layers[l].biases.data[i] = optimizer.update(bias_idx, self.layers[l].biases.data[i], delta.data[i]); + self.layers[l].biases.data[i] = optimizer.update( + bias_idx, + self.layers[l].biases.data[i], + delta.data[i], + ); } if l > 0 { let mut new_delta = Vector::new(in_size); for j in 0..in_size { for i in 0..self.layers[l].output_size { - new_delta.data[j] += self.layers[l].weights[i].data[j] * delta.data[i]; + new_delta.data[j] += + self.layers[l].weights[i].data[j] * delta.data[i]; } } let rd = self.hidden_activation.derivative(&zs[l - 1]); diff --git a/core_lib/src/models/rbf.rs b/core_lib/src/models/rbf.rs index 36b2e89..50a9da7 100644 --- a/core_lib/src/models/rbf.rs +++ b/core_lib/src/models/rbf.rs @@ -46,7 +46,9 @@ impl RBF { // Activations de la couche cachee pour une entree x pub fn compute_activations(&self, x: &Vector) -> Vector { - let data: Vec = self.centers.iter() + let data: Vec = self + .centers + .iter() .map(|c| Self::rbf_kernel(x, c, self.gamma)) .collect(); Vector::from_vec(data) @@ -62,7 +64,10 @@ impl RBF { let j = rng.gen_range(0..=i); indices.swap(i, j); } - indices[..k].iter().map(|&idx| inputs[idx].clone()).collect() + indices[..k] + .iter() + .map(|&idx| inputs[idx].clone()) + .collect() } // Resout W = (Phi^T Phi + lambda I)^-1 * Phi^T Y via Gauss-Jordan avec pivot partiel @@ -73,27 +78,25 @@ impl RBF { n_outputs: usize, lambda: f64, ) -> (Vec, Vector) { - let n = phi.len(); - // Phi^T Phi let mut ptp = vec![vec![0.0f64; n_centers]; n_centers]; - for k in 0..n { + for phi_row in phi { for i in 0..n_centers { for j in 0..n_centers { - ptp[i][j] += phi[k][i] * phi[k][j]; + ptp[i][j] += phi_row[i] * phi_row[j]; } } } - for i in 0..n_centers { - ptp[i][i] += lambda; + for (i, row) in ptp.iter_mut().enumerate() { + row[i] += lambda; } // Phi^T Y let mut pty = vec![vec![0.0f64; n_outputs]; n_centers]; - for k in 0..n { - for i in 0..n_centers { - for j in 0..n_outputs { - pty[i][j] += phi[k][i] * targets[k].data[j]; + for (phi_row, target) in phi.iter().zip(targets.iter()) { + for (phi_val, pty_row) in phi_row.iter().zip(pty.iter_mut()) { + for (pty_val, td) in pty_row.iter_mut().zip(target.data.iter()) { + *pty_val += phi_val * td; } } } @@ -103,8 +106,8 @@ impl RBF { let mut aug: Vec> = (0..n_centers) .map(|i| { let mut row = vec![0.0f64; w]; - for j in 0..n_centers { row[j] = ptp[i][j]; } - for j in 0..n_outputs { row[n_centers + j] = pty[i][j]; } + row[..n_centers].copy_from_slice(&ptp[i]); + row[n_centers..].copy_from_slice(&pty[i]); row }) .collect(); @@ -112,24 +115,32 @@ impl RBF { // Elimination de Gauss-Jordan for col in 0..n_centers { let (mut max_row, mut max_val) = (col, aug[col][col].abs()); - for row in (col + 1)..n_centers { - if aug[row][col].abs() > max_val { - max_val = aug[row][col].abs(); - max_row = row; + for (row_idx, aug_row) in aug.iter().enumerate().skip(col + 1) { + if aug_row[col].abs() > max_val { + max_val = aug_row[col].abs(); + max_row = row_idx; } } aug.swap(col, max_row); let pivot = aug[col][col]; - if pivot.abs() < 1e-14 { continue; } + if pivot.abs() < 1e-14 { + continue; + } let inv = 1.0 / pivot; - for j in 0..w { aug[col][j] *= inv; } - for row in 0..n_centers { - if row == col { continue; } - let f = aug[row][col]; - if f.abs() < 1e-14 { continue; } - for j in 0..w { - let sub = f * aug[col][j]; - aug[row][j] -= sub; + for v in aug[col].iter_mut() { + *v *= inv; + } + let pivot_row = aug[col].clone(); + for (row, aug_row) in aug.iter_mut().enumerate() { + if row == col { + continue; + } + let f = aug_row[col]; + if f.abs() < 1e-14 { + continue; + } + for (v, &p) in aug_row.iter_mut().zip(pivot_row.iter()) { + *v -= f * p; } } } @@ -168,8 +179,14 @@ impl RBF { self.biases = Vector::new(n_outputs); self.centers = Self::init_centers_random(inputs, self.n_centers); self.n_centers = self.centers.len(); - let phi: Vec> = inputs.iter() - .map(|x| self.centers.iter().map(|c| Self::rbf_kernel(x, c, self.gamma)).collect()) + let phi: Vec> = inputs + .iter() + .map(|x| { + self.centers + .iter() + .map(|c| Self::rbf_kernel(x, c, self.gamma)) + .collect() + }) .collect(); let (weights, biases) = Self::solve_weights(&phi, targets, self.n_centers, n_outputs, self.lambda); @@ -237,7 +254,11 @@ mod tests { fn rbf_output_shape() { let (inputs, targets) = xor_data(); let mut rbf = RBF::new(4, 1.0, 2); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); assert_eq!(rbf.predict(&inputs[0]).len, 2); } @@ -245,7 +266,11 @@ mod tests { fn rbf_softmax_sums_to_one() { let (inputs, targets) = xor_data(); let mut rbf = RBF::new(4, 1.0, 2); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); for x in &inputs { let sum: f64 = rbf.predict(x).data.iter().sum(); assert!((sum - 1.0).abs() < 1e-10); @@ -259,8 +284,19 @@ mod tests { let mut ok = false; for _ in 0..10 { let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { + rbf.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 0.05, + epochs: 300, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { ok = true; break; } @@ -272,7 +308,11 @@ mod tests { fn rbf_json_roundtrip() { let (inputs, targets) = xor_data(); let mut rbf = RBF::new(4, 1.0, 2); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); rbf.save_json("__rbf_test.json").unwrap(); let loaded = RBF::load_json("__rbf_test.json").unwrap(); let out_orig = rbf.predict(&inputs[0]); @@ -287,7 +327,11 @@ mod tests { fn rbf_binary_roundtrip() { let (inputs, targets) = xor_data(); let mut rbf = RBF::new(4, 1.0, 2); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); rbf.save_binary("__rbf_test.bin").unwrap(); let loaded = RBF::load_binary("__rbf_test.bin").unwrap(); let out_orig = rbf.predict(&inputs[0]); diff --git a/core_lib/src/models/svm.rs b/core_lib/src/models/svm.rs index 91f0c61..c51f8f6 100644 --- a/core_lib/src/models/svm.rs +++ b/core_lib/src/models/svm.rs @@ -18,9 +18,7 @@ impl KernelType { let diff = a.sub(b); (-gamma * diff.dot(&diff)).exp() } - KernelType::Polynomial { degree, coef0 } => { - (a.dot(b) + coef0).powi(*degree as i32) - } + KernelType::Polynomial { degree, coef0 } => (a.dot(b) + coef0).powi(*degree as i32), } } } @@ -36,7 +34,11 @@ struct BinaryLinearSVM { impl BinaryLinearSVM { fn new(input_size: usize, c: f64) -> Self { - BinaryLinearSVM { weights: Vector::new(input_size), bias: 0.0, c } + BinaryLinearSVM { + weights: Vector::new(input_size), + bias: 0.0, + c, + } } // labels : +1.0 ou -1.0 @@ -93,7 +95,8 @@ impl BinaryKernelSVM { fn decision_raw(&self, x: &Vector) -> f64 { let mut result = self.bias; for i in 0..self.alphas.len() { - result += self.alphas[i] * self.sv_labels[i] + result += self.alphas[i] + * self.sv_labels[i] * self.kernel.compute(&self.support_vectors[i], x); } result @@ -106,7 +109,8 @@ impl BinaryKernelSVM { bias: f64, i: usize, ) -> f64 { - let sum: f64 = alphas.iter() + let sum: f64 = alphas + .iter() .zip(labels.iter()) .enumerate() .map(|(j, (&a, &y))| a * y * k[j][i]) @@ -140,7 +144,9 @@ impl BinaryKernelSVM { let candidates: Vec = if examine_all { (0..n).collect() } else { - (0..n).filter(|&i| alphas[i] > eps && alphas[i] < self.c - eps).collect() + (0..n) + .filter(|&i| alphas[i] > eps && alphas[i] < self.c - eps) + .collect() }; for &i in &candidates { @@ -152,10 +158,16 @@ impl BinaryKernelSVM { let mut best_j = (i + 1) % n; let mut best_diff = 0.0f64; for j in 0..n { - if j == i { continue; } - let ej = Self::decision_from_matrix(&alphas, labels, &k, bias, j) - labels[j]; + if j == i { + continue; + } + let ej = + Self::decision_from_matrix(&alphas, labels, &k, bias, j) - labels[j]; let diff = (ei - ej).abs(); - if diff > best_diff { best_diff = diff; best_j = j; } + if diff > best_diff { + best_diff = diff; + best_j = j; + } } let j = best_j; @@ -171,10 +183,14 @@ impl BinaryKernelSVM { ((-d).max(0.0), (self.c - d).min(self.c)) }; - if (l - h).abs() < eps { continue; } + if (l - h).abs() < eps { + continue; + } let eta = 2.0 * k[i][j] - k[i][i] - k[j][j]; - if eta >= 0.0 { continue; } + if eta >= 0.0 { + continue; + } alphas[j] -= labels[j] * (ei - ej) / eta; alphas[j] = alphas[j].max(l).min(h); @@ -185,25 +201,36 @@ impl BinaryKernelSVM { alphas[i] += labels[i] * labels[j] * (alpha_j_old - alphas[j]); - let b1 = bias - ei + let b1 = bias + - ei - labels[i] * (alphas[i] - alpha_i_old) * k[i][i] - labels[j] * (alphas[j] - alpha_j_old) * k[i][j]; - let b2 = bias - ej + let b2 = bias + - ej - labels[i] * (alphas[i] - alpha_i_old) * k[i][j] - labels[j] * (alphas[j] - alpha_j_old) * k[j][j]; - bias = if alphas[i] > eps && alphas[i] < self.c - eps { b1 } - else if alphas[j] > eps && alphas[j] < self.c - eps { b2 } - else { (b1 + b2) / 2.0 }; + bias = if alphas[i] > eps && alphas[i] < self.c - eps { + b1 + } else if alphas[j] > eps && alphas[j] < self.c - eps { + b2 + } else { + (b1 + b2) / 2.0 + }; changed += 1; } } iter += 1; - if iter >= max_iter { break; } - if examine_all { examine_all = false; } - else if changed == 0 { examine_all = true; } + if iter >= max_iter { + break; + } + if examine_all { + examine_all = false; + } else if changed == 0 { + examine_all = true; + } } self.bias = bias; @@ -258,9 +285,15 @@ impl SVM { pub fn predict(&self, input: &Vector) -> Vector { assert!(self.n_classes > 0, "SVM non entraine"); let scores: Vec = if self.use_kernel { - self.kernel_classifiers.iter().map(|clf| clf.decision_raw(input)).collect() + self.kernel_classifiers + .iter() + .map(|clf| clf.decision_raw(input)) + .collect() } else { - self.linear_classifiers.iter().map(|clf| clf.decision(input)).collect() + self.linear_classifiers + .iter() + .map(|clf| clf.decision(input)) + .collect() }; softmax(&Vector::from_vec(scores)) } @@ -275,23 +308,29 @@ impl SVM { let input_size = inputs[0].len; if self.use_kernel { - self.kernel_classifiers = (0..n_classes).map(|cls| { - let labels: Vec = targets.iter() - .map(|t| if t.argmax() == cls { 1.0 } else { -1.0 }) - .collect(); - let mut clf = BinaryKernelSVM::new(self.c, self.kernel.clone()); - clf.train(inputs, &labels, epochs.max(50)); - clf - }).collect(); + self.kernel_classifiers = (0..n_classes) + .map(|cls| { + let labels: Vec = targets + .iter() + .map(|t| if t.argmax() == cls { 1.0 } else { -1.0 }) + .collect(); + let mut clf = BinaryKernelSVM::new(self.c, self.kernel.clone()); + clf.train(inputs, &labels, epochs.max(50)); + clf + }) + .collect(); } else { - self.linear_classifiers = (0..n_classes).map(|cls| { - let labels: Vec = targets.iter() - .map(|t| if t.argmax() == cls { 1.0 } else { -1.0 }) - .collect(); - let mut clf = BinaryLinearSVM::new(input_size, self.c); - clf.train(inputs, &labels, lr, epochs); - clf - }).collect(); + self.linear_classifiers = (0..n_classes) + .map(|cls| { + let labels: Vec = targets + .iter() + .map(|t| if t.argmax() == cls { 1.0 } else { -1.0 }) + .collect(); + let mut clf = BinaryLinearSVM::new(input_size, self.c); + clf.train(inputs, &labels, lr, epochs); + clf + }) + .collect(); } } @@ -380,8 +419,13 @@ mod tests { for _ in 0..5 { let mut svm = SVM::new_linear(10.0); svm.train(&inputs, &targets, 0.05, 2000); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM lineaire doit converger sur AND"); @@ -406,8 +450,13 @@ mod tests { for _ in 0..5 { let mut svm = SVM::new_linear(10.0); svm.train(&inputs, &targets, 0.05, 2000); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM lineaire doit converger sur OR"); @@ -421,8 +470,13 @@ mod tests { for _ in 0..5 { let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); svm.train(&inputs, &targets, 0.0, 200); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM noyau RBF doit converger sur XOR"); diff --git a/core_lib/src/optim/adam.rs b/core_lib/src/optim/adam.rs index 576281c..41ad071 100644 --- a/core_lib/src/optim/adam.rs +++ b/core_lib/src/optim/adam.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use super::optimizer::Optimizer; +use std::collections::HashMap; // Adam (Adaptive Moment Estimation) // m_t = b1 * m_{t-1} + (1-b1) * grad @@ -86,7 +86,9 @@ mod tests { #[test] fn adam_reset_clears_state() { let mut adam = Adam::new(0.001); - for _ in 0..5 { adam.update(0, 1.0, 0.5); } + for _ in 0..5 { + adam.update(0, 1.0, 0.5); + } adam.reset(); let v_after_reset = adam.update(0, 1.0, 0.5); let mut adam2 = Adam::new(0.001); diff --git a/core_lib/src/optim/mod.rs b/core_lib/src/optim/mod.rs index f27ed7f..bed3b60 100644 --- a/core_lib/src/optim/mod.rs +++ b/core_lib/src/optim/mod.rs @@ -1,4 +1,4 @@ +pub mod adam; pub mod gradient_descent; pub mod optimizer; pub mod sgd_momentum; -pub mod adam; diff --git a/core_lib/src/optim/sgd_momentum.rs b/core_lib/src/optim/sgd_momentum.rs index ec2030c..fec692d 100644 --- a/core_lib/src/optim/sgd_momentum.rs +++ b/core_lib/src/optim/sgd_momentum.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use super::optimizer::Optimizer; +use std::collections::HashMap; // SGD avec momentum // v_t = momentum * v_{t-1} - lr * grad @@ -14,7 +14,12 @@ pub struct SGDMomentum { impl SGDMomentum { pub fn new(lr: f64, momentum: f64) -> Self { - SGDMomentum { lr, momentum, nesterov: false, velocities: HashMap::new() } + SGDMomentum { + lr, + momentum, + nesterov: false, + velocities: HashMap::new(), + } } pub fn with_nesterov(mut self) -> Self { @@ -56,7 +61,9 @@ mod tests { fn sgd_momentum_accumulates_velocity() { let mut opt = SGDMomentum::new(0.1, 0.9); let mut v = 1.0f64; - for _ in 0..5 { v = opt.update(0, v, 0.1); } + for _ in 0..5 { + v = opt.update(0, v, 0.1); + } let drop_5 = 1.0 - v; let mut opt2 = SGDMomentum::new(0.1, 0.9); let drop_1 = 1.0 - opt2.update(0, 1.0, 0.1); diff --git a/core_lib/tests/integration_tests.rs b/core_lib/tests/integration_tests.rs index dfd8957..ccd9a6b 100644 --- a/core_lib/tests/integration_tests.rs +++ b/core_lib/tests/integration_tests.rs @@ -2,11 +2,11 @@ use core_lib::math::activations::{Activation, relu, sigmoid, softmax, tanh}; use core_lib::math::matrix::Matrix; use core_lib::math::vector::Vector; use core_lib::metrics::{kfold_indices, mae, mse, r_squared}; +use core_lib::models::TrainConfig; use core_lib::models::linear::LinearModel; use core_lib::models::mlp::MLP; use core_lib::models::rbf::RBF; use core_lib::models::svm::{KernelType, SVM}; -use core_lib::models::TrainConfig; use core_lib::optim::adam::Adam; use core_lib::optim::gradient_descent::GradientDescentConfig; use core_lib::optim::sgd_momentum::SGDMomentum; @@ -673,7 +673,11 @@ fn three_class_vectors() -> (Vec, Vec, Vec) { fn rbf_output_shape_integration() { let (inputs, targets, _) = xor_vectors(); let mut rbf = RBF::new(4, 1.0, 2); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); let out = rbf.predict(&inputs[0]); assert_eq!(out.len, 2); let sum: f64 = out.data.iter().sum(); @@ -686,9 +690,21 @@ fn rbf_xor_integration() { let mut ok = false; for _ in 0..10 { let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { - ok = true; break; + rbf.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 0.05, + epochs: 300, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "RBF doit resoudre XOR"); @@ -700,9 +716,21 @@ fn rbf_and_integration() { let mut ok = false; for _ in 0..5 { let mut rbf = RBF::new(4, 1.0, 2).with_lambda(1e-6); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.01, epochs: 0 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { - ok = true; break; + rbf.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 0.01, + epochs: 0, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "RBF doit resoudre AND"); @@ -714,9 +742,18 @@ fn rbf_multiclass_integration() { let mut ok = false; for _ in 0..5 { let mut rbf = RBF::new(9, 1.0, 3).with_lambda(1e-6); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { - ok = true; break; + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "RBF doit classifier 3 classes"); @@ -726,12 +763,18 @@ fn rbf_multiclass_integration() { fn rbf_json_roundtrip_integration() { let (inputs, targets, _) = xor_vectors(); let mut rbf = RBF::new(4, 1.0, 2); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); rbf.save_json("__it_rbf.json").unwrap(); let loaded = RBF::load_json("__it_rbf.json").unwrap(); let out_a = rbf.predict(&inputs[0]); let out_b = loaded.predict(&inputs[0]); - for (a, b) in out_a.data.iter().zip(out_b.data.iter()) { assert!((a - b).abs() < 1e-10); } + for (a, b) in out_a.data.iter().zip(out_b.data.iter()) { + assert!((a - b).abs() < 1e-10); + } std::fs::remove_file("__it_rbf.json").ok(); } @@ -744,8 +787,13 @@ fn linear_svm_and_integration() { for _ in 0..5 { let mut svm = SVM::new_linear(10.0); svm.train(&inputs, &targets, 0.05, 2000); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM lineaire doit resoudre AND"); @@ -770,8 +818,13 @@ fn linear_svm_or_integration() { for _ in 0..5 { let mut svm = SVM::new_linear(10.0); svm.train(&inputs, &targets, 0.05, 2000); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM lineaire doit resoudre OR"); @@ -784,8 +837,13 @@ fn linear_svm_multiclass_integration() { for _ in 0..5 { let mut svm = SVM::new_linear(10.0); svm.train(&inputs, &targets, 0.05, 3000); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM lineaire doit classifier 3 classes"); @@ -811,8 +869,13 @@ fn kernel_svm_rbf_xor_integration() { for _ in 0..5 { let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); svm.train(&inputs, &targets, 0.0, 300); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM RBF doit resoudre XOR"); @@ -823,10 +886,21 @@ fn kernel_svm_poly_and_integration() { let (inputs, targets, expected) = and_vectors(); let mut ok = false; for _ in 0..5 { - let mut svm = SVM::new_kernel(10.0, KernelType::Polynomial { degree: 2, coef0: 1.0 }); + let mut svm = SVM::new_kernel( + 10.0, + KernelType::Polynomial { + degree: 2, + coef0: 1.0, + }, + ); svm.train(&inputs, &targets, 0.0, 200); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM polynomial doit resoudre AND"); @@ -839,8 +913,13 @@ fn kernel_svm_linear_kernel_and() { for _ in 0..5 { let mut svm = SVM::new_kernel(10.0, KernelType::Linear); svm.train(&inputs, &targets, 0.0, 200); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "SVM noyau lineaire doit resoudre AND"); @@ -855,7 +934,9 @@ fn svm_json_roundtrip_integration() { let loaded = SVM::load_json("__it_svm.json").unwrap(); let out_a = svm.predict(&inputs[0]); let out_b = loaded.predict(&inputs[0]); - for (a, b) in out_a.data.iter().zip(out_b.data.iter()) { assert!((a - b).abs() < 1e-10); } + for (a, b) in out_a.data.iter().zip(out_b.data.iter()) { + assert!((a - b).abs() < 1e-10); + } std::fs::remove_file("__it_svm.json").ok(); } @@ -869,8 +950,13 @@ fn sgd_momentum_mlp_xor() { let mut mlp = MLP::new(&[2, 16, 2]).with_activation(Activation::Sigmoid); let mut opt = SGDMomentum::new(0.5, 0.9); mlp.train_with_optimizer(&inputs, &targets, 5000, &mut opt); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| mlp.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "MLP + SGD Momentum doit resoudre XOR"); @@ -884,8 +970,13 @@ fn adam_mlp_xor() { let mut mlp = MLP::new(&[2, 16, 2]).with_activation(Activation::Sigmoid); let mut opt = Adam::new(0.01); mlp.train_with_optimizer(&inputs, &targets, 3000, &mut opt); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { - ok = true; break; + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| mlp.predict(x).argmax() == e) + { + ok = true; + break; } } assert!(ok, "MLP + Adam doit resoudre XOR"); @@ -973,7 +1064,9 @@ fn metrics_kfold_correct_sizes() { fn metrics_kfold_no_overlap() { let folds = kfold_indices(10, 5); for (train, test) in &folds { - for &t in test { assert!(!train.contains(&t)); } + for &t in test { + assert!(!train.contains(&t)); + } } } @@ -985,20 +1078,52 @@ fn compare_all_models_on_and() { let mut mlp_ok = false; for _ in 0..5 { let mut mlp = MLP::new(&[2, 8, 2]).with_activation(Activation::Sigmoid); - mlp.train(&inputs, &targets, GradientDescentConfig { lr: 1.0, epochs: 3000 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { mlp_ok = true; break; } + mlp.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 1.0, + epochs: 3000, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| mlp.predict(x).argmax() == e) + { + mlp_ok = true; + break; + } } let mut rbf_ok = false; for _ in 0..5 { let mut rbf = RBF::new(4, 1.0, 2).with_lambda(1e-6); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.0, epochs: 0 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { rbf_ok = true; break; } + rbf.train( + &inputs, + &targets, + GradientDescentConfig { lr: 0.0, epochs: 0 }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { + rbf_ok = true; + break; + } } let mut svm_ok = false; for _ in 0..5 { let mut svm = SVM::new_linear(10.0); svm.train(&inputs, &targets, 0.05, 2000); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { svm_ok = true; break; } + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + svm_ok = true; + break; + } } assert!(mlp_ok, "MLP doit reussir AND"); assert!(rbf_ok, "RBF doit reussir AND"); @@ -1011,20 +1136,55 @@ fn compare_nonlinear_models_on_xor() { let mut mlp_ok = false; for _ in 0..5 { let mut mlp = MLP::new(&[2, 16, 2]).with_activation(Activation::Sigmoid); - mlp.train(&inputs, &targets, GradientDescentConfig { lr: 1.0, epochs: 5000 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| mlp.predict(x).argmax() == e) { mlp_ok = true; break; } + mlp.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 1.0, + epochs: 5000, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| mlp.predict(x).argmax() == e) + { + mlp_ok = true; + break; + } } let mut rbf_ok = false; for _ in 0..10 { let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { rbf_ok = true; break; } + rbf.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 0.05, + epochs: 300, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { + rbf_ok = true; + break; + } } let mut svm_ok = false; for _ in 0..5 { let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); svm.train(&inputs, &targets, 0.0, 300); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e) { svm_ok = true; break; } + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e) + { + svm_ok = true; + break; + } } assert!(mlp_ok, "MLP doit resoudre XOR"); assert!(rbf_ok, "RBF doit resoudre XOR"); @@ -1050,12 +1210,29 @@ fn circles_dataset_mlp_vs_rbf() { let mut rbf_ok = false; for _ in 0..20 { let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-6); - rbf.train(&inputs, &targets, GradientDescentConfig { lr: 0.05, epochs: 300 }); - if inputs.iter().zip(expected.iter()).all(|(x, &e)| rbf.predict(x).argmax() == e) { rbf_ok = true; break; } + rbf.train( + &inputs, + &targets, + GradientDescentConfig { + lr: 0.05, + epochs: 300, + }, + ); + if inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| rbf.predict(x).argmax() == e) + { + rbf_ok = true; + break; + } } let mut svm = SVM::new_kernel(10.0, KernelType::RBF { gamma: 2.0 }); svm.train(&inputs, &targets, 0.0, 500); - let svm_ok = inputs.iter().zip(expected.iter()).all(|(x, &e)| svm.predict(x).argmax() == e); + let svm_ok = inputs + .iter() + .zip(expected.iter()) + .all(|(x, &e)| svm.predict(x).argmax() == e); assert!(rbf_ok, "RBF doit separer cercles"); assert!(svm_ok, "SVM RBF doit separer cercles"); diff --git a/python_binding/src/lib.rs b/python_binding/src/lib.rs index 3f0d388..09bb831 100644 --- a/python_binding/src/lib.rs +++ b/python_binding/src/lib.rs @@ -186,7 +186,11 @@ impl PyRBF { #[pyo3(signature = (input_size, output_size, n_centers=10, sigma=1.0))] fn new(input_size: usize, output_size: usize, n_centers: usize, sigma: f64) -> Self { let _ = input_size; // déduit des données dans train() - let gamma = if sigma > 0.0 { 1.0 / (2.0 * sigma * sigma) } else { 1.0 }; + let gamma = if sigma > 0.0 { + 1.0 / (2.0 * sigma * sigma) + } else { + 1.0 + }; Self { model: RBF::new(n_centers, gamma, output_size), } From 3b3a51e1a5b5763b86a54aead20f646eff5f361b Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Sun, 28 Jun 2026 20:43:16 +0200 Subject: [PATCH 12/14] ajout import Optimizer manquant dans les tests --- core_lib/tests/integration_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core_lib/tests/integration_tests.rs b/core_lib/tests/integration_tests.rs index ccd9a6b..7b37e89 100644 --- a/core_lib/tests/integration_tests.rs +++ b/core_lib/tests/integration_tests.rs @@ -9,6 +9,7 @@ use core_lib::models::rbf::RBF; use core_lib::models::svm::{KernelType, SVM}; use core_lib::optim::adam::Adam; use core_lib::optim::gradient_descent::GradientDescentConfig; +use core_lib::optim::optimizer::Optimizer; use core_lib::optim::sgd_momentum::SGDMomentum; // ─── Vector tests ─────────────────────────────────────────────── From b4b9fed2cbbd07f877bfd13b2732d3c22ffc2625 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Sun, 28 Jun 2026 20:46:32 +0200 Subject: [PATCH 13/14] ajout imports Model et Optimizer manquants dans les tests --- core_lib/tests/integration_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core_lib/tests/integration_tests.rs b/core_lib/tests/integration_tests.rs index 7b37e89..3dcac43 100644 --- a/core_lib/tests/integration_tests.rs +++ b/core_lib/tests/integration_tests.rs @@ -2,11 +2,11 @@ use core_lib::math::activations::{Activation, relu, sigmoid, softmax, tanh}; use core_lib::math::matrix::Matrix; use core_lib::math::vector::Vector; use core_lib::metrics::{kfold_indices, mae, mse, r_squared}; -use core_lib::models::TrainConfig; use core_lib::models::linear::LinearModel; use core_lib::models::mlp::MLP; use core_lib::models::rbf::RBF; use core_lib::models::svm::{KernelType, SVM}; +use core_lib::models::{Model, TrainConfig}; use core_lib::optim::adam::Adam; use core_lib::optim::gradient_descent::GradientDescentConfig; use core_lib::optim::optimizer::Optimizer; From c809e80ac66cb5a355f187e5628d22191f167193 Mon Sep 17 00:00:00 2001 From: SINCER-Ali Date: Sun, 28 Jun 2026 20:53:42 +0200 Subject: [PATCH 14/14] correction warnings clippy vec inutiles --- core_lib/src/models/rbf.rs | 2 +- core_lib/src/models/svm.rs | 6 +++--- core_lib/tests/integration_tests.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core_lib/src/models/rbf.rs b/core_lib/src/models/rbf.rs index 50a9da7..32bf8c5 100644 --- a/core_lib/src/models/rbf.rs +++ b/core_lib/src/models/rbf.rs @@ -280,7 +280,7 @@ mod tests { #[test] fn rbf_xor_converges() { let (inputs, targets) = xor_data(); - let expected = vec![0usize, 1, 1, 0]; + let expected = [0usize, 1, 1, 0]; let mut ok = false; for _ in 0..10 { let mut rbf = RBF::new(4, 2.0, 2).with_lambda(1e-8); diff --git a/core_lib/src/models/svm.rs b/core_lib/src/models/svm.rs index c51f8f6..1fd8331 100644 --- a/core_lib/src/models/svm.rs +++ b/core_lib/src/models/svm.rs @@ -414,7 +414,7 @@ mod tests { #[test] fn linear_svm_and_converges() { let (inputs, targets) = and_data(); - let expected = vec![0usize, 0, 0, 1]; + let expected = [0usize, 0, 0, 1]; let mut ok = false; for _ in 0..5 { let mut svm = SVM::new_linear(10.0); @@ -445,7 +445,7 @@ mod tests { Vector::from_vec(vec![0.0, 1.0]), Vector::from_vec(vec![0.0, 1.0]), ]; - let expected = vec![0usize, 1, 1, 1]; + let expected = [0usize, 1, 1, 1]; let mut ok = false; for _ in 0..5 { let mut svm = SVM::new_linear(10.0); @@ -465,7 +465,7 @@ mod tests { #[test] fn kernel_svm_xor_rbf() { let (inputs, targets) = xor_data(); - let expected = vec![0usize, 1, 1, 0]; + let expected = [0usize, 1, 1, 0]; let mut ok = false; for _ in 0..5 { let mut svm = SVM::new_kernel(5.0, KernelType::RBF { gamma: 1.0 }); diff --git a/core_lib/tests/integration_tests.rs b/core_lib/tests/integration_tests.rs index 3dcac43..eb15ab8 100644 --- a/core_lib/tests/integration_tests.rs +++ b/core_lib/tests/integration_tests.rs @@ -814,7 +814,7 @@ fn linear_svm_or_integration() { Vector::from_vec(vec![0.0, 1.0]), Vector::from_vec(vec![0.0, 1.0]), ]; - let expected = vec![0usize, 1, 1, 1]; + let expected = [0usize, 1, 1, 1]; let mut ok = false; for _ in 0..5 { let mut svm = SVM::new_linear(10.0); @@ -1206,7 +1206,7 @@ fn circles_dataset_mlp_vs_rbf() { ]; let mut targets: Vec = vec![Vector::from_vec(vec![1.0, 0.0]); 4]; targets.extend(vec![Vector::from_vec(vec![0.0, 1.0]); 4]); - let expected = vec![0usize, 0, 0, 0, 1, 1, 1, 1]; + let expected = [0usize, 0, 0, 0, 1, 1, 1, 1]; let mut rbf_ok = false; for _ in 0..20 {