1use crate::error::{OptimError, Result};
7use scirs2_core::ndarray::{Array1, Array2};
8use scirs2_core::numeric::Float;
9use std::collections::HashMap;
10use std::fmt::Debug;
11
12pub mod actor_critic;
13pub mod linear_models;
14pub mod natural_gradients;
15pub mod policy_gradient;
16pub mod trust_region;
17
18pub use actor_critic::{ActorCriticConfig, ActorCriticMethod, ActorCriticOptimizer};
20pub use linear_models::{
21 LinearGaussianPolicy, LinearQFunction, LinearSoftmaxPolicy, LinearValueFunction,
22};
23pub use natural_gradients::{NaturalGradientConfig, NaturalPolicyGradient};
24pub use policy_gradient::{PolicyGradientConfig, PolicyGradientMethod, PolicyGradientOptimizer};
25pub use trust_region::{TrustRegionConfig, TrustRegionMethod, TrustRegionOptimizer};
26
27#[derive(Debug, Clone)]
29pub struct RLOptimizerConfig<T: Float + Debug + Send + Sync + 'static> {
30 pub policy_lr: T,
32
33 pub value_lr: T,
35
36 pub discount_factor: T,
38
39 pub gae_lambda: T,
41
42 pub clip_epsilon: T,
44
45 pub entropy_coeff: T,
47
48 pub value_loss_coeff: T,
50
51 pub max_grad_norm: T,
53
54 pub n_epochs: usize,
56
57 pub mini_batchsize: usize,
59
60 pub trust_region_config: Option<TrustRegionConfig<T>>,
62
63 pub use_natural_gradients: bool,
65
66 pub fisher_approximation: FisherApproximationMethod,
68}
69
70#[derive(Debug, Clone, Copy)]
72pub enum FisherApproximationMethod {
73 Empirical,
75
76 KroneckerFactored,
78
79 Diagonal,
81
82 BlockDiagonal,
84
85 LowRank,
87}
88
89impl<T: Float + Debug + Send + Sync + 'static> Default for RLOptimizerConfig<T> {
90 fn default() -> Self {
91 Self {
92 policy_lr: T::from(3e-4).unwrap_or_else(|| T::zero()),
93 value_lr: T::from(1e-3).unwrap_or_else(|| T::zero()),
94 discount_factor: T::from(0.99).unwrap_or_else(|| T::zero()),
95 gae_lambda: T::from(0.95).unwrap_or_else(|| T::zero()),
96 clip_epsilon: T::from(0.2).unwrap_or_else(|| T::zero()),
97 entropy_coeff: T::from(0.01).unwrap_or_else(|| T::zero()),
98 value_loss_coeff: T::from(0.5).unwrap_or_else(|| T::zero()),
99 max_grad_norm: T::from(0.5).unwrap_or_else(|| T::zero()),
100 n_epochs: 4,
101 mini_batchsize: 64,
102 trust_region_config: None,
103 use_natural_gradients: false,
104 fisher_approximation: FisherApproximationMethod::Diagonal,
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct TrajectoryBatch<T: Float + Debug + Send + Sync + 'static> {
112 pub observations: Array2<T>,
114
115 pub actions: Array2<T>,
117
118 pub log_probs: Array1<T>,
120
121 pub rewards: Array1<T>,
123
124 pub values: Array1<T>,
126
127 pub dones: Array1<bool>,
129
130 pub advantages: Array1<T>,
132
133 pub returns: Array1<T>,
135
136 pub final_observation: Option<Array1<T>>,
150}
151
152impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive>
153 TrajectoryBatch<T>
154{
155 pub fn new(
157 observations: Array2<T>,
158 actions: Array2<T>,
159 log_probs: Array1<T>,
160 rewards: Array1<T>,
161 values: Array1<T>,
162 dones: Array1<bool>,
163 ) -> Result<Self> {
164 let batch_size = observations.nrows();
165
166 if actions.nrows() != batch_size
168 || log_probs.len() != batch_size
169 || rewards.len() != batch_size
170 || values.len() != batch_size
171 || dones.len() != batch_size
172 {
173 return Err(OptimError::InvalidConfig(
174 "Inconsistent batch dimensions".to_string(),
175 ));
176 }
177
178 let advantages = Array1::zeros(batch_size);
180 let returns = Array1::zeros(batch_size);
181
182 Ok(Self {
183 observations,
184 actions,
185 log_probs,
186 rewards,
187 values,
188 dones,
189 advantages,
190 returns,
191 final_observation: None,
192 })
193 }
194
195 pub fn with_final_observation(mut self, final_observation: Array1<T>) -> Result<Self> {
200 let expected = self.observations.ncols();
201 if final_observation.len() != expected {
202 return Err(OptimError::DimensionMismatch(format!(
203 "final observation length ({}) does not match observation dimension ({})",
204 final_observation.len(),
205 expected
206 )));
207 }
208 self.final_observation = Some(final_observation);
209 Ok(self)
210 }
211
212 pub fn compute_gae(&mut self, gamma: T, lambda: T, nextvalue: T) -> Result<()> {
226 let batch_size = self.rewards.len();
227 if batch_size == 0 {
228 return Ok(());
229 }
230 let mut gae = T::zero();
231
232 for t in (0..batch_size).rev() {
233 let nonterminal = if self.dones[t] { T::zero() } else { T::one() };
235
236 let next_val = if t == batch_size - 1 {
237 nextvalue
238 } else {
239 self.values[t + 1]
240 };
241
242 let delta = self.rewards[t] + gamma * next_val * nonterminal - self.values[t];
243 gae = delta + gamma * lambda * nonterminal * gae;
244
245 self.advantages[t] = gae;
246 self.returns[t] = gae + self.values[t];
247 }
248
249 Ok(())
250 }
251
252 pub fn compute_advantages(&mut self, gamma: T, lambda: T, nextvalue: T) -> Result<()> {
259 self.compute_gae(gamma, lambda, nextvalue)?;
260
261 if self.advantages.len() < 2 {
262 return Ok(());
263 }
264
265 let mean = self.advantages.mean().unwrap_or(T::zero());
266 let std = self
267 .advantages
268 .mapv(|x| (x - mean) * (x - mean))
269 .mean()
270 .unwrap_or(T::one())
271 .sqrt();
272
273 if std > T::from(1e-8).unwrap_or_else(|| T::zero()) {
274 self.advantages.mapv_inplace(|x| (x - mean) / std);
275 }
276
277 Ok(())
278 }
279
280 pub fn compute_discounted_returns(&mut self, gamma: T, nextvalue: T) -> Result<()> {
288 let batch_size = self.rewards.len();
289 if batch_size == 0 {
290 return Ok(());
291 }
292
293 let mut running = nextvalue;
294 for t in (0..batch_size).rev() {
295 let nonterminal = if self.dones[t] { T::zero() } else { T::one() };
296 running = self.rewards[t] + gamma * nonterminal * running;
297 self.returns[t] = running;
298 self.advantages[t] = running - self.values[t];
299 }
300
301 Ok(())
302 }
303
304 pub fn get_mini_batches(&self, mini_batchsize: usize) -> Vec<TrajectoryBatch<T>> {
306 let batch_size = self.observations.nrows();
307 let n_mini_batches = batch_size.div_ceil(mini_batchsize);
308
309 let mut mini_batches = Vec::new();
310
311 for i in 0..n_mini_batches {
312 let start = i * mini_batchsize;
313 let end = ((i + 1) * mini_batchsize).min(batch_size);
314
315 if start >= end {
316 break;
317 }
318
319 let obs = self.observations.slice(s![start..end, ..]).to_owned();
320 let acts = self.actions.slice(s![start..end, ..]).to_owned();
321 let log_probs = self.log_probs.slice(s![start..end]).to_owned();
322 let rewards = self.rewards.slice(s![start..end]).to_owned();
323 let values = self.values.slice(s![start..end]).to_owned();
324 let dones = self.dones.slice(s![start..end]).to_owned().to_vec();
325 let advantages = self.advantages.slice(s![start..end]).to_owned();
326 let returns = self.returns.slice(s![start..end]).to_owned();
327
328 let dones_array = Array1::from_vec(dones);
330
331 let final_observation = if end < batch_size {
335 Some(self.observations.row(end).to_owned())
336 } else {
337 self.final_observation.clone()
338 };
339
340 let mini_batch = TrajectoryBatch {
341 observations: obs,
342 actions: acts,
343 log_probs,
344 rewards,
345 values,
346 dones: dones_array,
347 advantages,
348 returns,
349 final_observation,
350 };
351
352 mini_batches.push(mini_batch);
353 }
354
355 mini_batches
356 }
357}
358
359#[derive(Debug, Clone)]
370pub struct KroneckerBlock<T: Float + Debug + Send + Sync + 'static> {
371 pub name: String,
373
374 pub inputs: Array2<T>,
376
377 pub outputs: Array2<T>,
379}
380
381pub fn parameter_count<T: Float + Debug + Send + Sync + 'static>(
383 params: &HashMap<String, Array1<T>>,
384) -> usize {
385 params.values().map(|p| p.len()).sum()
386}
387
388pub fn parameter_keys<T: Float + Debug + Send + Sync + 'static>(
390 params: &HashMap<String, Array1<T>>,
391) -> Vec<String> {
392 let mut keys: Vec<String> = params.keys().cloned().collect();
393 keys.sort();
394 keys
395}
396
397pub fn flatten_named<T: Float + Debug + Send + Sync + 'static>(
400 params: &HashMap<String, Array1<T>>,
401) -> Array1<T> {
402 let mut flat = Array1::zeros(parameter_count(params));
403 let mut offset = 0usize;
404 for key in parameter_keys(params) {
405 let value = ¶ms[&key];
406 for (i, &v) in value.iter().enumerate() {
407 flat[offset + i] = v;
408 }
409 offset += value.len();
410 }
411 flat
412}
413
414pub fn unflatten_named<T: Float + Debug + Send + Sync + 'static>(
420 template: &HashMap<String, Array1<T>>,
421 flat: &Array1<T>,
422) -> Result<HashMap<String, Array1<T>>> {
423 let total = parameter_count(template);
424 if total != flat.len() {
425 return Err(OptimError::DimensionMismatch(format!(
426 "Flat vector length ({}) does not match total parameter count ({})",
427 flat.len(),
428 total
429 )));
430 }
431
432 let keys = parameter_keys(template);
433 let mut out: HashMap<String, Array1<T>> = HashMap::with_capacity(keys.len());
434 let mut offset = 0usize;
435 for key in keys {
436 let len = template[&key].len();
437 let mut chunk = Array1::zeros(len);
438 for i in 0..len {
439 chunk[i] = flat[offset + i];
440 }
441 out.insert(key, chunk);
442 offset += len;
443 }
444 Ok(out)
445}
446
447pub fn clip_named_gradients<T: Float + Debug + Send + Sync + 'static>(
452 gradients: &HashMap<String, Array1<T>>,
453 max_norm: T,
454) -> (HashMap<String, Array1<T>>, T) {
455 let mut total = T::zero();
456 for grad in gradients.values() {
457 for &g in grad.iter() {
458 total = total + g * g;
459 }
460 }
461 let norm = total.sqrt();
462
463 let factor = if max_norm > T::zero() && norm > max_norm && norm > T::zero() {
464 max_norm / norm
465 } else {
466 T::one()
467 };
468
469 let clipped = gradients
470 .iter()
471 .map(|(name, grad)| (name.clone(), grad.mapv(|g| g * factor)))
472 .collect();
473
474 (clipped, norm)
475}
476
477pub fn scale_named_gradients<T: Float + Debug + Send + Sync + 'static>(
479 gradients: &HashMap<String, Array1<T>>,
480 factor: T,
481) -> HashMap<String, Array1<T>> {
482 gradients
483 .iter()
484 .map(|(name, grad)| (name.clone(), grad.mapv(|g| g * factor)))
485 .collect()
486}
487
488pub fn add_named_gradients<T: Float + Debug + Send + Sync + 'static>(
493 base: &mut HashMap<String, Array1<T>>,
494 addend: HashMap<String, Array1<T>>,
495) -> Result<()> {
496 for (name, grad) in addend {
497 match base.get_mut(&name) {
498 Some(target) => {
499 if target.len() != grad.len() {
500 return Err(OptimError::DimensionMismatch(format!(
501 "gradient '{name}' has length {} in one term and {} in the other",
502 target.len(),
503 grad.len()
504 )));
505 }
506 for i in 0..target.len() {
507 target[i] = target[i] + grad[i];
508 }
509 }
510 None => {
511 base.insert(name, grad);
512 }
513 }
514 }
515 Ok(())
516}
517
518pub trait PolicyNetwork<T: Float + Debug + Send + Sync + 'static> {
537 fn evaluate_actions(
539 &self,
540 observations: &Array2<T>,
541 actions: &Array2<T>,
542 ) -> Result<PolicyEvaluation<T>>;
543
544 fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>>;
546
547 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()>;
549
550 fn get_parameters(&self) -> HashMap<String, Array1<T>>;
552
553 fn log_prob_gradient(
564 &self,
565 observations: &Array2<T>,
566 actions: &Array2<T>,
567 coefficients: &Array1<T>,
568 ) -> Result<HashMap<String, Array1<T>>> {
569 let _ = (observations, actions, coefficients);
570 Err(OptimError::UnsupportedOperation(
571 "PolicyNetwork::log_prob_gradient is not implemented for this policy; \
572 policy-gradient updates require an analytic (or autodiff) score function"
573 .to_string(),
574 ))
575 }
576
577 fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
581 let _ = observations;
582 Err(OptimError::UnsupportedOperation(
583 "PolicyNetwork::entropy_gradient is not implemented for this policy; \
584 set entropy_coeff = 0 or provide an analytic entropy gradient"
585 .to_string(),
586 ))
587 }
588
589 fn mean_action_gradient(
597 &self,
598 observations: &Array2<T>,
599 weights: &Array2<T>,
600 ) -> Result<HashMap<String, Array1<T>>> {
601 let _ = (observations, weights);
602 Err(OptimError::UnsupportedOperation(
603 "PolicyNetwork::mean_action_gradient is not implemented for this policy; \
604 deterministic-policy-gradient updates (DDPG/TD3/SAC actor) require it"
605 .to_string(),
606 ))
607 }
608
609 fn score_matrix(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
617 let n = observations.nrows();
618 let dim = parameter_count(&self.get_parameters());
619 let mut scores = Array2::zeros((n, dim));
620
621 let one = Array1::from_elem(1, T::one());
622 for i in 0..n {
623 let obs_i = observations.slice(s![i..i + 1, ..]).to_owned();
624 let act_i = actions.slice(s![i..i + 1, ..]).to_owned();
625 let grad = self.log_prob_gradient(&obs_i, &act_i, &one)?;
626 let flat = flatten_named(&grad);
627 if flat.len() != dim {
628 return Err(OptimError::DimensionMismatch(format!(
629 "score vector length ({}) does not match parameter count ({})",
630 flat.len(),
631 dim
632 )));
633 }
634 for j in 0..dim {
635 scores[[i, j]] = flat[j];
636 }
637 }
638
639 Ok(scores)
640 }
641
642 fn kronecker_factors(
648 &self,
649 observations: &Array2<T>,
650 actions: &Array2<T>,
651 ) -> Result<Vec<KroneckerBlock<T>>> {
652 let _ = (observations, actions);
653 Err(OptimError::UnsupportedOperation(
654 "PolicyNetwork::kronecker_factors is not implemented for this policy; \
655 Kronecker-factored Fisher estimation requires per-layer factors"
656 .to_string(),
657 ))
658 }
659}
660
661impl<T: Float + Debug + Send + Sync + 'static, P: PolicyNetwork<T> + ?Sized> PolicyNetwork<T>
669 for &mut P
670{
671 fn evaluate_actions(
672 &self,
673 observations: &Array2<T>,
674 actions: &Array2<T>,
675 ) -> Result<PolicyEvaluation<T>> {
676 (**self).evaluate_actions(observations, actions)
677 }
678
679 fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>> {
680 (**self).get_action_distribution(observations)
681 }
682
683 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
684 (**self).update_parameters(deltas)
685 }
686
687 fn get_parameters(&self) -> HashMap<String, Array1<T>> {
688 (**self).get_parameters()
689 }
690
691 fn log_prob_gradient(
692 &self,
693 observations: &Array2<T>,
694 actions: &Array2<T>,
695 coefficients: &Array1<T>,
696 ) -> Result<HashMap<String, Array1<T>>> {
697 (**self).log_prob_gradient(observations, actions, coefficients)
698 }
699
700 fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
701 (**self).entropy_gradient(observations)
702 }
703
704 fn mean_action_gradient(
705 &self,
706 observations: &Array2<T>,
707 weights: &Array2<T>,
708 ) -> Result<HashMap<String, Array1<T>>> {
709 (**self).mean_action_gradient(observations, weights)
710 }
711
712 fn score_matrix(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
713 (**self).score_matrix(observations, actions)
714 }
715
716 fn kronecker_factors(
717 &self,
718 observations: &Array2<T>,
719 actions: &Array2<T>,
720 ) -> Result<Vec<KroneckerBlock<T>>> {
721 (**self).kronecker_factors(observations, actions)
722 }
723}
724
725pub trait ValueNetwork<T: Float + Debug + Send + Sync + 'static> {
730 fn evaluate_value(&self, observations: &Array2<T>) -> Result<Array1<T>>;
732
733 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()>;
735
736 fn get_parameters(&self) -> HashMap<String, Array1<T>>;
738
739 fn value_gradient(
745 &self,
746 observations: &Array2<T>,
747 residuals: &Array1<T>,
748 ) -> Result<HashMap<String, Array1<T>>> {
749 let _ = (observations, residuals);
750 Err(OptimError::UnsupportedOperation(
751 "ValueNetwork::value_gradient is not implemented for this network; \
752 value-function updates require an analytic (or autodiff) gradient"
753 .to_string(),
754 ))
755 }
756}
757
758pub trait QNetwork<T: Float + Debug + Send + Sync + 'static>: ValueNetwork<T> {
769 fn evaluate_q(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array1<T>>;
771
772 fn q_gradient(
775 &self,
776 states: &Array2<T>,
777 actions: &Array2<T>,
778 residuals: &Array1<T>,
779 ) -> Result<HashMap<String, Array1<T>>> {
780 let _ = (states, actions, residuals);
781 Err(OptimError::UnsupportedOperation(
782 "QNetwork::q_gradient is not implemented for this critic".to_string(),
783 ))
784 }
785
786 fn action_gradient(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
791 let _ = (states, actions);
792 Err(OptimError::UnsupportedOperation(
793 "QNetwork::action_gradient is not implemented for this critic; \
794 the deterministic policy gradient requires ∇_a Q(s, a)"
795 .to_string(),
796 ))
797 }
798}
799
800#[derive(Debug, Clone)]
802pub struct PolicyEvaluation<T: Float + Debug + Send + Sync + 'static> {
803 pub log_probs: Array1<T>,
805
806 pub entropy: Array1<T>,
808
809 pub metrics: HashMap<String, T>,
811}
812
813#[derive(Debug, Clone)]
815pub struct ActionDistribution<T: Float + Debug + Send + Sync + 'static> {
816 pub mean: Option<Array2<T>>,
818
819 pub std: Option<Array2<T>>,
821
822 pub logits: Option<Array2<T>>,
824
825 pub distribution_type: DistributionType,
827}
828
829#[derive(Debug, Clone, Copy)]
831pub enum DistributionType {
832 Gaussian,
834
835 Categorical,
837
838 Beta,
840
841 Mixed,
843}
844
845#[derive(Debug, Clone)]
847pub struct RLScheduler<T: Float + Debug + Send + Sync + 'static> {
848 pub initiallr: T,
850
851 pub current_lr: T,
853
854 pub decay_factor: T,
856
857 pub schedule: ScheduleType,
859
860 pub update_count: usize,
862
863 pub schedule_params: HashMap<String, T>,
865}
866
867#[derive(Debug, Clone, Copy)]
869pub enum ScheduleType {
870 Constant,
872
873 Linear,
875
876 Exponential,
878
879 Cosine,
881
882 Step,
884
885 Adaptive,
887}
888
889impl<T: Float + Debug + Send + Sync + 'static> RLScheduler<T> {
890 pub fn new(initiallr: T, schedule: ScheduleType) -> Self {
892 Self {
893 initiallr,
894 current_lr: initiallr,
895 decay_factor: T::from(0.99).unwrap_or_else(|| T::zero()),
896 schedule,
897 update_count: 0,
898 schedule_params: HashMap::new(),
899 }
900 }
901
902 pub fn step(&mut self) -> T {
904 self.update_count += 1;
905
906 match self.schedule {
907 ScheduleType::Constant => {
908 }
910 ScheduleType::Linear => {
911 let decay_steps = self
912 .schedule_params
913 .get("decay_steps")
914 .copied()
915 .unwrap_or(T::from(10000).unwrap_or_else(|| T::zero()));
916 let progress =
917 T::from(self.update_count).unwrap_or_else(|| T::zero()) / decay_steps;
918 self.current_lr = self.initiallr * (T::one() - progress).max(T::zero());
919 }
920 ScheduleType::Exponential => {
921 self.current_lr = self.current_lr * self.decay_factor;
922 }
923 ScheduleType::Step => {
924 let step_size = self
925 .schedule_params
926 .get("step_size")
927 .copied()
928 .unwrap_or(T::from(1000).unwrap_or_else(|| T::zero()));
929 if T::from(self.update_count).unwrap_or_else(|| T::zero()) % step_size == T::zero()
930 {
931 self.current_lr = self.current_lr * self.decay_factor;
932 }
933 }
934 ScheduleType::Cosine => {
935 let max_steps = self
936 .schedule_params
937 .get("max_steps")
938 .copied()
939 .unwrap_or(T::from(10000).unwrap_or_else(|| T::zero()));
940 let progress = T::from(self.update_count).unwrap_or_else(|| T::zero()) / max_steps;
941 let pi = T::from(std::f64::consts::PI).unwrap_or_else(|| T::zero());
942 self.current_lr = self.initiallr * (T::one() + (pi * progress).cos())
943 / T::from(2).unwrap_or_else(|| T::zero());
944 }
945 ScheduleType::Adaptive => {
946 }
949 }
950
951 self.current_lr
952 }
953
954 pub fn get_lr(&self) -> T {
956 self.current_lr
957 }
958
959 pub fn set_param(&mut self, key: &str, value: T) {
961 self.schedule_params.insert(key.to_string(), value);
962 }
963}
964
965#[derive(Debug, Clone)]
967pub struct RLOptimizationMetrics<T: Float + Debug + Send + Sync + 'static> {
968 pub policy_loss: T,
970
971 pub value_loss: T,
973
974 pub entropy_loss: T,
976
977 pub total_loss: T,
979
980 pub kl_divergence: Option<T>,
982
983 pub explained_variance: T,
985
986 pub clip_fraction: Option<T>,
988
989 pub policy_lr: T,
991 pub value_lr: T,
992
993 pub policy_grad_norm: T,
995 pub value_grad_norm: T,
996
997 pub custom_metrics: HashMap<String, T>,
999}
1000
1001impl<T: Float + Debug + Send + Sync + 'static> Default for RLOptimizationMetrics<T> {
1002 fn default() -> Self {
1003 Self {
1004 policy_loss: T::zero(),
1005 value_loss: T::zero(),
1006 entropy_loss: T::zero(),
1007 total_loss: T::zero(),
1008 kl_divergence: None,
1009 explained_variance: T::zero(),
1010 clip_fraction: None,
1011 policy_lr: T::from(3e-4).unwrap_or_else(|| T::zero()),
1012 value_lr: T::from(1e-3).unwrap_or_else(|| T::zero()),
1013 policy_grad_norm: T::zero(),
1014 value_grad_norm: T::zero(),
1015 custom_metrics: HashMap::new(),
1016 }
1017 }
1018}
1019
1020use scirs2_core::ndarray::s;
1022