clawft_kernel/sensor_graph/
train.rs1use super::fixture::SyntheticDataset;
4use super::model::{KstemitConfig, KstemitModel};
5
6#[derive(Debug, Clone)]
8pub struct TrainConfig {
9 pub epochs: usize,
10 pub learning_rate: f32,
11 pub weight_decay: f32,
12 pub shuffle: bool,
14 pub seed: u64,
15}
16
17impl Default for TrainConfig {
18 fn default() -> Self {
19 Self {
20 epochs: 30,
21 learning_rate: 0.05,
22 weight_decay: 1e-4,
23 shuffle: true,
24 seed: 7,
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
31pub struct TrainResult {
32 pub model: KstemitModel,
33 pub epoch_losses: Vec<f32>,
35 pub train_accuracy: f32,
37}
38
39#[derive(Debug, Clone)]
41pub struct EvalMetrics {
42 pub accuracy: f32,
43 pub mean_loss: f32,
44 pub n: usize,
45 pub confusion: Vec<usize>,
47 pub num_classes: usize,
48}
49
50pub fn train_sgd(
52 dataset: &SyntheticDataset,
53 model_cfg: KstemitConfig,
54 train_cfg: TrainConfig,
55) -> TrainResult {
56 let mut model = KstemitModel::new(model_cfg);
57 let mut rng = train_cfg.seed;
58 let mut epoch_losses = Vec::with_capacity(train_cfg.epochs);
59 let n = dataset.samples.len();
60 assert!(n > 0, "empty dataset");
61
62 for _epoch in 0..train_cfg.epochs {
63 let mut order: Vec<usize> = (0..n).collect();
64 if train_cfg.shuffle {
65 for i in (1..n).rev() {
67 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
68 let j = (rng as usize) % (i + 1);
69 order.swap(i, j);
70 }
71 }
72 let mut loss_sum = 0.0f32;
73 for &si in &order {
74 let s = &dataset.samples[si];
75 let loss = model.train_step(
76 &dataset.graph,
77 s.node_idx,
78 s.label,
79 train_cfg.learning_rate,
80 train_cfg.weight_decay,
81 );
82 loss_sum += loss;
83 }
84 epoch_losses.push(loss_sum / n as f32);
85 }
86
87 let eval = evaluate(&model, dataset);
88 TrainResult {
89 model,
90 epoch_losses,
91 train_accuracy: eval.accuracy,
92 }
93}
94
95pub fn evaluate(model: &KstemitModel, dataset: &SyntheticDataset) -> EvalMetrics {
97 let num_classes = model.config.num_classes;
98 let mut confusion = vec![0usize; num_classes * num_classes];
99 let mut correct = 0usize;
100 let mut loss_sum = 0.0f32;
101 let n = dataset.samples.len();
102
103 for s in &dataset.samples {
104 let out = model.forward_node(&dataset.graph, s.node_idx);
105 loss_sum += super::layers::cross_entropy(&out.probs, s.label);
106 let pred = argmax(&out.logits);
107 if pred == s.label {
108 correct += 1;
109 }
110 if s.label < num_classes && pred < num_classes {
111 confusion[s.label * num_classes + pred] += 1;
112 }
113 }
114
115 EvalMetrics {
116 accuracy: if n == 0 {
117 0.0
118 } else {
119 correct as f32 / n as f32
120 },
121 mean_loss: if n == 0 { 0.0 } else { loss_sum / n as f32 },
122 n,
123 confusion,
124 num_classes,
125 }
126}
127
128fn argmax(v: &[f32]) -> usize {
129 let mut best_i = 0;
130 let mut best_v = f32::NEG_INFINITY;
131 for (i, &x) in v.iter().enumerate() {
132 if x > best_v {
133 best_v = x;
134 best_i = i;
135 }
136 }
137 best_i
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::sensor_graph::fixture::{generate_synthetic_array, SyntheticConfig};
144 use crate::sensor_graph::graph::PHYSICS_DIM;
145
146 #[test]
147 fn train_improves_over_chance_on_synthetic() {
148 let ds = generate_synthetic_array(SyntheticConfig {
149 n_buoys: 12,
150 series_len: 32,
151 connect_radius: 3.0,
152 noise: 0.04,
153 seed: 1234,
154 });
155 let model_cfg = KstemitConfig {
156 feature_dim: PHYSICS_DIM,
157 temporal_window: 16,
158 hidden: 12,
159 num_classes: 3,
160 physics_scale: 0.25,
161 seed: 3,
162 };
163 let train_cfg = TrainConfig {
164 epochs: 40,
165 learning_rate: 0.08,
166 weight_decay: 1e-4,
167 shuffle: true,
168 seed: 9,
169 };
170 let result = train_sgd(&ds, model_cfg, train_cfg);
171 assert!(result.epoch_losses.len() == 40);
172 let first = result.epoch_losses[0];
174 let last = *result.epoch_losses.last().unwrap();
175 assert!(
176 last < first,
177 "loss should drop: first={first:.4} last={last:.4}"
178 );
179 assert!(
181 result.train_accuracy > 0.45,
182 "acc={:.3} expected > 0.45",
183 result.train_accuracy
184 );
185 }
186
187 #[test]
188 fn evaluate_shapes() {
189 let ds = generate_synthetic_array(SyntheticConfig::default());
190 let model = KstemitModel::new(KstemitConfig::default());
191 let m = evaluate(&model, &ds);
192 assert_eq!(m.n, ds.samples.len());
193 assert_eq!(m.confusion.len(), 9);
194 }
195}