torsh_nn/core/module_ext.rs
1//! Module trait ergonomic extensions
2//!
3//! This module provides additional ergonomic helpers and utilities for the Module trait,
4//! following Rust best practices for trait extension patterns.
5
6use crate::Module;
7use torsh_core::device::DeviceType;
8use torsh_core::error::Result;
9use torsh_tensor::Tensor;
10
11#[cfg(feature = "std")]
12use std::collections::HashMap;
13
14#[cfg(not(feature = "std"))]
15use hashbrown::HashMap;
16
17/// Extension trait providing additional ergonomic methods for Module
18///
19/// This trait is automatically implemented for all types that implement Module,
20/// providing additional convenience methods without requiring changes to existing code.
21///
22/// # Design Philosophy
23///
24/// This extension follows Rust's extension trait pattern to:
25/// - Add functionality without breaking backward compatibility
26/// - Keep the core Module trait focused on essential methods
27/// - Provide advanced features for users who need them
28/// - Enable fluent/builder-style APIs
29pub trait ModuleExt: Module {
30 // === Fluent API / Builder Pattern Methods ===
31
32 /// Chain forward pass with a transformation function
33 ///
34 /// This enables functional-style chaining of operations.
35 ///
36 /// # Arguments
37 /// * `input` - Input tensor
38 /// * `f` - Transformation function to apply to output
39 ///
40 /// # Returns
41 /// * `Result<Tensor>` - Transformed output
42 ///
43 /// # Example
44 /// ```ignore
45 /// let output = layer.and_then(&input, |x| x.relu())?;
46 /// ```
47 fn and_then<F>(&self, input: &Tensor, f: F) -> Result<Tensor>
48 where
49 F: FnOnce(Tensor) -> Result<Tensor>,
50 {
51 let output = self.forward(input)?;
52 f(output)
53 }
54
55 /// Apply module and map the output with a function
56 ///
57 /// Similar to `and_then` but for non-failable transformations.
58 ///
59 /// # Arguments
60 /// * `input` - Input tensor
61 /// * `f` - Mapping function
62 ///
63 /// # Returns
64 /// * `Result<Tensor>` - Mapped output
65 fn map<F>(&self, input: &Tensor, f: F) -> Result<Tensor>
66 where
67 F: FnOnce(Tensor) -> Tensor,
68 {
69 let output = self.forward(input)?;
70 Ok(f(output))
71 }
72
73 /// Forward pass with input transformation
74 ///
75 /// Apply a transformation to the input before forwarding.
76 ///
77 /// # Arguments
78 /// * `input` - Input tensor
79 /// * `f` - Input transformation function
80 ///
81 /// # Returns
82 /// * `Result<Tensor>` - Module output
83 fn with_input<F>(&self, input: &Tensor, f: F) -> Result<Tensor>
84 where
85 F: FnOnce(&Tensor) -> Result<Tensor>,
86 {
87 let transformed = f(input)?;
88 self.forward(&transformed)
89 }
90
91 // === Inspection and Debugging Methods ===
92
93 /// Get human-readable summary of the module
94 ///
95 /// # Returns
96 /// * `String` - Formatted module summary
97 fn summary(&self) -> String {
98 let info = self.module_info();
99 format!(
100 "Module: {}\n\
101 Training: {}\n\
102 Parameters: {} ({} trainable)\n\
103 Memory: {:.2} MB\n\
104 Children: {}",
105 info.name,
106 info.training,
107 info.parameter_count,
108 info.trainable_parameter_count,
109 info.memory_usage_bytes as f64 / (1024.0 * 1024.0),
110 info.children_count
111 )
112 }
113
114 /// Print module summary to stdout
115 fn print_summary(&self) {
116 println!("{}", self.summary());
117 }
118
119 /// Get parameter statistics
120 ///
121 /// # Returns
122 /// * `ParameterStats` - Statistical information about parameters
123 fn parameter_stats(&self) -> ParameterStats {
124 let params = self.all_parameters();
125 let mut total_params = 0;
126 let mut trainable_params = 0;
127 let mut frozen_params = 0;
128 let mut total_memory = 0;
129
130 for param in params.values() {
131 let numel = param.numel().unwrap_or(0);
132 total_params += numel;
133 total_memory += numel * 4; // Assume f32
134
135 if param.requires_grad() {
136 trainable_params += numel;
137 } else {
138 frozen_params += numel;
139 }
140 }
141
142 ParameterStats {
143 total_parameters: total_params,
144 trainable_parameters: trainable_params,
145 frozen_parameters: frozen_params,
146 total_memory_bytes: total_memory,
147 parameter_count: params.len(),
148 }
149 }
150
151 /// Check if module has NaN or Inf in parameters
152 ///
153 /// # Returns
154 /// * `bool` - true if all parameters are finite
155 fn has_finite_parameters(&self) -> bool {
156 self.all_parameters()
157 .values()
158 .all(|p| p.is_finite().unwrap_or(false))
159 }
160
161 /// Get list of parameter names
162 ///
163 /// # Returns
164 /// * `Vec<String>` - Sorted list of parameter names
165 fn parameter_names(&self) -> Vec<String> {
166 let mut names: Vec<String> = self.all_named_parameters().keys().cloned().collect();
167 names.sort();
168 names
169 }
170
171 /// Get parameter by name
172 ///
173 /// # Arguments
174 /// * `name` - Parameter name
175 ///
176 /// # Returns
177 /// * `Option<Parameter>` - Parameter if found
178 fn get_parameter(&self, name: &str) -> Option<crate::Parameter> {
179 self.all_named_parameters().get(name).cloned()
180 }
181
182 // === Training Utilities ===
183
184 /// Freeze parameters whose name contains `pattern` (sets `requires_grad = false`)
185 ///
186 /// Pass an empty pattern (`""`) to freeze every parameter, since every name
187 /// contains the empty string.
188 ///
189 /// # Arguments
190 /// * `pattern` - Substring to match against parameter names
191 ///
192 /// # Returns
193 /// * `usize` - Number of parameters frozen
194 ///
195 /// # Note
196 /// A parameter's `requires_grad` flag is shared across clones (see
197 /// [`crate::Parameter`]), so updating the by-value clones handed out by
198 /// [`Module::all_named_parameters`] is observable on the module's own
199 /// parameters and on any subsequently fetched copies.
200 fn freeze_matching(&mut self, pattern: &str) -> usize {
201 let mut count = 0;
202 for (name, param) in self.all_named_parameters() {
203 if name.contains(pattern) {
204 param.set_requires_grad(false);
205 count += 1;
206 }
207 }
208 count
209 }
210
211 /// Unfreeze parameters whose name contains `pattern` (sets `requires_grad = true`)
212 ///
213 /// Pass an empty pattern (`""`) to unfreeze every parameter, since every name
214 /// contains the empty string.
215 ///
216 /// # Arguments
217 /// * `pattern` - Substring to match against parameter names
218 ///
219 /// # Returns
220 /// * `usize` - Number of parameters unfrozen
221 ///
222 /// # Note
223 /// See [`ModuleExt::freeze_matching`] for why mutating the by-value parameter
224 /// clones is observable on the owning module.
225 fn unfreeze_matching(&mut self, pattern: &str) -> usize {
226 let mut count = 0;
227 for (name, param) in self.all_named_parameters() {
228 if name.contains(pattern) {
229 param.set_requires_grad(true);
230 count += 1;
231 }
232 }
233 count
234 }
235
236 /// Get list of frozen parameters
237 ///
238 /// # Returns
239 /// * `Vec<String>` - Names of frozen parameters
240 fn frozen_parameters(&self) -> Vec<String> {
241 self.all_named_parameters()
242 .into_iter()
243 .filter(|(_, p)| !p.requires_grad())
244 .map(|(name, _)| name)
245 .collect()
246 }
247
248 /// Get list of trainable parameters
249 ///
250 /// # Returns
251 /// * `Vec<String>` - Names of trainable parameters
252 fn trainable_parameters(&self) -> Vec<String> {
253 self.all_named_parameters()
254 .into_iter()
255 .filter(|(_, p)| p.requires_grad())
256 .map(|(name, _)| name)
257 .collect()
258 }
259
260 // === Advanced Operations ===
261
262 /// Clone module parameters into a new state dict
263 ///
264 /// # Returns
265 /// * `HashMap<String, Tensor>` - Cloned state dictionary
266 fn clone_state_dict(&self) -> HashMap<String, Tensor> {
267 self.state_dict()
268 }
269
270 /// Apply a function to all parameters
271 ///
272 /// # Arguments
273 /// * `f` - Function to apply to each parameter
274 fn apply_to_parameters<F>(&self, mut f: F)
275 where
276 F: FnMut(&str, &crate::Parameter),
277 {
278 for (name, param) in self.all_named_parameters() {
279 f(&name, ¶m);
280 }
281 }
282
283 /// Count parameters by layer type
284 ///
285 /// # Returns
286 /// * `HashMap<String, usize>` - Parameter count per layer type
287 fn parameters_by_type(&self) -> HashMap<String, usize> {
288 let mut counts = HashMap::new();
289
290 for (name, param) in self.all_named_parameters() {
291 // Extract layer type from name (first component)
292 let layer_type = name.split('.').next().unwrap_or("unknown").to_string();
293
294 let numel = param.numel().unwrap_or(0);
295 *counts.entry(layer_type).or_insert(0) += numel;
296 }
297
298 counts
299 }
300
301 /// Validate module configuration
302 ///
303 /// Performs comprehensive validation of module state.
304 ///
305 /// # Returns
306 /// * `Result<ValidationReport>` - Validation results
307 fn validate(&self) -> Result<ValidationReport> {
308 let mut report = ValidationReport::default();
309
310 // Check for parameters
311 if !self.has_parameters() {
312 report.warnings.push("Module has no parameters".to_string());
313 }
314
315 // Check for finite parameters
316 if !self.has_finite_parameters() {
317 report
318 .errors
319 .push("Module has non-finite parameters (NaN or Inf)".to_string());
320 }
321
322 // Check memory usage
323 let memory_mb = self.memory_usage_mb();
324 if memory_mb > 1024.0 {
325 report
326 .warnings
327 .push(format!("Large memory usage: {:.2} GB", memory_mb / 1024.0));
328 }
329
330 // Check parameter count
331 let param_count = self.num_parameters();
332 if param_count > 100_000_000 {
333 report
334 .warnings
335 .push(format!("Very large model: {} parameters", param_count));
336 }
337
338 report.is_valid = report.errors.is_empty();
339 Ok(report)
340 }
341
342 /// Get the device the module's parameters reside on
343 ///
344 /// # Returns
345 /// * `Option<DeviceType>` - The device of the module's parameters, or `None`
346 /// only when the module genuinely has no parameters (recursively).
347 ///
348 /// # Note
349 /// The device is read from the actual underlying parameter tensors. If a
350 /// module's parameters span multiple devices, the device of the first
351 /// parameter encountered is reported.
352 fn device(&self) -> Option<DeviceType> {
353 self.all_parameters().values().next().map(|p| p.device())
354 }
355
356 /// Check if all parameters are on CPU
357 ///
358 /// # Returns
359 /// * `bool` - true if all parameters on CPU
360 fn is_cpu(&self) -> bool {
361 self.device() == Some(DeviceType::Cpu)
362 }
363
364 /// Check if all parameters are on CUDA device
365 ///
366 /// # Returns
367 /// * `bool` - true if all parameters on CUDA
368 fn is_cuda(&self) -> bool {
369 matches!(self.device(), Some(DeviceType::Cuda(_)))
370 }
371}
372
373// Automatically implement ModuleExt for all types that implement Module
374impl<T: Module + ?Sized> ModuleExt for T {}
375
376// === Supporting Types ===
377
378/// Parameter statistics for a module
379#[derive(Debug, Clone)]
380pub struct ParameterStats {
381 /// Total number of parameter elements
382 pub total_parameters: usize,
383 /// Number of trainable parameter elements
384 pub trainable_parameters: usize,
385 /// Number of frozen parameter elements
386 pub frozen_parameters: usize,
387 /// Total memory usage in bytes
388 pub total_memory_bytes: usize,
389 /// Number of distinct parameters
390 pub parameter_count: usize,
391}
392
393impl ParameterStats {
394 /// Get memory usage in megabytes
395 pub fn memory_mb(&self) -> f64 {
396 self.total_memory_bytes as f64 / (1024.0 * 1024.0)
397 }
398
399 /// Get memory usage in gigabytes
400 pub fn memory_gb(&self) -> f64 {
401 self.memory_mb() / 1024.0
402 }
403
404 /// Get percentage of parameters that are trainable
405 pub fn trainable_percentage(&self) -> f64 {
406 if self.total_parameters == 0 {
407 0.0
408 } else {
409 (self.trainable_parameters as f64 / self.total_parameters as f64) * 100.0
410 }
411 }
412}
413
414/// Validation report for a module
415#[derive(Debug, Clone, Default)]
416pub struct ValidationReport {
417 /// Whether the module is valid
418 pub is_valid: bool,
419 /// List of errors found
420 pub errors: Vec<String>,
421 /// List of warnings
422 pub warnings: Vec<String>,
423}
424
425impl ValidationReport {
426 /// Check if validation passed without errors
427 pub fn passed(&self) -> bool {
428 self.is_valid && self.errors.is_empty()
429 }
430
431 /// Get total number of issues (errors + warnings)
432 pub fn issue_count(&self) -> usize {
433 self.errors.len() + self.warnings.len()
434 }
435
436 /// Format as human-readable string
437 pub fn format(&self) -> String {
438 let mut result = String::new();
439
440 result.push_str(&format!(
441 "Validation: {}\n",
442 if self.is_valid { "PASSED" } else { "FAILED" }
443 ));
444
445 if !self.errors.is_empty() {
446 result.push_str("\nErrors:\n");
447 for error in &self.errors {
448 result.push_str(&format!(" - {}\n", error));
449 }
450 }
451
452 if !self.warnings.is_empty() {
453 result.push_str("\nWarnings:\n");
454 for warning in &self.warnings {
455 result.push_str(&format!(" - {}\n", warning));
456 }
457 }
458
459 result
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::ModuleExt;
466 use crate::layers::Linear;
467 use crate::Module;
468 use torsh_core::device::DeviceType;
469 use torsh_core::error::Result;
470 use torsh_tensor::Tensor;
471
472 /// A module that genuinely owns no parameters, used to verify that `device()`
473 /// honestly reports `None` instead of a fabricated default.
474 struct EmptyModule;
475
476 impl Module for EmptyModule {
477 fn forward(&self, input: &Tensor) -> Result<Tensor> {
478 Ok(input.clone())
479 }
480 }
481
482 #[test]
483 fn test_freeze_unfreeze_sets_requires_grad() {
484 let mut linear = Linear::new(4, 3, true);
485
486 // A Linear(.., bias = true) layer exposes exactly two parameters.
487 let param_count = linear.all_parameters().len();
488 assert_eq!(
489 param_count, 2,
490 "Linear with bias should expose weight + bias"
491 );
492
493 // Freshly created parameters must require gradients.
494 assert!(
495 linear.all_parameters().values().all(|p| p.requires_grad()),
496 "new Linear parameters should require grad"
497 );
498
499 // Freeze every parameter (empty pattern matches all names).
500 let frozen = linear.freeze_matching("");
501 assert_eq!(
502 frozen, param_count,
503 "freeze_matching(\"\") must touch every parameter"
504 );
505
506 // The freeze must be observable on a *fresh* read of the parameters,
507 // proving the flag really mutated shared state (not a throwaway clone).
508 assert!(
509 linear.all_parameters().values().all(|p| !p.requires_grad()),
510 "after freeze no parameter may require grad"
511 );
512 assert_eq!(
513 linear.frozen_parameters().len(),
514 param_count,
515 "all parameters should be reported as frozen"
516 );
517 assert!(
518 linear.trainable_parameters().is_empty(),
519 "no parameter should be trainable after freeze"
520 );
521
522 // Unfreeze every parameter and verify it sticks.
523 let unfrozen = linear.unfreeze_matching("");
524 assert_eq!(unfrozen, param_count);
525 assert!(
526 linear.all_parameters().values().all(|p| p.requires_grad()),
527 "after unfreeze every parameter must require grad"
528 );
529 assert!(linear.frozen_parameters().is_empty());
530 }
531
532 #[test]
533 fn test_freeze_matching_only_affects_matching_names() {
534 let mut linear = Linear::new(4, 3, true);
535
536 // Freeze only the bias; the weight must stay trainable.
537 let frozen = linear.freeze_matching("bias");
538 assert_eq!(frozen, 1, "only the bias parameter should match");
539
540 let params = linear.all_named_parameters();
541 assert!(
542 !params["bias"].requires_grad(),
543 "bias must be frozen after freeze_matching(\"bias\")"
544 );
545 assert!(
546 params["weight"].requires_grad(),
547 "weight must remain trainable"
548 );
549 }
550
551 #[test]
552 fn test_device_reports_real_parameter_device() {
553 let linear = Linear::new(8, 5, true);
554
555 // Parameters are created on CPU, so the module device must be CPU.
556 assert_eq!(linear.device(), Some(DeviceType::Cpu));
557 assert!(linear.is_cpu());
558 assert!(!linear.is_cuda());
559 }
560
561 #[test]
562 fn test_device_is_none_without_parameters() {
563 let empty = EmptyModule;
564 // A parameter-less module must honestly report None, never a fake device.
565 assert_eq!(empty.device(), None);
566 assert!(!empty.is_cpu());
567 assert!(!empty.is_cuda());
568 }
569}