1use crate::NeuralResult;
8use scirs2_core::ndarray::{Array1, Array2};
9#[cfg(feature = "serde")]
10use serde::{de::DeserializeOwned, Deserialize, Serialize};
11use sklears_core::error::SklearsError;
12use std::collections::HashMap;
13use std::fs;
14#[cfg(feature = "mmap")]
15use std::io::Write;
16use std::path::Path;
17
18#[cfg(feature = "serde")]
19use serde_json;
20
21#[cfg(feature = "serde")]
22use oxicode;
23
24#[cfg(feature = "serde")]
25use chrono;
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum CheckpointFormat {
30 Json,
32 #[default]
34 Binary,
35 Compressed,
37 #[cfg(feature = "mmap")]
39 MemoryMapped,
40}
41
42#[cfg(feature = "serde")]
43fn serialize_to_binary<T>(value: &T) -> Result<Vec<u8>, oxicode::Error>
44where
45 T: Serialize,
46{
47 oxicode::serde::encode_to_vec(value, oxicode::config::standard())
48}
49
50#[cfg(feature = "serde")]
51fn deserialize_from_binary<T>(bytes: &[u8]) -> Result<T, oxicode::Error>
52where
53 T: DeserializeOwned,
54{
55 let (value, _bytes_read) =
56 oxicode::serde::decode_from_slice(bytes, oxicode::config::standard())?;
57 Ok(value)
58}
59
60#[derive(Debug, Clone)]
62#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
63pub struct ModelWeights {
64 pub weights: Vec<Array2<f64>>,
66 pub biases: Vec<Array1<f64>>,
68 pub layer_names: Option<Vec<String>>,
70}
71
72impl ModelWeights {
73 pub fn new(weights: Vec<Array2<f64>>, biases: Vec<Array1<f64>>) -> Self {
75 Self {
76 weights,
77 biases,
78 layer_names: None,
79 }
80 }
81
82 pub fn with_names(
84 weights: Vec<Array2<f64>>,
85 biases: Vec<Array1<f64>>,
86 layer_names: Vec<String>,
87 ) -> Self {
88 Self {
89 weights,
90 biases,
91 layer_names: Some(layer_names),
92 }
93 }
94
95 pub fn num_layers(&self) -> usize {
97 self.weights.len().min(self.biases.len())
98 }
99
100 pub fn validate(&self) -> NeuralResult<()> {
102 if self.weights.len() != self.biases.len() {
103 return Err(SklearsError::InvalidParameter {
104 name: "weights_biases".to_string(),
105 reason: format!(
106 "Number of weight matrices ({}) doesn't match number of bias vectors ({})",
107 self.weights.len(),
108 self.biases.len()
109 ),
110 });
111 }
112
113 for (i, (weight, bias)) in self.weights.iter().zip(self.biases.iter()).enumerate() {
114 if weight.ncols() != bias.len() {
115 return Err(SklearsError::ShapeMismatch {
116 expected: format!("weight.ncols()={}", weight.ncols()),
117 actual: format!("bias.len()={} at layer {}", bias.len(), i),
118 });
119 }
120 }
121
122 if let Some(ref names) = self.layer_names {
123 if names.len() != self.num_layers() {
124 return Err(SklearsError::InvalidParameter {
125 name: "layer_names".to_string(),
126 reason: format!(
127 "Number of layer names ({}) doesn't match number of layers ({})",
128 names.len(),
129 self.num_layers()
130 ),
131 });
132 }
133 }
134
135 Ok(())
136 }
137
138 pub fn total_parameters(&self) -> usize {
140 let weight_params: usize = self.weights.iter().map(|w| w.len()).sum();
141 let bias_params: usize = self.biases.iter().map(|b| b.len()).sum();
142 weight_params + bias_params
143 }
144}
145
146#[derive(Debug, Clone)]
148#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
149pub struct OptimizerState {
150 pub optimizer_type: String,
152 pub learning_rate: f64,
154 pub momentum_states: Option<Vec<Array2<f64>>>,
156 pub velocity_states: Option<Vec<Array2<f64>>>,
158 pub bias_correction: Option<HashMap<String, f64>>,
160 pub parameters: HashMap<String, f64>,
162 pub step_count: usize,
164}
165
166impl OptimizerState {
167 pub fn new(optimizer_type: String, learning_rate: f64) -> Self {
169 Self {
170 optimizer_type,
171 learning_rate,
172 momentum_states: None,
173 velocity_states: None,
174 bias_correction: None,
175 parameters: HashMap::new(),
176 step_count: 0,
177 }
178 }
179
180 pub fn with_momentum(mut self, momentum_states: Vec<Array2<f64>>) -> Self {
182 self.momentum_states = Some(momentum_states);
183 self
184 }
185
186 pub fn with_velocity(mut self, velocity_states: Vec<Array2<f64>>) -> Self {
188 self.velocity_states = Some(velocity_states);
189 self
190 }
191
192 pub fn with_bias_correction(mut self, bias_correction: HashMap<String, f64>) -> Self {
194 self.bias_correction = Some(bias_correction);
195 self
196 }
197
198 pub fn with_parameter(mut self, key: String, value: f64) -> Self {
200 self.parameters.insert(key, value);
201 self
202 }
203
204 pub fn with_step_count(mut self, step_count: usize) -> Self {
206 self.step_count = step_count;
207 self
208 }
209}
210
211#[derive(Debug, Clone)]
213#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
214pub struct TrainingHistory {
215 pub train_loss: Vec<f64>,
217 pub val_loss: Option<Vec<f64>>,
219 pub train_accuracy: Option<Vec<f64>>,
221 pub val_accuracy: Option<Vec<f64>>,
223 pub learning_rates: Vec<f64>,
225 pub epochs: Vec<usize>,
227 pub custom_metrics: HashMap<String, Vec<f64>>,
229}
230
231impl TrainingHistory {
232 pub fn new() -> Self {
234 Self {
235 train_loss: Vec::new(),
236 val_loss: None,
237 train_accuracy: None,
238 val_accuracy: None,
239 learning_rates: Vec::new(),
240 epochs: Vec::new(),
241 custom_metrics: HashMap::new(),
242 }
243 }
244
245 pub fn add_epoch(&mut self, epoch: usize, train_loss: f64, learning_rate: f64) {
247 self.epochs.push(epoch);
248 self.train_loss.push(train_loss);
249 self.learning_rates.push(learning_rate);
250 }
251
252 pub fn add_validation(&mut self, val_loss: f64, val_accuracy: Option<f64>) {
254 if self.val_loss.is_none() {
255 self.val_loss = Some(Vec::new());
256 }
257 self.val_loss
258 .as_mut()
259 .expect("val_loss not available")
260 .push(val_loss);
261
262 if let Some(acc) = val_accuracy {
263 if self.val_accuracy.is_none() {
264 self.val_accuracy = Some(Vec::new());
265 }
266 self.val_accuracy
267 .as_mut()
268 .expect("val_accuracy not available")
269 .push(acc);
270 }
271 }
272
273 pub fn add_custom_metric(&mut self, name: &str, value: f64) {
275 self.custom_metrics
276 .entry(name.to_string())
277 .or_default()
278 .push(value);
279 }
280
281 pub fn best_epoch(&self) -> Option<usize> {
283 self.val_loss.as_ref().and_then(|losses| {
284 losses
285 .iter()
286 .enumerate()
287 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
288 .map(|(idx, _)| self.epochs[idx])
289 })
290 }
291
292 pub fn is_improving(&self, patience: usize) -> bool {
294 if let Some(ref val_losses) = self.val_loss {
295 if val_losses.len() < patience {
296 return true; }
298
299 let recent_best = val_losses[val_losses.len() - patience..]
300 .iter()
301 .fold(f64::INFINITY, |a, &b| a.min(b));
302 let overall_best = val_losses.iter().fold(f64::INFINITY, |a, &b| a.min(b));
303
304 recent_best <= overall_best
305 } else {
306 if self.train_loss.len() < patience {
308 return true;
309 }
310
311 let recent_best = self.train_loss[self.train_loss.len() - patience..]
312 .iter()
313 .fold(f64::INFINITY, |a, &b| a.min(b));
314 let overall_best = self.train_loss.iter().fold(f64::INFINITY, |a, &b| a.min(b));
315
316 recent_best <= overall_best
317 }
318 }
319}
320
321impl Default for TrainingHistory {
322 fn default() -> Self {
323 Self::new()
324 }
325}
326
327#[derive(Debug, Clone)]
329#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
330pub struct ModelCheckpoint {
331 pub model_type: String,
333 pub weights: ModelWeights,
335 pub optimizer_state: Option<OptimizerState>,
337 pub training_history: TrainingHistory,
339 pub current_epoch: usize,
341 #[cfg(feature = "serde")]
343 pub hyperparameters: HashMap<String, serde_json::Value>,
344 pub metadata: CheckpointMetadata,
346 pub version: String,
348}
349
350#[derive(Debug, Clone)]
352#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
353pub struct CheckpointMetadata {
354 #[cfg(feature = "serde")]
356 pub created_at: chrono::DateTime<chrono::Utc>,
357 pub training_duration: Option<std::time::Duration>,
359 pub git_commit: Option<String>,
361 pub environment: HashMap<String, String>,
363 pub tags: Vec<String>,
365 pub description: Option<String>,
367}
368
369impl Default for CheckpointMetadata {
370 fn default() -> Self {
371 let mut environment = HashMap::new();
372 environment.insert(
373 "sklears_version".to_string(),
374 env!("CARGO_PKG_VERSION").to_string(),
375 );
376
377 #[cfg(feature = "serde")]
378 {
379 Self {
380 created_at: chrono::Utc::now(),
381 training_duration: None,
382 git_commit: None,
383 environment,
384 tags: Vec::new(),
385 description: None,
386 }
387 }
388 #[cfg(not(feature = "serde"))]
389 {
390 Self {
391 training_duration: None,
392 git_commit: None,
393 environment,
394 tags: Vec::new(),
395 description: None,
396 }
397 }
398 }
399}
400
401impl ModelCheckpoint {
402 pub fn new(model_type: String, weights: ModelWeights, current_epoch: usize) -> Self {
404 #[cfg(feature = "serde")]
405 {
406 Self {
407 model_type,
408 weights,
409 optimizer_state: None,
410 training_history: TrainingHistory::new(),
411 current_epoch,
412 hyperparameters: HashMap::new(),
413 metadata: CheckpointMetadata::default(),
414 version: env!("CARGO_PKG_VERSION").to_string(),
415 }
416 }
417 #[cfg(not(feature = "serde"))]
418 {
419 Self {
420 model_type,
421 weights,
422 optimizer_state: None,
423 training_history: TrainingHistory::new(),
424 current_epoch,
425 metadata: CheckpointMetadata::default(),
426 version: env!("CARGO_PKG_VERSION").to_string(),
427 }
428 }
429 }
430
431 pub fn with_optimizer(mut self, optimizer_state: OptimizerState) -> Self {
433 self.optimizer_state = Some(optimizer_state);
434 self
435 }
436
437 pub fn with_history(mut self, training_history: TrainingHistory) -> Self {
439 self.training_history = training_history;
440 self
441 }
442
443 #[cfg(feature = "serde")]
445 pub fn with_hyperparameters(
446 mut self,
447 hyperparameters: HashMap<String, serde_json::Value>,
448 ) -> Self {
449 self.hyperparameters = hyperparameters;
450 self
451 }
452
453 pub fn with_metadata(mut self, metadata: CheckpointMetadata) -> Self {
455 self.metadata = metadata;
456 self
457 }
458
459 pub fn validate(&self) -> NeuralResult<()> {
461 self.weights.validate()?;
462
463 if let Some(ref opt_state) = self.optimizer_state {
465 if let Some(ref momentum) = opt_state.momentum_states {
466 if momentum.len() != self.weights.num_layers() {
467 return Err(SklearsError::InvalidParameter {
468 name: "momentum_states".to_string(),
469 reason: "Momentum states count doesn't match number of layers".to_string(),
470 });
471 }
472 }
473
474 if let Some(ref velocity) = opt_state.velocity_states {
475 if velocity.len() != self.weights.num_layers() {
476 return Err(SklearsError::InvalidParameter {
477 name: "velocity_states".to_string(),
478 reason: "Velocity states count doesn't match number of layers".to_string(),
479 });
480 }
481 }
482 }
483
484 Ok(())
485 }
486}
487
488pub struct CheckpointManager {
490 pub checkpoint_dir: std::path::PathBuf,
492 pub format: CheckpointFormat,
494 pub max_checkpoints: Option<usize>,
496 pub naming_pattern: String,
498}
499
500impl CheckpointManager {
501 pub fn new<P: AsRef<Path>>(checkpoint_dir: P) -> NeuralResult<Self> {
503 let checkpoint_dir = checkpoint_dir.as_ref().to_path_buf();
504
505 if !checkpoint_dir.exists() {
507 fs::create_dir_all(&checkpoint_dir).map_err(|e| SklearsError::InvalidParameter {
508 name: "checkpoint_dir".to_string(),
509 reason: format!("Failed to create checkpoint directory: {}", e),
510 })?;
511 }
512
513 Ok(Self {
514 checkpoint_dir,
515 format: CheckpointFormat::default(),
516 max_checkpoints: Some(10),
517 naming_pattern: "checkpoint_epoch_{:04d}".to_string(),
518 })
519 }
520
521 pub fn with_format(mut self, format: CheckpointFormat) -> Self {
523 self.format = format;
524 self
525 }
526
527 pub fn with_max_checkpoints(mut self, max_checkpoints: Option<usize>) -> Self {
529 self.max_checkpoints = max_checkpoints;
530 self
531 }
532
533 pub fn with_naming_pattern(mut self, pattern: String) -> Self {
535 self.naming_pattern = pattern;
536 self
537 }
538
539 #[cfg(feature = "serde")]
541 pub fn save_checkpoint(
542 &self,
543 checkpoint: &ModelCheckpoint,
544 ) -> NeuralResult<std::path::PathBuf> {
545 checkpoint.validate()?;
546
547 let filename = self
548 .naming_pattern
549 .replace("{:04d}", &format!("{:04}", checkpoint.current_epoch));
550 let file_path = match self.format {
551 CheckpointFormat::Json => {
552 let path = self.checkpoint_dir.join(format!("{}.json", filename));
553 let json_data = serde_json::to_string_pretty(checkpoint).map_err(|e| {
554 SklearsError::InvalidParameter {
555 name: "checkpoint".to_string(),
556 reason: format!("Failed to serialize checkpoint to JSON: {}", e),
557 }
558 })?;
559 fs::write(&path, json_data).map_err(|e| SklearsError::InvalidParameter {
560 name: "file_path".to_string(),
561 reason: format!("Failed to write checkpoint file: {}", e),
562 })?;
563 path
564 }
565 CheckpointFormat::Binary => {
566 let path = self.checkpoint_dir.join(format!("{}.bin", filename));
567 let binary_data = serialize_to_binary(checkpoint).map_err(|e| {
568 SklearsError::InvalidParameter {
569 name: "checkpoint".to_string(),
570 reason: format!("Failed to serialize checkpoint to binary: {}", e),
571 }
572 })?;
573 fs::write(&path, binary_data).map_err(|e| SklearsError::InvalidParameter {
574 name: "file_path".to_string(),
575 reason: format!("Failed to write checkpoint file: {}", e),
576 })?;
577 path
578 }
579 CheckpointFormat::Compressed => {
580 let path = self.checkpoint_dir.join(format!("{}.bin.gz", filename));
581 let binary_data = serialize_to_binary(checkpoint).map_err(|e| {
582 SklearsError::InvalidParameter {
583 name: "checkpoint".to_string(),
584 reason: format!("Failed to serialize checkpoint: {}", e),
585 }
586 })?;
587
588 let compressed = oxiarc_deflate::gzip_compress(&binary_data, 6).map_err(|e| {
589 SklearsError::InvalidParameter {
590 name: "compression".to_string(),
591 reason: format!("Failed to compress checkpoint: {}", e),
592 }
593 })?;
594 fs::write(&path, &compressed).map_err(|e| SklearsError::InvalidParameter {
595 name: "file_path".to_string(),
596 reason: format!("Failed to write checkpoint file: {}", e),
597 })?;
598 path
599 }
600 #[cfg(feature = "mmap")]
601 CheckpointFormat::MemoryMapped => {
602 let path = self.checkpoint_dir.join(format!("{}.mmap", filename));
603 self.save_memory_mapped_checkpoint(checkpoint, &path)?;
604 path
605 }
606 };
607
608 if let Some(max_checkpoints) = self.max_checkpoints {
610 self.cleanup_old_checkpoints(max_checkpoints)?;
611 }
612
613 Ok(file_path)
614 }
615
616 #[cfg(feature = "serde")]
618 pub fn load_checkpoint<P: AsRef<Path>>(&self, file_path: P) -> NeuralResult<ModelCheckpoint> {
619 let file_path = file_path.as_ref();
620 let extension = file_path.extension().and_then(|s| s.to_str()).unwrap_or("");
621
622 let checkpoint: ModelCheckpoint = match extension {
623 "json" => {
624 let json_data =
625 fs::read_to_string(file_path).map_err(|e| SklearsError::InvalidParameter {
626 name: "file_path".to_string(),
627 reason: format!("Failed to read checkpoint file: {}", e),
628 })?;
629 serde_json::from_str(&json_data).map_err(|e| SklearsError::InvalidParameter {
630 name: "checkpoint".to_string(),
631 reason: format!("Failed to deserialize JSON checkpoint: {}", e),
632 })?
633 }
634 "bin" => {
635 let binary_data =
636 fs::read(file_path).map_err(|e| SklearsError::InvalidParameter {
637 name: "file_path".to_string(),
638 reason: format!("Failed to read checkpoint file: {}", e),
639 })?;
640 deserialize_from_binary(&binary_data).map_err(|e| {
641 SklearsError::InvalidParameter {
642 name: "checkpoint".to_string(),
643 reason: format!("Failed to deserialize binary checkpoint: {}", e),
644 }
645 })?
646 }
647 "gz" => {
648 let raw = fs::read(file_path).map_err(|e| SklearsError::InvalidParameter {
649 name: "file_path".to_string(),
650 reason: format!("Failed to read checkpoint file: {}", e),
651 })?;
652 let binary_data = oxiarc_deflate::gzip_decompress(&raw).map_err(|e| {
653 SklearsError::InvalidParameter {
654 name: "decompression".to_string(),
655 reason: format!("Failed to decompress checkpoint: {}", e),
656 }
657 })?;
658 deserialize_from_binary(&binary_data).map_err(|e| {
659 SklearsError::InvalidParameter {
660 name: "checkpoint".to_string(),
661 reason: format!("Failed to deserialize compressed checkpoint: {}", e),
662 }
663 })?
664 }
665 #[cfg(feature = "mmap")]
666 "mmap" => self.load_memory_mapped_checkpoint(file_path)?,
667 _ => {
668 return Err(SklearsError::InvalidParameter {
669 name: "file_extension".to_string(),
670 reason: format!("Unsupported checkpoint file extension: {}", extension),
671 });
672 }
673 };
674
675 checkpoint.validate()?;
676 Ok(checkpoint)
677 }
678
679 pub fn list_checkpoints(&self) -> NeuralResult<Vec<std::path::PathBuf>> {
681 let mut checkpoints = Vec::new();
682
683 let entries =
684 fs::read_dir(&self.checkpoint_dir).map_err(|e| SklearsError::InvalidParameter {
685 name: "checkpoint_dir".to_string(),
686 reason: format!("Failed to read checkpoint directory: {}", e),
687 })?;
688
689 for entry in entries {
690 let entry = entry.map_err(|e| SklearsError::InvalidParameter {
691 name: "directory_entry".to_string(),
692 reason: format!("Failed to read directory entry: {}", e),
693 })?;
694
695 let path = entry.path();
696 if path.is_file() {
697 if let Some(extension) = path.extension().and_then(|s| s.to_str()) {
698 if matches!(extension, "json" | "bin" | "gz") {
699 checkpoints.push(path);
700 }
701 }
702 }
703 }
704
705 checkpoints.sort_by_key(|path| {
707 fs::metadata(path)
708 .and_then(|metadata| metadata.modified())
709 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
710 });
711 checkpoints.reverse();
712
713 Ok(checkpoints)
714 }
715
716 pub fn get_latest_checkpoint(&self) -> NeuralResult<Option<std::path::PathBuf>> {
718 let checkpoints = self.list_checkpoints()?;
719 Ok(checkpoints.into_iter().next())
720 }
721
722 #[allow(dead_code)]
724 fn cleanup_old_checkpoints(&self, max_checkpoints: usize) -> NeuralResult<()> {
725 let checkpoints = self.list_checkpoints()?;
726
727 if checkpoints.len() > max_checkpoints {
728 for checkpoint_path in checkpoints.into_iter().skip(max_checkpoints) {
729 fs::remove_file(&checkpoint_path).map_err(|e| SklearsError::InvalidParameter {
730 name: "cleanup".to_string(),
731 reason: format!("Failed to remove old checkpoint: {}", e),
732 })?;
733 }
734 }
735
736 Ok(())
737 }
738
739 #[cfg(feature = "mmap")]
741 fn save_memory_mapped_checkpoint(
742 &self,
743 checkpoint: &ModelCheckpoint,
744 path: &Path,
745 ) -> NeuralResult<()> {
746 let binary_data =
748 serialize_to_binary(checkpoint).map_err(|e| SklearsError::InvalidParameter {
749 name: "checkpoint".to_string(),
750 reason: format!("Failed to serialize checkpoint: {}", e),
751 })?;
752
753 let mut file = fs::OpenOptions::new()
755 .read(true)
756 .write(true)
757 .create(true)
758 .truncate(true)
759 .open(path)
760 .map_err(|e| SklearsError::InvalidParameter {
761 name: "file_path".to_string(),
762 reason: format!("Failed to create memory-mapped file: {}", e),
763 })?;
764
765 file.write_all(&binary_data)
767 .map_err(|e| SklearsError::InvalidParameter {
768 name: "file_write".to_string(),
769 reason: format!("Failed to write data to file: {}", e),
770 })?;
771 file.flush().map_err(|e| SklearsError::InvalidParameter {
772 name: "file_flush".to_string(),
773 reason: format!("Failed to flush file: {}", e),
774 })?;
775
776 Ok(())
780 }
781
782 #[cfg(feature = "mmap")]
784 fn load_memory_mapped_checkpoint(&self, path: &Path) -> NeuralResult<ModelCheckpoint> {
785 use memmap2::Mmap;
786
787 let file = fs::File::open(path).map_err(|e| SklearsError::InvalidParameter {
789 name: "file_path".to_string(),
790 reason: format!("Failed to open memory-mapped file: {}", e),
791 })?;
792
793 let mmap = unsafe {
795 Mmap::map(&file).map_err(|e| SklearsError::InvalidParameter {
796 name: "memory_map".to_string(),
797 reason: format!("Failed to create memory map: {}", e),
798 })?
799 };
800
801 let checkpoint: ModelCheckpoint =
803 deserialize_from_binary(&mmap).map_err(|e| SklearsError::InvalidParameter {
804 name: "checkpoint".to_string(),
805 reason: format!("Failed to deserialize memory-mapped checkpoint: {}", e),
806 })?;
807
808 Ok(checkpoint)
809 }
810}
811
812pub trait Checkpointable {
814 fn to_checkpoint(&self, current_epoch: usize) -> NeuralResult<ModelCheckpoint>;
816
817 fn load_from_checkpoint(&mut self, checkpoint: &ModelCheckpoint) -> NeuralResult<()>;
820
821 fn model_type(&self) -> String;
823}
824
825#[allow(non_snake_case)]
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use approx::assert_abs_diff_eq;
830
831 #[test]
832 fn test_model_weights_creation() {
833 let weights = vec![
834 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
835 .expect("array shape mismatch"),
836 Array2::from_shape_vec((3, 1), vec![7.0, 8.0, 9.0]).expect("array shape mismatch"),
837 ];
838 let biases = vec![
839 Array1::from_vec(vec![0.1, 0.2, 0.3]),
840 Array1::from_vec(vec![0.4]),
841 ];
842
843 let model_weights = ModelWeights::new(weights, biases);
844 assert_eq!(model_weights.num_layers(), 2);
845 assert_eq!(model_weights.total_parameters(), 13); assert!(model_weights.validate().is_ok());
847 }
848
849 #[test]
850 fn test_model_weights_validation() {
851 let weights = vec![
852 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
853 .expect("array shape mismatch"),
854 ];
855 let biases = vec![
856 Array1::from_vec(vec![0.1, 0.2]), ];
858
859 let model_weights = ModelWeights::new(weights, biases);
860 assert!(model_weights.validate().is_err());
861 }
862
863 #[test]
864 fn test_optimizer_state() {
865 let opt_state = OptimizerState::new("Adam".to_string(), 0.001)
866 .with_step_count(100)
867 .with_parameter("beta1".to_string(), 0.9)
868 .with_parameter("beta2".to_string(), 0.999);
869
870 assert_eq!(opt_state.optimizer_type, "Adam");
871 assert_abs_diff_eq!(opt_state.learning_rate, 0.001, epsilon = 1e-10);
872 assert_eq!(opt_state.step_count, 100);
873 assert_abs_diff_eq!(opt_state.parameters["beta1"], 0.9, epsilon = 1e-10);
874 }
875
876 #[test]
877 fn test_training_history() {
878 let mut history = TrainingHistory::new();
879
880 history.add_epoch(0, 1.0, 0.01);
881 history.add_validation(0.9, Some(0.85));
882
883 history.add_epoch(1, 0.8, 0.01);
884 history.add_validation(0.7, Some(0.88));
885
886 history.add_custom_metric("f1_score", 0.82);
887 history.add_custom_metric("f1_score", 0.86);
888
889 assert_eq!(history.epochs.len(), 2);
890 assert_eq!(history.train_loss, vec![1.0, 0.8]);
891 assert_eq!(history.val_loss, Some(vec![0.9, 0.7]));
892 assert_eq!(history.best_epoch(), Some(1)); assert!(history.is_improving(2));
894 assert_eq!(history.custom_metrics["f1_score"], vec![0.82, 0.86]);
895 }
896
897 #[test]
898 fn test_checkpoint_creation() {
899 let weights = vec![
900 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
901 .expect("array shape mismatch"),
902 ];
903 let biases = vec![Array1::from_vec(vec![0.1, 0.2, 0.3])];
904 let model_weights = ModelWeights::new(weights, biases);
905
906 let checkpoint = ModelCheckpoint::new("MLP".to_string(), model_weights, 10);
907
908 assert_eq!(checkpoint.model_type, "MLP");
909 assert_eq!(checkpoint.current_epoch, 10);
910 assert!(checkpoint.validate().is_ok());
911 }
912
913 #[cfg(feature = "serde")]
914 #[test]
915 fn test_checkpoint_manager() {
916 use std::env;
917
918 let temp_dir = env::temp_dir().join("sklears_checkpoint_test");
919 let manager = CheckpointManager::new(&temp_dir).expect("construction should succeed");
920
921 assert!(temp_dir.exists());
922 assert_eq!(manager.checkpoint_dir, temp_dir);
923
924 let _ = fs::remove_dir_all(&temp_dir);
926 }
927
928 #[test]
929 #[cfg(all(feature = "mmap", feature = "serde"))]
930 fn test_memory_mapped_checkpoint() {
931 use std::env;
932
933 let temp_dir = env::temp_dir().join("sklears_mmap_test");
934 let checkpoint_manager = CheckpointManager::new(&temp_dir)
935 .expect("operation should succeed")
936 .with_format(CheckpointFormat::MemoryMapped);
937
938 let weights = vec![
940 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
941 .expect("array shape mismatch"),
942 Array2::from_shape_vec((3, 1), vec![7.0, 8.0, 9.0]).expect("array shape mismatch"),
943 ];
944 let biases = vec![
945 Array1::from_vec(vec![0.1, 0.2, 0.3]),
946 Array1::from_vec(vec![0.4]),
947 ];
948 let model_weights = ModelWeights::new(weights, biases);
949
950 let optimizer_state = OptimizerState::new("Adam".to_string(), 0.001).with_step_count(100);
951
952 let checkpoint = ModelCheckpoint::new("MLP".to_string(), model_weights, 42)
953 .with_optimizer(optimizer_state);
954
955 let saved_path = checkpoint_manager
957 .save_checkpoint(&checkpoint)
958 .expect("operation should succeed");
959 assert!(saved_path.exists());
960 assert_eq!(
961 saved_path.extension().expect("operation should succeed"),
962 "mmap"
963 );
964
965 let loaded_checkpoint = checkpoint_manager
967 .load_checkpoint(&saved_path)
968 .expect("operation should succeed");
969
970 assert_eq!(loaded_checkpoint.current_epoch, 42);
972 assert_eq!(loaded_checkpoint.weights.num_layers(), 2);
973 assert_eq!(loaded_checkpoint.weights.total_parameters(), 13);
974
975 if let Some(ref opt_state) = loaded_checkpoint.optimizer_state {
976 assert_eq!(opt_state.optimizer_type, "Adam");
977 assert_abs_diff_eq!(opt_state.learning_rate, 0.001, epsilon = 1e-10);
978 assert_eq!(opt_state.step_count, 100);
979 } else {
980 panic!("Optimizer state should be present");
981 }
982
983 assert_abs_diff_eq!(
985 loaded_checkpoint.weights.weights[0][[0, 0]],
986 1.0,
987 epsilon = 1e-10
988 );
989 assert_abs_diff_eq!(
990 loaded_checkpoint.weights.weights[0][[1, 2]],
991 6.0,
992 epsilon = 1e-10
993 );
994 assert_abs_diff_eq!(loaded_checkpoint.weights.biases[0][1], 0.2, epsilon = 1e-10);
995
996 let _ = fs::remove_dir_all(&temp_dir);
998 }
999}