Skip to main content

torsh_cli/commands/
real_training.rs

1//! Real training engine backed by torsh-nn / torsh-optim / torsh-autograd.
2//!
3//! This module performs *genuine* optimisation: it builds a real neural network
4//! from [`torsh_nn`], runs real forward passes, computes a real loss, back-props
5//! through [`torsh_autograd`], and updates parameters with a real
6//! [`torsh_optim`] optimiser. Nothing here fabricates losses or gradients.
7//!
8//! It intentionally supports a bounded, honest surface (a multi-layer perceptron
9//! trained on an explicitly-synthetic regression task). Callers that need a real
10//! dataset or a real architecture that this engine cannot build must surface an
11//! honest error rather than fall back to fabricated numbers.
12
13use anyhow::{anyhow, Result};
14
15use torsh::core::device::DeviceType;
16use torsh::nn::container::Sequential;
17use torsh::nn::layers::{Linear, ReLU};
18use torsh::nn::Module;
19use torsh::optim::sgd::SGD;
20use torsh::optim::Optimizer;
21use torsh::tensor::Tensor;
22
23/// Shape of the multi-layer perceptron built by [`build_mlp`].
24#[derive(Debug, Clone, Copy)]
25pub struct MlpConfig {
26    /// Number of input features.
27    pub input_dim: usize,
28    /// Width of the single hidden layer.
29    pub hidden_dim: usize,
30    /// Number of regression outputs.
31    pub output_dim: usize,
32}
33
34/// An in-memory regression dataset made of real tensors.
35///
36/// Both tensors live on the CPU. `inputs` has shape `[n, input_dim]` and
37/// `targets` has shape `[n, output_dim]`.
38#[derive(Debug)]
39pub struct RegressionData {
40    /// Feature matrix, shape `[n, input_dim]`.
41    pub inputs: Tensor,
42    /// Target matrix, shape `[n, output_dim]`.
43    pub targets: Tensor,
44    /// Number of samples.
45    pub n: usize,
46    /// Input dimensionality.
47    pub input_dim: usize,
48    /// Output dimensionality.
49    pub output_dim: usize,
50}
51
52/// Build a real MLP (`Linear -> ReLU -> Linear`) with the requested shape.
53pub fn build_mlp(cfg: &MlpConfig) -> Result<Sequential> {
54    if cfg.input_dim == 0 || cfg.hidden_dim == 0 || cfg.output_dim == 0 {
55        return Err(anyhow!(
56            "MLP dimensions must all be non-zero (got input={}, hidden={}, output={})",
57            cfg.input_dim,
58            cfg.hidden_dim,
59            cfg.output_dim
60        ));
61    }
62    let model = Sequential::new()
63        .add(Linear::new(cfg.input_dim, cfg.hidden_dim, true))
64        .add(ReLU::new())
65        .add(Linear::new(cfg.hidden_dim, cfg.output_dim, true));
66    Ok(model)
67}
68
69/// Deterministically generate an **explicitly synthetic** regression dataset.
70///
71/// Targets are a fixed affine function of the inputs plus a small deterministic
72/// perturbation, so a correctly-wired trainer must be able to drive the loss
73/// down. The data is generated from a simple LCG seeded by `seed` — it is *not*
74/// read from disk and callers must label it as synthetic in any user output.
75pub fn synthetic_regression(
76    n: usize,
77    input_dim: usize,
78    output_dim: usize,
79    seed: u64,
80) -> Result<RegressionData> {
81    if n == 0 || input_dim == 0 || output_dim == 0 {
82        return Err(anyhow!("synthetic dataset dimensions must be non-zero"));
83    }
84
85    // Simple deterministic LCG (numerical-recipes constants) for reproducibility
86    // without pulling randomness into the training result.
87    let mut state = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
88    let mut next_unit = || -> f32 {
89        state = state
90            .wrapping_mul(6364136223846793005)
91            .wrapping_add(1442695040888963407);
92        // Top 24 bits -> [0, 1)
93        ((state >> 40) as f32) / ((1u64 << 24) as f32)
94    };
95
96    // Fixed ground-truth weights so the mapping is learnable.
97    let true_w: Vec<f32> = (0..input_dim * output_dim)
98        .map(|k| 0.5 - ((k % 7) as f32) * 0.1)
99        .collect();
100    let true_b: Vec<f32> = (0..output_dim).map(|j| 0.05 * (j as f32 + 1.0)).collect();
101
102    let mut inputs = Vec::with_capacity(n * input_dim);
103    let mut targets = Vec::with_capacity(n * output_dim);
104
105    for _ in 0..n {
106        let row: Vec<f32> = (0..input_dim).map(|_| next_unit() - 0.5).collect();
107        for j in 0..output_dim {
108            let mut acc = true_b[j];
109            for (i, &x) in row.iter().enumerate() {
110                acc += x * true_w[i * output_dim + j];
111            }
112            // Small deterministic perturbation.
113            acc += (next_unit() - 0.5) * 0.01;
114            targets.push(acc);
115        }
116        inputs.extend_from_slice(&row);
117    }
118
119    let inputs = Tensor::from_data(inputs, vec![n, input_dim], DeviceType::Cpu)?;
120    let targets = Tensor::from_data(targets, vec![n, output_dim], DeviceType::Cpu)?;
121
122    Ok(RegressionData {
123        inputs,
124        targets,
125        n,
126        input_dim,
127        output_dim,
128    })
129}
130
131/// Compute the real mean-squared-error loss tensor between `pred` and `target`.
132///
133/// Returns a scalar tensor still attached to the autograd graph so that
134/// [`Tensor::backward`] populates real parameter gradients.
135pub fn mse_loss(pred: &Tensor, target: &Tensor, count: usize) -> Result<Tensor> {
136    let diff = pred.sub(target)?;
137    let sq = diff.mul(&diff)?;
138    let sse = sq.sum()?;
139    Ok(sse.mul_scalar(1.0 / count as f32)?)
140}
141
142/// Run one real forward pass and return the scalar MSE loss value.
143pub fn evaluate_loss(model: &Sequential, data: &RegressionData) -> Result<f64> {
144    let pred = model.forward(&data.inputs)?;
145    let loss = mse_loss(&pred, &data.targets, data.n * data.output_dim)?;
146    let value = loss
147        .to_vec()?
148        .first()
149        .copied()
150        .ok_or_else(|| anyhow!("loss tensor was empty"))?;
151    Ok(value as f64)
152}
153
154/// Train `model` on `data` for `steps` full-batch SGD steps.
155///
156/// Returns the real, measured loss after every step (length `steps`). This
157/// performs genuine backpropagation and in-place parameter updates.
158pub fn train_regression(
159    model: &Sequential,
160    data: &RegressionData,
161    learning_rate: f32,
162    steps: usize,
163) -> Result<Vec<f64>> {
164    if data.input_dim == 0 || data.output_dim == 0 {
165        return Err(anyhow!("dataset has zero-sized dimensions"));
166    }
167    let params: Vec<_> = model.parameters().values().map(|p| p.tensor()).collect();
168    if params.is_empty() {
169        return Err(anyhow!("model exposes no trainable parameters"));
170    }
171
172    let mut optimizer = SGD::new(params, learning_rate, Some(0.9), None, None, false);
173    let denom = data.n * data.output_dim;
174    let mut history = Vec::with_capacity(steps);
175
176    for _ in 0..steps {
177        optimizer.zero_grad();
178        let pred = model.forward(&data.inputs)?;
179        let loss = mse_loss(&pred, &data.targets, denom)?;
180        loss.backward()?;
181        optimizer
182            .step()
183            .map_err(|e| anyhow!("optimizer step failed: {e}"))?;
184
185        let value = loss
186            .to_vec()?
187            .first()
188            .copied()
189            .ok_or_else(|| anyhow!("loss tensor was empty"))? as f64;
190        history.push(value);
191    }
192
193    Ok(history)
194}