Rust e Machine Learning — Inferência, ONNX e Aceleração de Modelos

Rust e Machine Learning — Inferência, ONNX e Aceleração de Modelos

Rust ocupa em aprendizado de máquina o lugar da inferência, não do treinamento, e a razão é prática: produção pede latência previsível e binário único. O artigo cobre a álgebra linear por baixo dos modelos, a execução via ONNX e o cuidado com o pré-processamento que precisa ser idêntico.
Rust

22 min de leitura

O pipeline típico de machine learning divide-se em duas fases com exigências opostas. Treinamento precisa de flexibilidade, iteração rápida e ecossistema rico — Python com PyTorch ou TensorFlow domina aqui. Inferência precisa de latência baixa, throughput alto, memória mínima e deployment confiável — Rust brilha aqui.

Esta divisão está se tornando a arquitetura padrão da indústria: treinar em Python, exportar para ONNX ou TorchScript, servir em Rust. Uma API de recomendação que responde em 2ms, um modelo de detecção de fraude que processa milhões de transações por segundo, um pipeline de NLP embarcado que roda em um dispositivo com 256MB de RAM — estes são os casos onde Rust entrega valor único.

Dependências

[package]
name = "ml_rust"
version = "0.1.0"
edition = "2021"

[dependencies]
# Tensores e álgebra linear
ndarray = { version = "0.15", features = ["rayon"] }
ndarray-rand = "0.14"
ndarray-stats = "0.5"
nalgebra = "0.32"

# Inferência ONNX
ort = { version = "1.16", features = ["load-dynamic"] }  # ONNX Runtime

# Candle — framework de deep learning da Hugging Face
candle-core = { version = "0.4", features = ["cuda"] }
candle-nn = "0.4"
candle-transformers = "0.4"

# Utilitários
rand = "0.8"
rand_distr = "0.4"
rayon = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tokenizers = "0.15"  # tokenização para NLP

Álgebra linear fundamental para ML

use ndarray::{Array1, Array2, Axis, s};
use ndarray_rand::RandomExt;
use ndarray_rand::rand_distr::{Normal, Uniform};
use rand::SeedableRng;

// Função de ativação — o coração das redes neurais
fn relu(x: f32) -> f32 {
    x.max(0.0)
}

fn sigmoid(x: f32) -> f32 {
    1.0 / (1.0 + (-x).exp())
}

fn tanh(x: f32) -> f32 {
    x.tanh()
}

// Softmax — converte logits em probabilidades
fn softmax(x: &Array1<f32>) -> Array1<f32> {
    let max = x.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
    let exp: Array1<f32> = x.mapv(|v| (v - max).exp());
    let soma = exp.sum();
    exp / soma
}

// Camada densa (linear): y = XW + b
fn camada_densa(
    entrada: &Array2<f32>,     // [batch, input_dim]
    pesos: &Array2<f32>,       // [input_dim, output_dim]
    vies: &Array1<f32>,        // [output_dim]
) -> Array2<f32> {
    let mut saida = entrada.dot(pesos);

    // Broadcasting do viés
    for mut linha in saida.rows_mut() {
        linha += vies;
    }
    saida
}

// Normalização de batch — estabiliza treinamento
fn batch_norm(
    x: &Array2<f32>,
    gamma: &Array1<f32>,
    beta: &Array1<f32>,
    epsilon: f32,
) -> Array2<f32> {
    let media = x.mean_axis(Axis(0)).unwrap();
    let variancia = x.var_axis(Axis(0), 0.0);

    let mut normalizado = Array2::zeros(x.raw_dim());
    for (mut linha, linha_orig) in normalizado.rows_mut().into_iter()
        .zip(x.rows())
    {
        for i in 0..linha.len() {
            linha[i] = (linha_orig[i] - media[i])
                / (variancia[i] + epsilon).sqrt()
                * gamma[i] + beta[i];
        }
    }
    normalizado
}

// Dropout — regularização por zeragem aleatória
fn dropout(
    x: &Array2<f32>,
    taxa: f32,
    treinando: bool,
    seed: u64,
) -> Array2<f32> {
    if !treinando || taxa == 0.0 {
        return x.clone();
    }

    let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
    let mascara = Array2::random_using(
        x.raw_dim(),
        Uniform::new(0.0f32, 1.0),
        &mut rng,
    );

    x * &mascara.mapv(|v| if v > taxa { 1.0 / (1.0 - taxa) } else { 0.0 })
}

fn demonstrar_operacoes_nn() {
    println!("══ Operações Fundamentais de Redes Neurais ══
");

    // Batch de 4 amostras, 3 features cada
    let entrada: Array2<f32> = ndarray::arr2(&[
        [1.0, 2.0, 3.0],
        [4.0, 5.0, 6.0],
        [7.0, 8.0, 9.0],
        [0.5, 1.5, 2.5],
    ]);

    // Pesos inicializados com Xavier/Glorot
    let n_entrada = 3usize;
    let n_saida = 4usize;
    let limite = (6.0 / (n_entrada + n_saida) as f32).sqrt();

    let mut rng = rand::rngs::SmallRng::seed_from_u64(42);
    let pesos = Array2::random_using(
        (n_entrada, n_saida),
        Uniform::new(-limite, limite),
        &mut rng,
    );
    let vies = Array1::zeros(n_saida);

    // Forward pass
    let linear = camada_densa(&entrada, &pesos, &vies);
    println!("Saída linear (4×4):
{linear:.3}
");

    let com_relu = linear.mapv(relu);
    println!("Após ReLU:
{com_relu:.3}
");

    // Softmax na primeira amostra
    let logits: Array1<f32> = com_relu.row(0).to_owned();
    let probs = softmax(&logits);
    println!("Softmax da amostra 0: {probs:.4}");
    println!("Soma das probs: {:.6}
", probs.sum()); // deve ser 1.0

    // Funções de ativação comparadas
    println!("── Comparação de ativações ──");
    let x_vals: Vec<f32> = (-4..=4).map(|x| x as f32).collect();
    println!("{:>6} {:>10} {:>10} {:>10}", "x", "relu", "sigmoid", "tanh");
    for x in &x_vals {
        println!("{:>6.1} {:>10.4} {:>10.4} {:>10.4}",
            x, relu(*x), sigmoid(*x), tanh(*x));
    }
}

Regressão logística do zero

use ndarray::{Array1, Array2};

struct RegressaoLogistica {
    pesos: Array1<f32>,
    vies: f32,
    taxa_aprendizado: f32,
    historico_loss: Vec<f32>,
}

impl RegressaoLogistica {
    fn novo(n_features: usize, taxa: f32) -> Self {
        let mut rng = rand::rngs::SmallRng::seed_from_u64(42);
        RegressaoLogistica {
            pesos: Array1::random_using(
                n_features,
                ndarray_rand::rand_distr::Normal::new(0.0f32, 0.01).unwrap(),
                &mut rng,
            ),
            vies: 0.0,
            taxa_aprendizado: taxa,
            historico_loss: Vec::new(),
        }
    }

    fn prever(&self, x: &Array2<f32>) -> Array1<f32> {
        let logits = x.dot(&self.pesos) + self.vies;
        logits.mapv(sigmoid)
    }

    fn prever_classe(&self, x: &Array2<f32>) -> Array1<u8> {
        self.prever(x).mapv(|p| if p >= 0.5 { 1 } else { 0 })
    }

    fn treinar(
        &mut self,
        x: &Array2<f32>,
        y: &Array1<f32>,
        epocas: usize,
        verbose: bool,
    ) {
        let n = x.nrows() as f32;

        for epoca in 0..epocas {
            // Forward pass
            let predicoes = self.prever(x);

            // Binary cross-entropy loss
            let loss = -( y * predicoes.mapv(|p| (p + 1e-15).ln())
                + (1.0 - y) * predicoes.mapv(|p| (1.0 - p + 1e-15).ln())
            ).mean().unwrap();

            self.historico_loss.push(loss);

            // Backpropagation — gradiente do BCE com sigmoid
            let erro = &predicoes - y;

            // Gradientes
            let grad_pesos = x.t().dot(&erro) / n;
            let grad_vies = erro.mean().unwrap();

            // Atualização gradient descent
            self.pesos -= &(grad_pesos * self.taxa_aprendizado);
            self.vies -= grad_vies * self.taxa_aprendizado;

            if verbose && (epoca + 1) % 100 == 0 {
                let acuracia = self.acuracia(x, y);
                println!("  Época {:4} | Loss: {:.4} | Acurácia: {:.2}%",
                    epoca + 1, loss, acuracia * 100.0);
            }
        }
    }

    fn acuracia(&self, x: &Array2<f32>, y: &Array1<f32>) -> f32 {
        let predicoes = self.prever_classe(x);
        let corretos = predicoes.iter()
            .zip(y.iter())
            .filter(|(&pred, &real)| pred as f32 == real)
            .count();
        corretos as f32 / y.len() as f32
    }

    fn matriz_confusao(&self, x: &Array2<f32>, y: &Array1<f32>) -> [[u32; 2]; 2] {
        let predicoes = self.prever_classe(x);
        let mut matriz = [[0u32; 2]; 2];
        for (&pred, &real) in predicoes.iter().zip(y.iter()) {
            matriz[real as usize][pred as usize] += 1;
        }
        matriz
    }
}

fn gerar_dataset_classificacao(
    n: usize,
    seed: u64,
) -> (Array2<f32>, Array1<f32>) {
    let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
    let normal = ndarray_rand::rand_distr::Normal::new(0.0f32, 1.0).unwrap();

    let mut x_data = Vec::with_capacity(n * 2);
    let mut y_data = Vec::with_capacity(n);

    for i in 0..n {
        let classe = (i % 2) as f32;
        let centro_x = if classe == 0.0 { -1.5f32 } else { 1.5f32 };
        let centro_y = if classe == 0.0 { -1.5f32 } else { 1.5f32 };

        let xi: f32 = ndarray_rand::rand_distr::Normal::new(
            centro_x, 0.8
        ).unwrap().sample(&mut rng);

        let yi: f32 = ndarray_rand::rand_distr::Normal::new(
            centro_y, 0.8
        ).unwrap().sample(&mut rng);

        x_data.push(xi);
        x_data.push(yi);
        y_data.push(classe);
    }

    let x = Array2::from_shape_vec((n, 2), x_data).unwrap();
    let y = Array1::from(y_data);
    (x, y)
}

fn demo_regressao_logistica() {
    println!("══ Regressão Logística ══
");

    let (x_treino, y_treino) = gerar_dataset_classificacao(200, 42);
    let (x_teste, y_teste)   = gerar_dataset_classificacao(50, 99);

    let mut modelo = RegressaoLogistica::novo(2, 0.1);

    println!("Treinando por 500 épocas...");
    modelo.treinar(&x_treino, &y_treino, 500, true);

    let acuracia_treino = modelo.acuracia(&x_treino, &y_treino);
    let acuracia_teste  = modelo.acuracia(&x_teste,  &y_teste);

    println!("
Acurácia treino : {:.2}%", acuracia_treino * 100.0);
    println!("Acurácia teste  : {:.2}%", acuracia_teste  * 100.0);

    let mc = modelo.matriz_confusao(&x_teste, &y_teste);
    println!("
Matriz de Confusão:");
    println!("           Previsto 0  Previsto 1");
    println!("  Real 0      {:>6}      {:>6}", mc[0][0], mc[0][1]);
    println!("  Real 1      {:>6}      {:>6}", mc[1][0], mc[1][1]);

    let vp = mc[1][1] as f32;
    let fp = mc[0][1] as f32;
    let vn = mc[0][0] as f32;
    let fn_ = mc[1][0] as f32;

    println!("
Métricas:");
    println!("  Precisão  : {:.4}", vp / (vp + fp));
    println!("  Recall    : {:.4}", vp / (vp + fn_));
    println!("  F1-Score  : {:.4}",
        2.0 * vp / (2.0 * vp + fp + fn_));
}

Rede neural MLP do zero

use ndarray::{Array1, Array2};
use rand::SeedableRng;
use ndarray_rand::RandomExt;
use ndarray_rand::rand_distr::Uniform;

#[derive(Debug, Clone)]
struct CamadaDensa {
    pesos: Array2<f32>,
    vies: Array1<f32>,
    grad_pesos: Array2<f32>,
    grad_vies: Array1<f32>,
    entrada_cache: Array2<f32>,
}

impl CamadaDensa {
    fn nova(n_entrada: usize, n_saida: usize, seed: u64) -> Self {
        let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
        // Inicialização He — adequada para ReLU
        let desvio = (2.0 / n_entrada as f32).sqrt();

        CamadaDensa {
            pesos: Array2::random_using(
                (n_entrada, n_saida),
                ndarray_rand::rand_distr::Normal::new(0.0f32, desvio).unwrap(),
                &mut rng,
            ),
            vies: Array1::zeros(n_saida),
            grad_pesos: Array2::zeros((n_entrada, n_saida)),
            grad_vies: Array1::zeros(n_saida),
            entrada_cache: Array2::zeros((1, n_entrada)),
        }
    }

    fn forward(&mut self, entrada: &Array2<f32>) -> Array2<f32> {
        self.entrada_cache = entrada.clone();
        let mut saida = entrada.dot(&self.pesos);
        for mut linha in saida.rows_mut() {
            linha += &self.vies;
        }
        saida
    }

    fn backward(&mut self, grad_saida: &Array2<f32>) -> Array2<f32> {
        let n = self.entrada_cache.nrows() as f32;

        self.grad_pesos = self.entrada_cache.t().dot(grad_saida) / n;
        self.grad_vies  = grad_saida.mean_axis(ndarray::Axis(0)).unwrap();

        grad_saida.dot(&self.pesos.t())
    }

    fn atualizar(&mut self, taxa: f32) {
        self.pesos -= &(self.grad_pesos.clone() * taxa);
        self.vies  -= &(self.grad_vies.clone()  * taxa);
    }
}

struct MLP {
    camadas: Vec<CamadaDensa>,
    taxa_aprendizado: f32,
}

impl MLP {
    fn novo(
        arquitetura: &[usize],
        taxa: f32,
    ) -> Self {
        let camadas = arquitetura.windows(2)
            .enumerate()
            .map(|(i, par)| CamadaDensa::nova(par[0], par[1], i as u64))
            .collect();

        MLP { camadas, taxa_aprendizado: taxa }
    }

    fn forward(&mut self, x: &Array2<f32>) -> Array2<f32> {
        let mut ativacao = x.clone();

        for (i, camada) in self.camadas.iter_mut().enumerate() {
            let linear = camada.forward(&ativacao);

            // ReLU em todas as camadas exceto a última
            ativacao = if i < self.camadas.len() - 1 {
                linear.mapv(|v| v.max(0.0))
            } else {
                linear // última camada: saída bruta (logits)
            };
        }

        ativacao
    }

    fn backward_e_atualizar(&mut self, grad: Array2<f32>) {
        let mut grad_atual = grad;

        for camada in self.camadas.iter_mut().rev() {
            // Gradiente através de ReLU (exceto última camada — simplificação)
            grad_atual = camada.backward(&grad_atual);
            camada.atualizar(self.taxa_aprendizado);
        }
    }

    fn mse_loss(predicoes: &Array2<f32>, targets: &Array2<f32>) -> f32 {
        let diff = predicoes - targets;
        (&diff * &diff).mean().unwrap()
    }

    fn treinar_step(
        &mut self,
        x: &Array2<f32>,
        y: &Array2<f32>,
    ) -> f32 {
        // Forward
        let predicoes = self.forward(x);

        // Loss MSE
        let loss = Self::mse_loss(&predicoes, y);

        // Gradiente da loss: dL/dŷ = 2(ŷ - y)/n
        let grad = (&predicoes - y) * 2.0
            / predicoes.nrows() as f32;

        // Backward
        self.backward_e_atualizar(grad);

        loss
    }
}

fn demo_mlp() {
    println!("══ MLP — Rede Neural Multi-Camada ══
");

    // Problema XOR — não linearmente separável
    let x: Array2<f32> = ndarray::arr2(&[
        [0.0, 0.0],
        [0.0, 1.0],
        [1.0, 0.0],
        [1.0, 1.0],
    ]);

    let y: Array2<f32> = ndarray::arr2(&[
        [0.0],
        [1.0],
        [1.0],
        [0.0],
    ]);

    // Arquitetura: 2 → 4 → 4 → 1
    let mut mlp = MLP::novo(&[2, 4, 4, 1], 0.1);

    println!("Treinando XOR por 2000 épocas...
");

    for epoca in 0..2000 {
        let loss = mlp.treinar_step(&x, &y);

        if (epoca + 1) % 400 == 0 {
            println!("  Época {:4} | Loss: {:.6}", epoca + 1, loss);
        }
    }

    println!("
Previsões após treinamento:");
    let preds = mlp.forward(&x);
    println!("{:>8} {:>8} │ {:>10} {:>10}",
        "x1", "x2", "Esperado", "Previsto");
    println!("{}", "─".repeat(42));
    for i in 0..4 {
        println!("{:>8.0} {:>8.0} │ {:>10.0} {:>10.4}",
            x[[i, 0]], x[[i, 1]],
            y[[i, 0]],
            preds[[i, 0]]);
    }

    // Verifica se aprendeu XOR
    let correto = preds.iter().zip(y.iter()).all(|(&p, &t)| {
        (p >= 0.5 && t == 1.0) || (p < 0.5 && t == 0.0)
    });
    println!("
XOR aprendido: {}", if correto { "✓" } else { "✗" });
}

Inferência com ONNX Runtime

O fluxo de produção real: treinar em Python, exportar para ONNX, servir em Rust:

# Em Python — treina e exporta (executado fora do Rust)
import torch
import torch.nn as nn
import torch.onnx

class ModeloSimples(nn.Module):
    def __init__(self):
        super().__init__()
        self.rede = nn.Sequential(
            nn.Linear(4, 16),
            nn.ReLU(),
            nn.Linear(16, 8),
            nn.ReLU(),
            nn.Linear(8, 3),
        )

    def forward(self, x):
        return self.rede(x)

modelo = ModeloSimples()
# ... treinar ...

# Exporta para ONNX
entrada_exemplo = torch.randn(1, 4)
torch.onnx.export(
    modelo,
    entrada_exemplo,
    "modelo.onnx",
    input_names=["entrada"],
    output_names=["saida"],
    dynamic_axes={"entrada": {0: "batch_size"}, "saida": {0: "batch_size"}},
    opset_version=17,
)
print("Exportado para modelo.onnx")
// Rust — carrega e executa inferência com ONNX Runtime
use ort::{Environment, Session, SessionBuilder, Value};
use ndarray::{Array2, CowArray};
use anyhow::Result;

struct ServicorInferencia {
    sessao: Session,
    nome_entrada: String,
    nome_saida: String,
}

impl ServicorInferencia {
    fn carregar(caminho_modelo: &str) -> Result<Self> {
        let ambiente = Environment::builder()
            .with_name("servico_ml")
            .build()?
            .into_arc();

        let sessao = SessionBuilder::new(&ambiente)?
            .with_intra_threads(4)?
            .with_model_from_file(caminho_modelo)?;

        let nome_entrada = sessao.inputs[0].name.clone();
        let nome_saida = sessao.outputs[0].name.clone();

        println!("Modelo carregado: {caminho_modelo}");
        println!("  Entrada: {} {:?}",
            nome_entrada, sessao.inputs[0].input_type);
        println!("  Saída:   {} {:?}",
            nome_saida, sessao.outputs[0].input_type);

        Ok(ServicorInferencia { sessao, nome_entrada, nome_saida })
    }

    fn inferir(&self, dados: Array2<f32>) -> Result<Array2<f32>> {
        let shape: Vec<i64> = dados.shape().iter()
            .map(|&d| d as i64)
            .collect();

        let array = CowArray::from(dados.into_dyn());

        let entrada = vec![(
            self.nome_entrada.as_str(),
            Value::from_array(self.sessao.allocator(), &array)?,
        )];

        let saidas = self.sessao.run(entrada)?;

        let resultado = saidas[0].try_extract::<f32>()?;
        let view = resultado.view().clone().into_dimensionality::<ndarray::Ix2>()?;

        Ok(view.to_owned())
    }

    fn inferir_batch_otimizado(
        &self,
        dados: Vec<Vec<f32>>,
        tamanho_batch: usize,
    ) -> Result<Vec<Vec<f32>>> {
        let n_features = dados[0].len();
        let mut resultados = Vec::new();

        for chunk in dados.chunks(tamanho_batch) {
            let batch_flat: Vec<f32> = chunk.iter().flatten().cloned().collect();
            let batch = Array2::from_shape_vec(
                (chunk.len(), n_features),
                batch_flat,
            )?;

            let saida = self.inferir(batch)?;
            for linha in saida.rows() {
                resultados.push(linha.to_vec());
            }
        }

        Ok(resultados)
    }
}

fn softmax_vec(logits: &[f32]) -> Vec<f32> {
    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
    let exps: Vec<f32> = logits.iter().map(|&x| (x - max).exp()).collect();
    let soma: f32 = exps.iter().sum();
    exps.iter().map(|&e| e / soma).collect()
}

async fn demo_onnx() -> Result<()> {
    println!("══ Inferência ONNX em Rust ══
");

    // Simula o que seria carregado do arquivo
    // Na prática: ServicorInferencia::carregar("modelo.onnx")?
    println!("(Demonstração — requer modelo.onnx exportado do Python)
");

    // Simula inferência com dados do dataset Iris
    let amostras_iris = vec![
        // [comprimento_sep, largura_sep, comprimento_pet, largura_pet]
        vec![5.1f32, 3.5, 1.4, 0.2],  // setosa
        vec![7.0,   3.2, 4.7, 1.4],   // versicolor
        vec![6.3,   3.3, 6.0, 2.5],   // virginica
        vec![4.9,   3.0, 1.4, 0.2],   // setosa
        vec![5.7,   2.8, 4.5, 1.3],   // versicolor
    ];

    let classes = ["Setosa", "Versicolor", "Virginica"];

    // Simula saída de um modelo treinado (logits)
    let logits_simulados: Vec<Vec<f32>> = vec![
        vec![ 3.2, -1.1, -2.3],
        vec![-0.5,  2.8,  0.3],
        vec![-1.8,  0.2,  3.5],
        vec![ 3.0, -0.9, -2.1],
        vec![-0.3,  2.5,  0.1],
    ];

    println!("Resultados de classificação (Iris):");
    println!("{:>6} {:>10} {:>12} {:>12} {:>12}  {:>12}",
        "ID", "Setosa%", "Versic%", "Virg%", "Classe", "Confiança");
    println!("{}", "─".repeat(75));

    for (i, logits) in logits_simulados.iter().enumerate() {
        let probs = softmax_vec(logits);
        let (classe_idx, &confianca) = probs.iter().enumerate()
            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
            .unwrap();

        println!("{:>6} {:>10.2}% {:>11.2}% {:>11.2}%  {:>12}  {:>11.2}%",
            i + 1,
            probs[0] * 100.0,
            probs[1] * 100.0,
            probs[2] * 100.0,
            classes[classe_idx],
            confianca * 100.0,
        );
    }

    Ok(())
}

Candle — deep learning nativo em Rust

use candle_core::{Tensor, Device, DType};
use candle_nn::{linear, Linear, Module, VarBuilder, VarMap};
use anyhow::Result;

// Rede neural com Candle
struct RedeCandle {
    l1: Linear,
    l2: Linear,
    l3: Linear,
}

impl RedeCandle {
    fn novo(vm: &VarMap, dev: &Device) -> Result<Self> {
        let vb = VarBuilder::from_varmap(vm, DType::F32, dev);
        Ok(RedeCandle {
            l1: linear(4, 16, vb.pp("l1"))?,
            l2: linear(16, 8, vb.pp("l2"))?,
            l3: linear(8, 3, vb.pp("l3"))?,
        })
    }

    fn forward(&self, x: &Tensor) -> Result<Tensor> {
        let x = self.l1.forward(x)?;
        let x = x.relu()?;
        let x = self.l2.forward(&x)?;
        let x = x.relu()?;
        let x = self.l3.forward(&x)?;
        Ok(x)
    }
}

fn demo_candle() -> Result<()> {
    println!("══ Candle — Deep Learning Nativo em Rust ══
");

    let dev = Device::Cpu;

    // Tensores básicos
    let a = Tensor::new(&[[1.0f32, 2.0], [3.0, 4.0]], &dev)?;
    let b = Tensor::new(&[[5.0f32, 6.0], [7.0, 8.0]], &dev)?;

    let soma = (&a + &b)?;
    let produto = a.matmul(&b)?;

    println!("A + B = {:?}", soma.to_vec2::<f32>()?);
    println!("A × B = {:?}", produto.to_vec2::<f32>()?);

    // Cria modelo
    let varmap = VarMap::new();
    let modelo = RedeCandle::novo(&varmap, &dev)?;

    // Dados sintéticos do Iris (4 features, 3 classes)
    let dados = Tensor::new(
        &[[5.1f32, 3.5, 1.4, 0.2],
          [7.0,   3.2, 4.7, 1.4],
          [6.3,   3.3, 6.0, 2.5]],
        &dev,
    )?;

    // Forward pass
    let logits = modelo.forward(&dados)?;
    println!("
Logits (3 amostras × 3 classes):");
    println!("{:?}", logits.to_vec2::<f32>()?);

    // Softmax manual
    let exp_logits = logits.exp()?;
    let soma_exp = exp_logits.sum_keepdim(1)?;
    let probs = exp_logits.broadcast_div(&soma_exp)?;
    println!("
Probabilidades:");
    println!("{:?}", probs.to_vec2::<f32>()?);

    // Argmax — classe predita
    let classes = probs.argmax(1)?;
    let nomes = ["Setosa", "Versicolor", "Virginica"];
    let preds: Vec<u32> = classes.to_vec1()?;
    for (i, &pred) in preds.iter().enumerate() {
        println!("Amostra {}: {} (classe {})", i + 1, nomes[pred as usize], pred);
    }

    Ok(())
}

Pipeline de NLP — tokenização e embeddings

use std::collections::HashMap;

// Tokenizador simples — versão educacional
struct TokenizadorSimples {
    vocabulario: HashMap<String, u32>,
    id_para_token: Vec<String>,
    token_unk: u32,
    token_pad: u32,
}

impl TokenizadorSimples {
    fn novo(textos: &[&str]) -> Self {
        let mut vocab: HashMap<String, u32> = HashMap::new();
        vocab.insert("<PAD>".to_string(), 0);
        vocab.insert("<UNK>".to_string(), 1);
        vocab.insert("<CLS>".to_string(), 2);
        vocab.insert("<SEP>".to_string(), 3);

        let mut id_para_token = vec![
            "<PAD>".to_string(),
            "<UNK>".to_string(),
            "<CLS>".to_string(),
            "<SEP>".to_string(),
        ];

        for texto in textos {
            for palavra in texto.split_whitespace() {
                let palavra = palavra.to_lowercase()
                    .trim_matches(|c: char| !c.is_alphabetic())
                    .to_string();

                if !palavra.is_empty() && !vocab.contains_key(&palavra) {
                    let id = vocab.len() as u32;
                    vocab.insert(palavra.clone(), id);
                    id_para_token.push(palavra);
                }
            }
        }

        TokenizadorSimples {
            token_unk: *vocab.get("<UNK>").unwrap(),
            token_pad: *vocab.get("<PAD>").unwrap(),
            vocabulario: vocab,
            id_para_token,
        }
    }

    fn tokenizar(&self, texto: &str, max_len: usize) -> Vec<u32> {
        let mut ids = vec![2u32]; // <CLS>

        for palavra in texto.split_whitespace() {
            let palavra = palavra.to_lowercase()
                .trim_matches(|c: char| !c.is_alphabetic())
                .to_string();

            if !palavra.is_empty() {
                let id = *self.vocabulario.get(&palavra)
                    .unwrap_or(&self.token_unk);
                ids.push(id);
            }

            if ids.len() >= max_len - 1 {
                break;
            }
        }

        ids.push(3); // <SEP>

        // Padding
        while ids.len() < max_len {
            ids.push(self.token_pad);
        }

        ids
    }

    fn decodificar(&self, ids: &[u32]) -> String {
        ids.iter()
            .filter(|&&id| id != self.token_pad && id != 2 && id != 3)
            .map(|&id| self.id_para_token.get(id as usize)
                .map(String::as_str)
                .unwrap_or("<UNK>"))
            .collect::<Vec<_>>()
            .join(" ")
    }

    fn tamanho_vocab(&self) -> usize {
        self.vocabulario.len()
    }
}

// Análise de sentimento baseada em léxico
struct AnalisadorSentimento {
    palavras_positivas: HashMap<String, f32>,
    palavras_negativas: HashMap<String, f32>,
    intensificadores: HashMap<String, f32>,
}

impl AnalisadorSentimento {
    fn novo() -> Self {
        let mut pos = HashMap::new();
        let mut neg = HashMap::new();
        let mut intens = HashMap::new();

        // Léxico positivo
        for (p, s) in [("ótimo", 0.9), ("bom", 0.7), ("excelente", 0.95),
                       ("perfeito", 0.9), ("maravilhoso", 0.85), ("incrível", 0.88),
                       ("satisfeito", 0.7), ("recomendo", 0.8), ("adorei", 0.9)] {
            pos.insert(p.to_string(), s);
        }

        // Léxico negativo
        for (p, s) in [("ruim", 0.8), ("péssimo", 0.95), ("horrível", 0.9),
                       ("terrível", 0.88), ("decepcionante", 0.85), ("horrendo", 0.92),
                       ("insatisfeito", 0.75), ("problema", 0.6), ("defeito", 0.7)] {
            neg.insert(p.to_string(), s);
        }

        // Intensificadores
        for (p, m) in [("muito", 1.5), ("extremamente", 2.0),
                       ("super", 1.4), ("bastante", 1.3)] {
            intens.insert(p.to_string(), m);
        }

        AnalisadorSentimento {
            palavras_positivas: pos,
            palavras_negativas: neg,
            intensificadores: intens,
        }
    }

    fn analisar(&self, texto: &str) -> (f32, &'static str) {
        let palavras: Vec<&str> = texto.split_whitespace().collect();
        let mut score = 0.0f32;
        let mut intensidade = 1.0f32;

        for (i, palavra) in palavras.iter().enumerate() {
            let p = palavra.to_lowercase();
            let p = p.trim_matches(|c: char| !c.is_alphabetic());

            if let Some(&mult) = self.intensificadores.get(p) {
                intensidade = mult;
                continue;
            }

            if let Some(&s) = self.palavras_positivas.get(p) {
                score += s * intensidade;
            } else if let Some(&s) = self.palavras_negativas.get(p) {
                score -= s * intensidade;
            }

            intensidade = 1.0; // reset após uso
        }

        let sentimento = if score > 0.3 {
            "Positivo"
        } else if score < -0.3 {
            "Negativo"
        } else {
            "Neutro"
        };

        (score, sentimento)
    }
}

fn demo_nlp() {
    println!("══ Pipeline de NLP ══
");

    let corpus = [
        "produto muito bom e de ótima qualidade",
        "péssimo atendimento terrível experiência",
        "chegou rápido e funcionando perfeitamente",
        "super satisfeito com a compra recomendo",
        "produto com defeito extremamente decepcionante",
    ];

    // Tokenização
    let tokenizador = TokenizadorSimples::novo(&corpus);
    println!("Vocabulário: {} tokens
", tokenizador.tamanho_vocab());

    println!("── Tokenização ──");
    for texto in &corpus[..2] {
        let ids = tokenizador.tokenizar(texto, 12);
        let decodificado = tokenizador.decodificar(&ids);
        println!("  Original : {texto}");
        println!("  IDs      : {:?}", &ids[..ids.iter().rposition(|&x| x != 0).map(|i| i + 1).unwrap_or(0)]);
        println!("  Decode   : {decodificado}
");
    }

    // Análise de sentimento
    let analisador = AnalisadorSentimento::novo();
    println!("── Análise de Sentimento ──");
    println!("{:>55}  {:>8}  {:>10}", "Texto", "Score", "Sentimento");
    println!("{}", "─".repeat(80));

    for texto in &corpus {
        let (score, sentimento) = analisador.analisar(texto);
        let icone = match sentimento {
            "Positivo" => "🟢",
            "Negativo" => "🔴",
            _          => "🟡",
        };
        println!("{:>55}  {:>8.3}  {} {}",
            &texto[..texto.len().min(55)],
            score,
            icone,
            sentimento,
        );
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    demonstrar_operacoes_nn();
    println!("
{}
", "═".repeat(55));

    demo_regressao_logistica();
    println!("
{}
", "═".repeat(55));

    demo_mlp();
    println!("
{}
", "═".repeat(55));

    demo_onnx().await?;
    println!("
{}
", "═".repeat(55));

    demo_candle()?;
    println!("
{}
", "═".repeat(55));

    demo_nlp();

    Ok(())
}

Exercícios

Exercício 1

Por que Rust é usado mais para inferência que para treinamento de modelos?

Ver resposta

✓ Resposta: Porque o ecossistema de treinamento — dados, experimentação, visualização, modelos pré-treinados — está consolidado em Python, e treinar é atividade exploratória, onde velocidade de iteração vale mais que desempenho de execução. A inferência tem requisitos opostos: roda em produção, milhares de vezes, muitas vezes com latência apertada ou em dispositivo com pouca memória. Ali pesam as vantagens do Rust — sem coletor de lixo, binário único, sem interpretador a carregar.

Exercício 2

O que é o ONNX e que problema ele resolve?

Ver resposta

✓ Resposta: É um formato aberto de representação de modelos treinados, que desacopla onde o modelo foi treinado de onde ele roda. Um modelo treinado em PyTorch é exportado para ONNX e executado por qualquer runtime compatível — inclusive em Rust, sem Python instalado. Isso resolve o problema prático de produção: colocar no ar sem arrastar junto o ambiente inteiro de ciência de dados, com suas dezenas de dependências e a versão exata do interpretador.

Exercício 3

Explique por que uma rede neural simples é, em essência, multiplicação de matrizes.

Ver resposta

✓ Resposta: Cada camada densa calcula saída = ativação(W · entrada + b), em que W é a matriz de pesos e b o viés. A propagação para frente é essa operação repetida camada a camada, e a única parte não linear é a função de ativação — sem ela, várias camadas colapsariam numa só, já que a composição de transformações lineares continua linear. Por isso o desempenho de um framework é, na prática, o desempenho da sua multiplicação de matrizes, e por isso GPUs, feitas para isso, dominam o treinamento.

Exercício 4

Por que normalizar os dados de entrada importa numa regressão logística?

Ver resposta

✓ Resposta: Porque características em escalas muito diferentes — idade entre 0 e 100, renda entre 0 e 100000 — produzem gradientes de magnitudes muito diferentes. Com uma taxa de aprendizado única, a característica de escala maior domina a atualização e as demais quase não se movem; reduzir a taxa para acomodá-la torna a convergência lentíssima. Normalizar coloca todas em faixa comparável e a descida do gradiente passa a progredir de forma equilibrada. A regra que acompanha: calcular média e desvio no conjunto de treino e aplicar os mesmos valores ao de teste, sob pena de vazar informação.

Exercício 5

Por que o pré-processamento de texto precisa ser idêntico no treino e na inferência?

Ver resposta

✓ Resposta: Porque o modelo não recebe texto, recebe números — e o mapeamento entre um e outro é definido pelo tokenizador. Se a inferência usar um vocabulário diferente, ou dividir as palavras de outro jeito, o mesmo texto vira uma sequência de identificadores diferente, e o modelo passa a operar sobre entradas que nunca viu. Não há erro nem exceção: ele responde com confiança e erra. Por isso o tokenizador é versionado e distribuído junto com os pesos, e não reimplementado na linguagem de destino.

Fontes e leituras recomendadas

Runtimes e bibliotecas

  • ONNX Runtime — Documentação — O runtime de inferência da Microsoft, com os provedores de execução para CPU, GPU e aceleradores.
  • ort — ONNX Runtime para Rust — A ligação em Rust para o ONNX Runtime, com guia de instalação e exemplos de carregamento e inferência.
  • Candle — Hugging Face — Framework de deep learning nativo em Rust, com foco em binários pequenos e inferência sem Python.
  • linfa — Coleção de algoritmos clássicos de aprendizado de máquina em Rust, no espírito do scikit-learn.

Fundamentos

  • ONNX — Open Neural Network Exchange — O formato aberto em si: especificação, operadores suportados e a lista de frameworks que exportam para ele.
  • ndarray — docs.rs — A base de álgebra linear usada pela maioria das implementações em Rust, e o que se usa ao escrever um modelo do zero.
Comentários

Mais em Rust

Enums e Pattern Matching — O Sistema Mais Expressivo que Você Já Viu
Enums e Pattern Matching — O Sistema Mais Expressivo que Você Já Viu

O enum de Rust não é um rótulo numérico: cada variante carrega os dados que…

Generics — Código que Funciona para Qualquer Tipo
Generics — Código que Funciona para Qualquer Tipo

Escrever a mesma função para cada tipo é o tipo de repetição que generics…

Banco de Dados com SQLx — Persistência Real para sua API
Banco de Dados com SQLx — Persistência Real para sua API

O SQLx não esconde o banco atrás de objetos: o SQL continua SQL, e as macros o…