1use super::{
8 clip_named_gradients, flatten_named, scale_named_gradients, PolicyNetwork,
9 RLOptimizationMetrics, RLOptimizerConfig, RLScheduler, ScheduleType, TrajectoryBatch,
10 TrustRegionConfig, TrustRegionMethod, TrustRegionOptimizer, ValueNetwork,
11};
12use crate::error::{OptimError, Result};
13use scirs2_core::ndarray::{Array1, Array2, ScalarOperand};
14use scirs2_core::numeric::Float;
15use std::fmt::Debug;
16
17#[derive(Debug, Clone, Copy)]
19pub enum PolicyGradientMethod {
20 Reinforce,
22
23 ActorCritic,
25
26 PPOClip,
28
29 PPOAdaptiveKL,
31
32 TRPO,
34
35 IMPALA,
37
38 A3C,
40}
41
42#[derive(Debug, Clone)]
44pub struct PolicyGradientConfig<T: Float + Debug + Send + Sync + 'static> {
45 pub base_config: RLOptimizerConfig<T>,
47
48 pub method: PolicyGradientMethod,
50
51 pub ppo_config: PPOConfig<T>,
53
54 pub trpo_config: TRPOConfig<T>,
56
57 pub policy_scheduler: Option<RLScheduler<T>>,
59
60 pub value_scheduler: Option<RLScheduler<T>>,
62
63 pub use_baseline: bool,
65
66 pub importance_sampling: bool,
68
69 pub max_is_ratio: T,
71}
72
73#[derive(Debug, Clone)]
75pub struct PPOConfig<T: Float + Debug + Send + Sync + 'static> {
76 pub clip_epsilon: T,
78
79 pub dual_clip: bool,
81
82 pub value_clip: bool,
84
85 pub value_clip_range: T,
87
88 pub target_kl: T,
90
91 pub kl_coeff: T,
93
94 pub kl_coeff_adapt_factor: T,
96
97 pub early_stop_on_kl: bool,
99}
100
101#[derive(Debug, Clone)]
103pub struct TRPOConfig<T: Float + Debug + Send + Sync + 'static> {
104 pub max_kl: T,
106
107 pub backtrack_factor: T,
109 pub max_backtracks: usize,
110
111 pub cg_iters: usize,
113 pub cg_damping: T,
114 pub cg_tolerance: T,
115
116 pub use_natural_gradients: bool,
118}
119
120impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive> Default
121 for PolicyGradientConfig<T>
122{
123 fn default() -> Self {
124 Self {
125 base_config: RLOptimizerConfig::default(),
126 method: PolicyGradientMethod::PPOClip,
127 ppo_config: PPOConfig::default(),
128 trpo_config: TRPOConfig::default(),
129 policy_scheduler: Some(RLScheduler::new(
130 T::from(3e-4).unwrap_or_else(|| T::zero()),
131 ScheduleType::Constant,
132 )),
133 value_scheduler: Some(RLScheduler::new(
134 T::from(1e-3).unwrap_or_else(|| T::zero()),
135 ScheduleType::Constant,
136 )),
137 use_baseline: true,
138 importance_sampling: false,
139 max_is_ratio: T::from(2.0).unwrap_or_else(|| T::zero()),
140 }
141 }
142}
143
144impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive> Default
145 for PPOConfig<T>
146{
147 fn default() -> Self {
148 Self {
149 clip_epsilon: T::from(0.2).unwrap_or_else(|| T::zero()),
150 dual_clip: false,
151 value_clip: true,
152 value_clip_range: T::from(0.2).unwrap_or_else(|| T::zero()),
153 target_kl: T::from(0.01).unwrap_or_else(|| T::zero()),
154 kl_coeff: T::from(0.2).unwrap_or_else(|| T::zero()),
155 kl_coeff_adapt_factor: T::from(1.5).unwrap_or_else(|| T::zero()),
156 early_stop_on_kl: true,
157 }
158 }
159}
160
161impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive> Default
162 for TRPOConfig<T>
163{
164 fn default() -> Self {
165 Self {
166 max_kl: T::from(0.01).unwrap_or_else(|| T::zero()),
167 backtrack_factor: T::from(0.5).unwrap_or_else(|| T::zero()),
168 max_backtracks: 10,
169 cg_iters: 10,
170 cg_damping: T::from(0.1).unwrap_or_else(|| T::zero()),
171 cg_tolerance: T::from(1e-8).unwrap_or_else(|| T::zero()),
172 use_natural_gradients: true,
173 }
174 }
175}
176
177pub struct PolicyGradientOptimizer<
179 T: Float + Debug + Send + Sync + 'static,
180 P: PolicyNetwork<T>,
181 V: ValueNetwork<T>,
182> {
183 config: PolicyGradientConfig<T>,
185
186 policy_network: P,
188
189 value_network: Option<V>,
191
192 policy_scheduler: Option<RLScheduler<T>>,
194 value_scheduler: Option<RLScheduler<T>>,
195
196 metrics: RLOptimizationMetrics<T>,
198
199 update_count: usize,
201
202 kl_coeff: T,
204
205 trajectory_buffer: Vec<TrajectoryBatch<T>>,
207
208 max_buffer_size: usize,
210}
211
212impl<
213 T: Float
214 + Debug
215 + Send
216 + Sync
217 + 'static
218 + ScalarOperand
219 + std::ops::AddAssign
220 + std::iter::Sum
221 + scirs2_core::numeric::FromPrimitive,
222 P: PolicyNetwork<T>,
223 V: ValueNetwork<T>,
224 > PolicyGradientOptimizer<T, P, V>
225{
226 pub fn new(
228 config: PolicyGradientConfig<T>,
229 policy_network: P,
230 value_network: Option<V>,
231 ) -> Self {
232 let kl_coeff = config.ppo_config.kl_coeff;
233 let policy_scheduler = config.policy_scheduler.clone();
234 let value_scheduler = config.value_scheduler.clone();
235
236 Self {
237 config,
238 policy_network,
239 value_network,
240 policy_scheduler,
241 value_scheduler,
242 metrics: RLOptimizationMetrics::default(),
243 update_count: 0,
244 kl_coeff,
245 trajectory_buffer: Vec::new(),
246 max_buffer_size: 1000,
247 }
248 }
249
250 pub fn update(&mut self, trajectory: TrajectoryBatch<T>) -> Result<RLOptimizationMetrics<T>> {
252 match self.config.method {
253 PolicyGradientMethod::PPOClip => self.update_ppo_clip(trajectory),
254 PolicyGradientMethod::PPOAdaptiveKL => self.update_ppo_adaptive_kl(trajectory),
255 PolicyGradientMethod::TRPO => self.update_trpo(trajectory),
256 PolicyGradientMethod::Reinforce => self.update_reinforce(trajectory),
257 PolicyGradientMethod::ActorCritic => self.update_actor_critic(trajectory),
258 PolicyGradientMethod::A3C => self.update_actor_critic(trajectory),
264 PolicyGradientMethod::IMPALA => self.update_impala(trajectory),
268 }
269 }
270
271 fn bootstrap_next_value(&self, trajectory: &TrajectoryBatch<T>) -> Result<T> {
282 let (Some(value_net), Some(final_obs)) = (
283 self.value_network.as_ref(),
284 trajectory.final_observation.as_ref(),
285 ) else {
286 return Ok(T::zero());
287 };
288
289 let mut batch = Array2::zeros((1, final_obs.len()));
290 batch.row_mut(0).assign(final_obs);
291 Ok(value_net.evaluate_value(&batch)?[0])
292 }
293
294 fn policy_lr(&self) -> T {
296 self.policy_scheduler
297 .as_ref()
298 .map(|s| s.get_lr())
299 .unwrap_or(self.config.base_config.policy_lr)
300 }
301
302 fn value_lr(&self) -> T {
304 self.value_scheduler
305 .as_ref()
306 .map(|s| s.get_lr())
307 .unwrap_or(self.config.base_config.value_lr)
308 }
309
310 fn prepare_gae(&self, trajectory: &mut TrajectoryBatch<T>) -> Result<()> {
315 let next_value = self.bootstrap_next_value(trajectory)?;
316 trajectory.compute_advantages(
317 self.config.base_config.discount_factor,
318 self.config.base_config.gae_lambda,
319 next_value,
320 )
321 }
322
323 fn update_ppo_clip(
332 &mut self,
333 mut trajectory: TrajectoryBatch<T>,
334 ) -> Result<RLOptimizationMetrics<T>> {
335 let mut total_policy_loss = T::zero();
336 let mut total_value_loss = T::zero();
337 let mut total_entropy_loss = T::zero();
338 let mut clip_fraction = T::zero();
339 let mut approx_kl = T::zero();
340 let mut n_updates = 0usize;
343
344 self.prepare_gae(&mut trajectory)?;
346
347 let n_epochs = self.config.base_config.n_epochs;
348 let mini_batch_size = self.config.base_config.mini_batchsize;
349 if mini_batch_size == 0 {
350 return Err(OptimError::InvalidConfig(
351 "mini_batchsize must be greater than zero".to_string(),
352 ));
353 }
354
355 let clip_eps = self.config.ppo_config.clip_epsilon;
356 let target_kl = self.config.ppo_config.target_kl;
357 let two = T::one() + T::one();
358 let half = T::one() / two;
359
360 'epochs: for _epoch in 0..n_epochs {
361 for mini_batch in trajectory.get_mini_batches(mini_batch_size) {
362 let batch_len = mini_batch.observations.nrows();
363 if batch_len == 0 {
364 continue;
365 }
366 let count = T::from(batch_len).ok_or_else(|| {
367 OptimError::ComputationError(
368 "failed to convert mini-batch size to scalar".to_string(),
369 )
370 })?;
371 let inv_n = T::one() / count;
372
373 let policy_eval = self
374 .policy_network
375 .evaluate_actions(&mini_batch.observations, &mini_batch.actions)?;
376
377 let log_ratio = &policy_eval.log_probs - &mini_batch.log_probs;
378 let ratio = log_ratio.mapv(|x| x.exp());
379
380 let mut policy_loss = T::zero();
381 let mut dloss_dlogp = Array1::zeros(batch_len);
382 let mut n_clipped = 0usize;
383
384 for i in 0..batch_len {
385 let r = ratio[i];
386 let advantage = mini_batch.advantages[i];
387 let clipped_r = r.max(T::one() - clip_eps).min(T::one() + clip_eps);
388
389 let surr1 = r * advantage;
390 let surr2 = clipped_r * advantage;
391
392 let (objective, dobjective) = if surr1 <= surr2 {
393 (surr1, r * advantage)
395 } else {
396 let inside = r > T::one() - clip_eps && r < T::one() + clip_eps;
399 (surr2, if inside { r * advantage } else { T::zero() })
400 };
401
402 policy_loss = policy_loss - objective * inv_n;
403 dloss_dlogp[i] = -dobjective * inv_n;
404
405 if r < T::one() - clip_eps || r > T::one() + clip_eps {
406 n_clipped += 1;
407 }
408 }
409
410 let entropy_loss = Self::entropy_loss(&policy_eval);
411
412 self.apply_policy_gradient_step(
415 &mini_batch.observations,
416 &mini_batch.actions,
417 &dloss_dlogp,
418 )?;
419 let value_loss = self.update_value_on_batch(
420 &mini_batch.observations,
421 &mini_batch.values,
422 &mini_batch.returns,
423 )?;
424
425 total_policy_loss += policy_loss;
426 total_value_loss += value_loss;
427 total_entropy_loss += entropy_loss;
428 n_updates += 1;
429
430 clip_fraction += T::from(n_clipped).unwrap_or_else(T::zero) * inv_n;
431
432 let batch_kl = half * log_ratio.mapv(|x| x * x).mean().unwrap_or(T::zero());
434 approx_kl += batch_kl;
435
436 if self.config.ppo_config.early_stop_on_kl && batch_kl > target_kl * two {
440 break 'epochs;
441 }
442 }
443 }
444
445 if let Some(ref mut scheduler) = self.policy_scheduler {
447 self.metrics.policy_lr = scheduler.step();
448 }
449 if let Some(ref mut scheduler) = self.value_scheduler {
450 self.metrics.value_lr = scheduler.step();
451 }
452
453 self.update_count += 1;
454
455 let divisor = T::from(n_updates.max(1)).unwrap_or_else(T::one);
456 self.metrics.policy_loss = total_policy_loss / divisor;
457 self.metrics.value_loss = total_value_loss / divisor;
458 self.metrics.entropy_loss = total_entropy_loss / divisor;
459 self.metrics.total_loss = self.metrics.policy_loss
460 + self.config.base_config.value_loss_coeff * self.metrics.value_loss
461 + self.config.base_config.entropy_coeff * self.metrics.entropy_loss;
462 self.metrics.clip_fraction = Some(clip_fraction / divisor);
463 self.metrics.kl_divergence = Some(approx_kl / divisor);
464
465 Ok(self.metrics.clone())
466 }
467
468 fn update_ppo_adaptive_kl(
488 &mut self,
489 mut trajectory: TrajectoryBatch<T>,
490 ) -> Result<RLOptimizationMetrics<T>> {
491 let mut total_policy_loss = T::zero();
492 let mut total_value_loss = T::zero();
493 let mut total_entropy_loss = T::zero();
494 let mut approx_kl = T::zero();
495
496 let target_kl = self.config.ppo_config.target_kl;
498 let one_point_five = T::from(1.5).unwrap_or_else(|| T::one());
499 let two = T::from(2.0).unwrap_or_else(|| T::one() + T::one());
500 let beta_min = T::from(1e-4).unwrap_or_else(|| T::zero());
502 let beta_max = T::from(1e4).unwrap_or_else(|| T::one());
503
504 self.prepare_gae(&mut trajectory)?;
506
507 let n_epochs = self.config.base_config.n_epochs;
508 let mini_batch_size = self.config.base_config.mini_batchsize;
509 if mini_batch_size == 0 {
510 return Err(OptimError::InvalidConfig(
511 "mini_batchsize must be greater than zero".to_string(),
512 ));
513 }
514
515 let mut last_epoch_kl = T::zero();
517 let mut n_updates = 0usize;
518
519 'epochs: for _epoch in 0..n_epochs {
520 let mini_batches = trajectory.get_mini_batches(mini_batch_size);
521 let mut epoch_kl_sum = T::zero();
522 let mut epoch_batches = T::zero();
523
524 for mini_batch in mini_batches {
525 let batch_len = mini_batch.observations.nrows();
526 if batch_len == 0 {
527 continue;
528 }
529 let batch_count = T::from(batch_len).ok_or_else(|| {
530 OptimError::ComputationError(
531 "failed to convert mini-batch size to scalar".to_string(),
532 )
533 })?;
534 let inv_n = T::one() / batch_count;
535
536 let policy_eval = self
538 .policy_network
539 .evaluate_actions(&mini_batch.observations, &mini_batch.actions)?;
540
541 let log_ratio = &policy_eval.log_probs - &mini_batch.log_probs;
543 let ratio = log_ratio.mapv(|x| x.exp());
544
545 let surrogate =
547 (&ratio * &mini_batch.advantages).iter().copied().sum::<T>() / batch_count;
548
549 let mut kl_sum = T::zero();
552 for &lr in log_ratio.iter() {
553 kl_sum = kl_sum - lr;
554 }
555 let batch_kl = (kl_sum / batch_count).max(T::zero());
556
557 let policy_loss = -surrogate + self.kl_coeff * batch_kl;
559
560 let mut dloss_dlogp = Array1::zeros(batch_len);
563 for i in 0..batch_len {
564 dloss_dlogp[i] = (-ratio[i] * mini_batch.advantages[i] - self.kl_coeff) * inv_n;
565 }
566
567 let entropy_loss = Self::entropy_loss(&policy_eval);
568
569 self.apply_policy_gradient_step(
570 &mini_batch.observations,
571 &mini_batch.actions,
572 &dloss_dlogp,
573 )?;
574 let value_loss = self.update_value_on_batch(
575 &mini_batch.observations,
576 &mini_batch.values,
577 &mini_batch.returns,
578 )?;
579
580 total_policy_loss += policy_loss;
582 total_value_loss += value_loss;
583 total_entropy_loss += entropy_loss;
584 n_updates += 1;
585
586 approx_kl += batch_kl;
587 epoch_kl_sum += batch_kl;
588 epoch_batches += T::one();
589
590 if self.config.ppo_config.early_stop_on_kl
592 && batch_kl > self.config.ppo_config.target_kl * two
593 {
594 last_epoch_kl = epoch_kl_sum / epoch_batches;
595 break 'epochs;
596 }
597 }
598
599 if epoch_batches > T::zero() {
601 last_epoch_kl = epoch_kl_sum / epoch_batches;
602 }
603 }
604
605 if last_epoch_kl > one_point_five * target_kl {
607 self.kl_coeff = self.kl_coeff * two;
608 } else if last_epoch_kl < target_kl / one_point_five {
609 self.kl_coeff = self.kl_coeff / two;
610 }
611 if self.kl_coeff < beta_min {
613 self.kl_coeff = beta_min;
614 } else if self.kl_coeff > beta_max {
615 self.kl_coeff = beta_max;
616 }
617
618 if let Some(ref mut scheduler) = self.policy_scheduler {
620 self.metrics.policy_lr = scheduler.step();
621 }
622 if let Some(ref mut scheduler) = self.value_scheduler {
623 self.metrics.value_lr = scheduler.step();
624 }
625
626 self.update_count += 1;
627
628 let divisor = T::from(n_updates.max(1)).unwrap_or_else(T::one);
630 self.metrics.policy_loss = total_policy_loss / divisor;
631 self.metrics.value_loss = total_value_loss / divisor;
632 self.metrics.entropy_loss = total_entropy_loss / divisor;
633 self.metrics.total_loss = self.metrics.policy_loss
634 + self.config.base_config.value_loss_coeff * self.metrics.value_loss
635 + self.config.base_config.entropy_coeff * self.metrics.entropy_loss;
636 self.metrics.clip_fraction = None;
638 self.metrics.kl_divergence = Some(approx_kl / divisor);
639 self.metrics
641 .custom_metrics
642 .insert("kl_coeff".to_string(), self.kl_coeff);
643
644 Ok(self.metrics.clone())
645 }
646
647 fn update_trpo(
660 &mut self,
661 mut trajectory: TrajectoryBatch<T>,
662 ) -> Result<RLOptimizationMetrics<T>> {
663 self.prepare_gae(&mut trajectory)?;
664
665 let batch_len = trajectory.observations.nrows();
666 if batch_len == 0 {
667 return Err(OptimError::InvalidConfig(
668 "TRPO received an empty trajectory".to_string(),
669 ));
670 }
671 let count = T::from(batch_len).ok_or_else(|| {
672 OptimError::ComputationError("failed to convert batch size to scalar".to_string())
673 })?;
674 let inv_n = T::one() / count;
675
676 let value_loss = self.update_value_on_batch(
678 &trajectory.observations,
679 &trajectory.values,
680 &trajectory.returns,
681 )?;
682
683 let coefficients = trajectory.advantages.mapv(|a| a * inv_n);
685 let gradient_map = self.policy_network.log_prob_gradient(
686 &trajectory.observations,
687 &trajectory.actions,
688 &coefficients,
689 )?;
690 let flat_gradient = flatten_named(&gradient_map);
691 let grad_norm = flat_gradient.iter().map(|&g| g * g).sum::<T>().sqrt();
692
693 let scores = self
695 .policy_network
696 .score_matrix(&trajectory.observations, &trajectory.actions)?;
697
698 let old_log_probs = self
700 .policy_network
701 .evaluate_actions(&trajectory.observations, &trajectory.actions)?
702 .log_probs;
703
704 let trpo = self.config.trpo_config.clone();
705 let tr_config = TrustRegionConfig {
706 method: TrustRegionMethod::TRPO,
707 max_kl: trpo.max_kl,
708 cg_iters: trpo.cg_iters,
709 cg_damping: trpo.cg_damping,
710 cg_tolerance: trpo.cg_tolerance,
711 max_backtracks: trpo.max_backtracks,
712 backtrack_coeff: trpo.backtrack_factor,
713 ..TrustRegionConfig::default()
714 };
715
716 let observations = trajectory.observations.clone();
718 let actions = trajectory.actions.clone();
719 let advantages = trajectory.advantages.clone();
720
721 let tr_metrics = {
722 let mut trust_region = TrustRegionOptimizer::new(tr_config, &mut self.policy_network);
723 trust_region.set_score_samples(scores);
724 trust_region.update_trpo_with_surrogate(&flat_gradient, |policy| {
725 let evaluation = policy.evaluate_actions(&observations, &actions)?;
726 let mut surrogate = T::zero();
727 for i in 0..batch_len {
728 let ratio = (evaluation.log_probs[i] - old_log_probs[i]).exp();
729 surrogate += ratio * advantages[i];
730 }
731 Ok(surrogate * inv_n)
732 })?
733 };
734
735 if let Some(ref mut scheduler) = self.policy_scheduler {
736 self.metrics.policy_lr = scheduler.step();
737 }
738 if let Some(ref mut scheduler) = self.value_scheduler {
739 self.metrics.value_lr = scheduler.step();
740 }
741 self.update_count += 1;
742
743 self.metrics.policy_loss = tr_metrics.policy_loss;
744 self.metrics.value_loss = value_loss;
745 self.metrics.entropy_loss = T::zero();
746 self.metrics.total_loss =
747 self.metrics.policy_loss + self.config.base_config.value_loss_coeff * value_loss;
748 self.metrics.clip_fraction = None;
749 self.metrics.kl_divergence = tr_metrics.kl_divergence;
750 self.metrics.policy_grad_norm = grad_norm;
751 for (name, value) in tr_metrics.custom_metrics {
752 self.metrics.custom_metrics.insert(name, value);
753 }
754
755 Ok(self.metrics.clone())
756 }
757
758 fn update_reinforce(
767 &mut self,
768 mut trajectory: TrajectoryBatch<T>,
769 ) -> Result<RLOptimizationMetrics<T>> {
770 let batch_len = trajectory.observations.nrows();
771 if batch_len == 0 {
772 return Err(OptimError::InvalidConfig(
773 "REINFORCE received an empty trajectory".to_string(),
774 ));
775 }
776 let count = T::from(batch_len).ok_or_else(|| {
777 OptimError::ComputationError("failed to convert batch size to scalar".to_string())
778 })?;
779 let inv_n = T::one() / count;
780
781 let use_baseline = self.config.use_baseline && self.value_network.is_some();
782 let weights = if use_baseline {
783 self.prepare_gae(&mut trajectory)?;
784 trajectory.advantages.clone()
785 } else {
786 let next_value = self.bootstrap_next_value(&trajectory)?;
787 trajectory
788 .compute_discounted_returns(self.config.base_config.discount_factor, next_value)?;
789 trajectory.returns.clone()
790 };
791
792 let policy_eval = self
793 .policy_network
794 .evaluate_actions(&trajectory.observations, &trajectory.actions)?;
795
796 let mut policy_loss = T::zero();
798 let mut dloss_dlogp = Array1::zeros(batch_len);
799 for i in 0..batch_len {
800 policy_loss = policy_loss - policy_eval.log_probs[i] * weights[i] * inv_n;
801 dloss_dlogp[i] = -weights[i] * inv_n;
802 }
803
804 let entropy_loss = Self::entropy_loss(&policy_eval);
805
806 self.apply_policy_gradient_step(
807 &trajectory.observations,
808 &trajectory.actions,
809 &dloss_dlogp,
810 )?;
811
812 let value_loss = if use_baseline {
813 self.update_value_on_batch(
814 &trajectory.observations,
815 &trajectory.values,
816 &trajectory.returns,
817 )?
818 } else {
819 T::zero()
820 };
821
822 if let Some(ref mut scheduler) = self.policy_scheduler {
823 self.metrics.policy_lr = scheduler.step();
824 }
825 if let Some(ref mut scheduler) = self.value_scheduler {
826 self.metrics.value_lr = scheduler.step();
827 }
828 self.update_count += 1;
829
830 self.metrics.policy_loss = policy_loss;
831 self.metrics.value_loss = value_loss;
832 self.metrics.entropy_loss = entropy_loss;
833 self.metrics.total_loss = policy_loss
834 + self.config.base_config.value_loss_coeff * value_loss
835 + self.config.base_config.entropy_coeff * entropy_loss;
836 self.metrics.clip_fraction = None;
837 self.metrics.kl_divergence = Some(T::zero());
838
839 Ok(self.metrics.clone())
840 }
841
842 fn update_actor_critic(
861 &mut self,
862 mut trajectory: TrajectoryBatch<T>,
863 ) -> Result<RLOptimizationMetrics<T>> {
864 self.prepare_gae(&mut trajectory)?;
867
868 let policy_eval = self
870 .policy_network
871 .evaluate_actions(&trajectory.observations, &trajectory.actions)?;
872
873 let batch_len = trajectory.observations.nrows();
874 if batch_len == 0 {
875 return Err(OptimError::InvalidConfig(
876 "A2C received an empty trajectory".to_string(),
877 ));
878 }
879 let batch_count = T::from(batch_len).ok_or_else(|| {
880 OptimError::ComputationError("failed to convert batch size to scalar".to_string())
881 })?;
882 let inv_n = T::one() / batch_count;
883
884 let mut policy_loss = T::zero();
888 let mut dloss_dlogp = Array1::zeros(batch_len);
889 for i in 0..batch_len {
890 policy_loss = policy_loss - policy_eval.log_probs[i] * trajectory.advantages[i] * inv_n;
891 dloss_dlogp[i] = -trajectory.advantages[i] * inv_n;
892 }
893
894 let entropy_loss = Self::entropy_loss(&policy_eval);
897
898 self.apply_policy_gradient_step(
901 &trajectory.observations,
902 &trajectory.actions,
903 &dloss_dlogp,
904 )?;
905 let value_loss = self.update_value_on_batch(
906 &trajectory.observations,
907 &trajectory.values,
908 &trajectory.returns,
909 )?;
910
911 let total_loss = policy_loss
914 + self.config.base_config.value_loss_coeff * value_loss
915 + self.config.base_config.entropy_coeff * entropy_loss;
916
917 if let Some(ref mut scheduler) = self.policy_scheduler {
919 self.metrics.policy_lr = scheduler.step();
920 }
921 if let Some(ref mut scheduler) = self.value_scheduler {
922 self.metrics.value_lr = scheduler.step();
923 }
924
925 self.update_count += 1;
926
927 self.metrics.policy_loss = policy_loss;
931 self.metrics.value_loss = value_loss;
932 self.metrics.entropy_loss = entropy_loss;
933 self.metrics.total_loss = total_loss;
934 self.metrics.clip_fraction = None;
935 self.metrics.kl_divergence = Some(T::zero());
936
937 Ok(self.metrics.clone())
938 }
939
940 fn update_impala(
966 &mut self,
967 trajectory: TrajectoryBatch<T>,
968 ) -> Result<RLOptimizationMetrics<T>> {
969 let batch_size = trajectory.observations.nrows();
970 if batch_size == 0 {
971 return Err(OptimError::InvalidConfig(
972 "IMPALA received an empty trajectory".to_string(),
973 ));
974 }
975
976 let gamma = self.config.base_config.discount_factor;
977
978 let rho_bar = self.config.max_is_ratio.max(T::one());
981 let c_bar = rho_bar.min(T::one());
982
983 let values_now = if let Some(ref value_net) = self.value_network {
985 value_net.evaluate_value(&trajectory.observations)?
986 } else {
987 Array1::zeros(batch_size)
988 };
989 let bootstrap_value = self.bootstrap_next_value(&trajectory)?;
990
991 let policy_eval = self
993 .policy_network
994 .evaluate_actions(&trajectory.observations, &trajectory.actions)?;
995
996 let mut rho = Array1::zeros(batch_size);
998 let mut c_trace = Array1::zeros(batch_size);
999 for t in 0..batch_size {
1000 let is_ratio = (policy_eval.log_probs[t] - trajectory.log_probs[t]).exp();
1001 rho[t] = is_ratio.min(rho_bar);
1002 c_trace[t] = is_ratio.min(c_bar);
1003 }
1004
1005 let mut vtrace_targets = Array1::zeros(batch_size);
1008 let mut pg_advantages = Array1::zeros(batch_size);
1009 let mut next_vtrace = bootstrap_value;
1011 for t in (0..batch_size).rev() {
1012 let is_terminal = trajectory.dones[t];
1013 let nonterminal = T::from(!is_terminal as u8).unwrap_or_else(T::zero);
1014
1015 let next_value = if t == batch_size - 1 {
1018 bootstrap_value
1019 } else {
1020 values_now[t + 1]
1021 } * nonterminal;
1022
1023 let delta = rho[t] * (trajectory.rewards[t] + gamma * next_value - values_now[t]);
1025
1026 let masked_next_vtrace = next_vtrace * nonterminal;
1028 let vtrace =
1029 values_now[t] + delta + gamma * c_trace[t] * (masked_next_vtrace - next_value);
1030 vtrace_targets[t] = vtrace;
1031
1032 pg_advantages[t] =
1034 rho[t] * (trajectory.rewards[t] + gamma * masked_next_vtrace - values_now[t]);
1035
1036 next_vtrace = vtrace;
1037 }
1038
1039 let batch_count = T::from(batch_size).ok_or_else(|| {
1040 OptimError::ComputationError("failed to convert batch size to scalar".to_string())
1041 })?;
1042 let inv_n = T::one() / batch_count;
1043
1044 let mut policy_loss = T::zero();
1047 let mut dloss_dlogp = Array1::zeros(batch_size);
1048 for t in 0..batch_size {
1049 policy_loss = policy_loss - policy_eval.log_probs[t] * pg_advantages[t] * inv_n;
1050 dloss_dlogp[t] = -pg_advantages[t] * inv_n;
1051 }
1052
1053 let entropy_loss = Self::entropy_loss(&policy_eval);
1055
1056 let two = T::one() + T::one();
1060 let mut value_loss = T::zero();
1061 let mut dloss_dv = Array1::zeros(batch_size);
1062 if self.value_network.is_some() {
1063 for t in 0..batch_size {
1064 let err = values_now[t] - vtrace_targets[t];
1065 value_loss += err * err * inv_n;
1066 dloss_dv[t] = two * err * inv_n * self.config.base_config.value_loss_coeff;
1067 }
1068 }
1069
1070 let total_loss = policy_loss
1072 + self.config.base_config.value_loss_coeff * value_loss
1073 + self.config.base_config.entropy_coeff * entropy_loss;
1074
1075 self.apply_policy_gradient_step(
1077 &trajectory.observations,
1078 &trajectory.actions,
1079 &dloss_dlogp,
1080 )?;
1081 if self.value_network.is_some() {
1082 self.apply_value_gradient_step(&trajectory.observations, &dloss_dv)?;
1083 }
1084
1085 if let Some(ref mut scheduler) = self.policy_scheduler {
1087 self.metrics.policy_lr = scheduler.step();
1088 }
1089 if let Some(ref mut scheduler) = self.value_scheduler {
1090 self.metrics.value_lr = scheduler.step();
1091 }
1092
1093 self.update_count += 1;
1094
1095 let mean_rho = rho.iter().copied().sum::<T>() / batch_count;
1099 let approx_kl = (&policy_eval.log_probs - &trajectory.log_probs)
1100 .mapv(|x| x * x)
1101 .mean()
1102 .unwrap_or(T::zero());
1103
1104 self.metrics.policy_loss = policy_loss;
1105 self.metrics.value_loss = value_loss;
1106 self.metrics.entropy_loss = entropy_loss;
1107 self.metrics.total_loss = total_loss;
1108 self.metrics.clip_fraction = None;
1109 self.metrics.kl_divergence = Some(approx_kl);
1110 self.metrics
1111 .custom_metrics
1112 .insert("mean_rho".to_string(), mean_rho);
1113
1114 Ok(self.metrics.clone())
1115 }
1116
1117 fn apply_policy_gradient_step(
1133 &mut self,
1134 observations: &Array2<T>,
1135 actions: &Array2<T>,
1136 dloss_dlogp: &Array1<T>,
1137 ) -> Result<()> {
1138 let mut gradients =
1139 self.policy_network
1140 .log_prob_gradient(observations, actions, dloss_dlogp)?;
1141
1142 let entropy_coeff = self.config.base_config.entropy_coeff;
1143 if entropy_coeff != T::zero() {
1144 let entropy_grad = self.policy_network.entropy_gradient(observations)?;
1145 for (name, grad) in entropy_grad {
1146 match gradients.get_mut(&name) {
1147 Some(target) => {
1148 if target.len() != grad.len() {
1149 return Err(OptimError::DimensionMismatch(format!(
1150 "entropy gradient for '{name}' has length {} but the log-prob \
1151 gradient has length {}",
1152 grad.len(),
1153 target.len()
1154 )));
1155 }
1156 for i in 0..target.len() {
1157 target[i] = target[i] - entropy_coeff * grad[i];
1158 }
1159 }
1160 None => {
1161 gradients.insert(name, grad.mapv(|g| -entropy_coeff * g));
1162 }
1163 }
1164 }
1165 }
1166
1167 let (clipped, norm) =
1168 clip_named_gradients(&gradients, self.config.base_config.max_grad_norm);
1169 self.metrics.policy_grad_norm = norm;
1170
1171 let step = scale_named_gradients(&clipped, -self.policy_lr());
1172 self.policy_network.update_parameters(&step)
1173 }
1174
1175 fn apply_value_gradient_step(
1180 &mut self,
1181 observations: &Array2<T>,
1182 dloss_dv: &Array1<T>,
1183 ) -> Result<()> {
1184 let max_norm = self.config.base_config.max_grad_norm;
1185 let lr = self.value_lr();
1186
1187 let gradients = match self.value_network {
1188 Some(ref value_net) => value_net.value_gradient(observations, dloss_dv)?,
1189 None => return Ok(()),
1190 };
1191
1192 let (clipped, norm) = clip_named_gradients(&gradients, max_norm);
1193 self.metrics.value_grad_norm = norm;
1194
1195 let step = scale_named_gradients(&clipped, -lr);
1196 if let Some(ref mut value_net) = self.value_network {
1197 value_net.update_parameters(&step)?;
1198 }
1199 Ok(())
1200 }
1201
1202 fn value_loss_and_grad(
1209 &self,
1210 predicted: &Array1<T>,
1211 old_values: &Array1<T>,
1212 returns: &Array1<T>,
1213 ) -> Result<(T, Array1<T>)> {
1214 let n = predicted.len();
1215 if n == 0 {
1216 return Ok((T::zero(), Array1::zeros(0)));
1217 }
1218 if old_values.len() != n || returns.len() != n {
1219 return Err(OptimError::DimensionMismatch(
1220 "value prediction, old value and return batches must have equal length".to_string(),
1221 ));
1222 }
1223
1224 let count = T::from(n).ok_or_else(|| {
1225 OptimError::ComputationError("failed to convert batch size to scalar".to_string())
1226 })?;
1227 let inv_n = T::one() / count;
1228 let two = T::one() + T::one();
1229
1230 let mut loss = T::zero();
1231 let mut grad = Array1::zeros(n);
1232
1233 if self.config.ppo_config.value_clip {
1234 let clip_range = self.config.ppo_config.value_clip_range;
1235 for i in 0..n {
1236 let diff = predicted[i] - old_values[i];
1237 let clamped = diff.max(-clip_range).min(clip_range);
1238 let clipped_pred = old_values[i] + clamped;
1239
1240 let raw_err = predicted[i] - returns[i];
1241 let clipped_err = clipped_pred - returns[i];
1242 let l1 = raw_err * raw_err;
1243 let l2 = clipped_err * clipped_err;
1244
1245 if l1 >= l2 {
1246 loss += l1 * inv_n;
1247 grad[i] = two * raw_err * inv_n;
1248 } else {
1249 loss += l2 * inv_n;
1250 let inside = diff.abs() < clip_range;
1252 grad[i] = if inside {
1253 two * clipped_err * inv_n
1254 } else {
1255 T::zero()
1256 };
1257 }
1258 }
1259 } else {
1260 for i in 0..n {
1261 let err = predicted[i] - returns[i];
1262 loss += err * err * inv_n;
1263 grad[i] = two * err * inv_n;
1264 }
1265 }
1266
1267 Ok((loss, grad))
1268 }
1269
1270 fn update_value_on_batch(
1276 &mut self,
1277 observations: &Array2<T>,
1278 old_values: &Array1<T>,
1279 returns: &Array1<T>,
1280 ) -> Result<T> {
1281 let predicted = match self.value_network {
1282 Some(ref value_net) => value_net.evaluate_value(observations)?,
1283 None => return Ok(T::zero()),
1284 };
1285
1286 let (loss, grad) = self.value_loss_and_grad(&predicted, old_values, returns)?;
1287 let coeff = self.config.base_config.value_loss_coeff;
1288 let scaled = grad.mapv(|g| g * coeff);
1289 self.apply_value_gradient_step(observations, &scaled)?;
1290 Ok(loss)
1291 }
1292
1293 fn entropy_loss(evaluation: &super::PolicyEvaluation<T>) -> T {
1295 let len = evaluation.entropy.len();
1296 if len == 0 {
1297 return T::zero();
1298 }
1299 let count = T::from(len).unwrap_or_else(T::one);
1300 -evaluation.entropy.iter().copied().sum::<T>() / count
1301 }
1302
1303 pub fn get_metrics(&self) -> &RLOptimizationMetrics<T> {
1305 &self.metrics
1306 }
1307
1308 pub fn policy_network(&self) -> &P {
1312 &self.policy_network
1313 }
1314
1315 pub fn value_network(&self) -> Option<&V> {
1317 self.value_network.as_ref()
1318 }
1319
1320 pub fn add_trajectory(&mut self, trajectory: TrajectoryBatch<T>) {
1322 self.trajectory_buffer.push(trajectory);
1323 if self.trajectory_buffer.len() > self.max_buffer_size {
1324 self.trajectory_buffer.remove(0);
1325 }
1326 }
1327
1328 pub fn update_from_buffer(&mut self) -> Result<RLOptimizationMetrics<T>> {
1330 if self.trajectory_buffer.is_empty() {
1331 return Err(OptimError::InvalidConfig(
1332 "No trajectories in buffer".to_string(),
1333 ));
1334 }
1335
1336 let combined = self.combine_trajectories()?;
1338 self.update(combined)
1339 }
1340
1341 fn combine_trajectories(&self) -> Result<TrajectoryBatch<T>> {
1343 if self.trajectory_buffer.is_empty() {
1344 return Err(OptimError::InvalidConfig(
1345 "No trajectories to combine".to_string(),
1346 ));
1347 }
1348
1349 let total_size: usize = self
1350 .trajectory_buffer
1351 .iter()
1352 .map(|t| t.observations.nrows())
1353 .sum();
1354
1355 let obs_dim = self.trajectory_buffer[0].observations.ncols();
1356 let action_dim = self.trajectory_buffer[0].actions.ncols();
1357
1358 let mut combined_obs = Array2::zeros((total_size, obs_dim));
1359 let mut combined_actions = Array2::zeros((total_size, action_dim));
1360 let mut combined_log_probs = Array1::zeros(total_size);
1361 let mut combined_rewards = Array1::zeros(total_size);
1362 let mut combined_values = Array1::zeros(total_size);
1363 let mut combined_dones = Vec::with_capacity(total_size);
1364
1365 let mut offset = 0;
1366 for trajectory in &self.trajectory_buffer {
1367 let size = trajectory.observations.nrows();
1368
1369 combined_obs
1370 .slice_mut(s![offset..offset + size, ..])
1371 .assign(&trajectory.observations);
1372 combined_actions
1373 .slice_mut(s![offset..offset + size, ..])
1374 .assign(&trajectory.actions);
1375 combined_log_probs
1376 .slice_mut(s![offset..offset + size])
1377 .assign(&trajectory.log_probs);
1378 combined_rewards
1379 .slice_mut(s![offset..offset + size])
1380 .assign(&trajectory.rewards);
1381 combined_values
1382 .slice_mut(s![offset..offset + size])
1383 .assign(&trajectory.values);
1384
1385 combined_dones.extend(trajectory.dones.iter().copied());
1388
1389 offset += size;
1390 }
1391
1392 let combined_dones_array = Array1::from_vec(combined_dones);
1393
1394 let combined = TrajectoryBatch::new(
1395 combined_obs,
1396 combined_actions,
1397 combined_log_probs,
1398 combined_rewards,
1399 combined_values,
1400 combined_dones_array,
1401 )?;
1402
1403 match self
1405 .trajectory_buffer
1406 .last()
1407 .and_then(|t| t.final_observation.clone())
1408 {
1409 Some(final_observation) => combined.with_final_observation(final_observation),
1410 None => Ok(combined),
1411 }
1412 }
1413
1414 pub fn clear_buffer(&mut self) {
1416 self.trajectory_buffer.clear();
1417 }
1418}
1419
1420use scirs2_core::ndarray::s;
1422#[cfg(test)]
1425mod tests {
1426 use super::super::{
1427 ActionDistribution, DistributionType, PolicyEvaluation, RLOptimizerConfig, TrajectoryBatch,
1428 ValueNetwork,
1429 };
1430 use super::*;
1431 use scirs2_core::ndarray::{arr1, arr2, Array1, Array2};
1432 use std::collections::HashMap;
1433
1434 struct MockPolicy {
1441 params: HashMap<String, Array1<f64>>,
1442 entropy: f64,
1443 update_calls: usize,
1445 }
1446
1447 impl MockPolicy {
1448 fn new(log_prob_offset: f64) -> Self {
1449 let mut params = HashMap::new();
1450 params.insert("w".to_string(), arr1(&[log_prob_offset]));
1451 Self {
1452 params,
1453 entropy: 0.5,
1454 update_calls: 0,
1455 }
1456 }
1457
1458 fn offset(&self) -> f64 {
1459 self.params["w"][0]
1460 }
1461 }
1462
1463 impl PolicyNetwork<f64> for MockPolicy {
1464 fn evaluate_actions(
1465 &self,
1466 observations: &Array2<f64>,
1467 _actions: &Array2<f64>,
1468 ) -> Result<PolicyEvaluation<f64>> {
1469 let n = observations.nrows();
1470 let log_probs = Array1::from_elem(n, self.offset());
1471 let entropy = Array1::from_elem(n, self.entropy);
1472 Ok(PolicyEvaluation {
1473 log_probs,
1474 entropy,
1475 metrics: HashMap::new(),
1476 })
1477 }
1478
1479 fn get_action_distribution(
1480 &self,
1481 _observations: &Array2<f64>,
1482 ) -> Result<ActionDistribution<f64>> {
1483 Ok(ActionDistribution {
1484 mean: None,
1485 std: None,
1486 logits: None,
1487 distribution_type: DistributionType::Gaussian,
1488 })
1489 }
1490
1491 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<f64>>) -> Result<()> {
1492 self.update_calls += 1;
1493 for (key, delta) in deltas {
1494 if let Some(p) = self.params.get_mut(key) {
1495 if p.len() == delta.len() {
1496 *p = &*p + delta;
1497 }
1498 }
1499 }
1500 Ok(())
1501 }
1502
1503 fn get_parameters(&self) -> HashMap<String, Array1<f64>> {
1504 self.params.clone()
1505 }
1506
1507 fn log_prob_gradient(
1508 &self,
1509 _observations: &Array2<f64>,
1510 _actions: &Array2<f64>,
1511 coefficients: &Array1<f64>,
1512 ) -> Result<HashMap<String, Array1<f64>>> {
1513 let mut map = HashMap::new();
1515 map.insert("w".to_string(), arr1(&[coefficients.iter().sum::<f64>()]));
1516 Ok(map)
1517 }
1518
1519 fn entropy_gradient(
1520 &self,
1521 _observations: &Array2<f64>,
1522 ) -> Result<HashMap<String, Array1<f64>>> {
1523 let mut map = HashMap::new();
1525 map.insert("w".to_string(), arr1(&[0.0]));
1526 Ok(map)
1527 }
1528 }
1529
1530 struct MockValue {
1533 params: HashMap<String, Array1<f64>>,
1534 update_calls: usize,
1536 }
1537
1538 impl MockValue {
1539 fn new() -> Self {
1540 Self::with_value(0.0)
1541 }
1542
1543 fn with_value(value: f64) -> Self {
1544 let mut params = HashMap::new();
1545 params.insert("v".to_string(), arr1(&[value]));
1546 Self {
1547 params,
1548 update_calls: 0,
1549 }
1550 }
1551 }
1552
1553 impl ValueNetwork<f64> for MockValue {
1554 fn evaluate_value(&self, observations: &Array2<f64>) -> Result<Array1<f64>> {
1555 Ok(Array1::from_elem(observations.nrows(), self.params["v"][0]))
1556 }
1557
1558 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<f64>>) -> Result<()> {
1559 self.update_calls += 1;
1560 for (key, delta) in deltas {
1561 if let Some(p) = self.params.get_mut(key) {
1562 if p.len() == delta.len() {
1563 *p = &*p + delta;
1564 }
1565 }
1566 }
1567 Ok(())
1568 }
1569
1570 fn get_parameters(&self) -> HashMap<String, Array1<f64>> {
1571 self.params.clone()
1572 }
1573
1574 fn value_gradient(
1575 &self,
1576 _observations: &Array2<f64>,
1577 residuals: &Array1<f64>,
1578 ) -> Result<HashMap<String, Array1<f64>>> {
1579 let mut map = HashMap::new();
1580 map.insert("v".to_string(), arr1(&[residuals.iter().sum::<f64>()]));
1581 Ok(map)
1582 }
1583 }
1584
1585 fn make_trajectory() -> TrajectoryBatch<f64> {
1588 let observations = arr2(&[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]]);
1589 let actions = arr2(&[[0.0], [1.0], [0.0], [1.0]]);
1590 let log_probs = arr1(&[0.0, 0.0, 0.0, 0.0]);
1591 let rewards = arr1(&[1.0, 0.5, 0.25, 1.0]);
1592 let values = arr1(&[0.0, 0.0, 0.0, 0.0]);
1593 let dones = Array1::from_vec(vec![false, false, false, true]);
1594 TrajectoryBatch::new(observations, actions, log_probs, rewards, values, dones)
1595 .expect("valid trajectory")
1596 }
1597
1598 fn make_optimizer(
1599 log_prob_offset: f64,
1600 kl_coeff: f64,
1601 target_kl: f64,
1602 ) -> PolicyGradientOptimizer<f64, MockPolicy, MockValue> {
1603 let ppo_config = PPOConfig::<f64> {
1604 kl_coeff,
1605 target_kl,
1606 early_stop_on_kl: false,
1608 ..PPOConfig::default()
1609 };
1610 let base_config = RLOptimizerConfig::<f64> {
1611 n_epochs: 1,
1613 mini_batchsize: 4,
1614 ..RLOptimizerConfig::default()
1615 };
1616 let config = PolicyGradientConfig::<f64> {
1617 base_config,
1618 method: PolicyGradientMethod::PPOAdaptiveKL,
1619 ppo_config,
1620 ..PolicyGradientConfig::default()
1621 };
1622 PolicyGradientOptimizer::new(
1623 config,
1624 MockPolicy::new(log_prob_offset),
1625 Some(MockValue::new()),
1626 )
1627 }
1628
1629 #[test]
1630 fn test_adaptive_kl_runs_and_reports_finite_kl() {
1631 let mut opt = make_optimizer(-0.01, 0.2, 0.01);
1633 let traj = make_trajectory();
1634 let metrics = opt.update(traj).expect("update should succeed");
1635
1636 let kl = metrics.kl_divergence.expect("kl_divergence must be set");
1637 assert!(kl.is_finite(), "KL must be finite, got {kl}");
1638 assert!(metrics.clip_fraction.is_none());
1640 assert!(metrics.policy_loss.is_finite());
1642 assert!(metrics.value_loss.is_finite());
1643 assert!(metrics.total_loss.is_finite());
1644 }
1645
1646 #[test]
1647 fn test_adaptive_kl_increases_beta_on_large_kl() {
1648 let mut opt = make_optimizer(-1.0, 0.2, 0.01);
1650 let beta_before = opt.kl_coeff;
1651 let traj = make_trajectory();
1652 let _ = opt.update(traj).expect("update should succeed");
1653 let beta_after = opt.kl_coeff;
1654
1655 assert!(
1656 beta_after > beta_before,
1657 "β should increase on large KL: before={beta_before}, after={beta_after}"
1658 );
1659 assert!((beta_after - beta_before * 2.0).abs() < 1e-9);
1660 }
1661
1662 #[test]
1663 fn test_adaptive_kl_decreases_beta_on_small_kl() {
1664 let mut opt = make_optimizer(0.0, 0.2, 0.01);
1666 let beta_before = opt.kl_coeff;
1667 let traj = make_trajectory();
1668 let _ = opt.update(traj).expect("update should succeed");
1669 let beta_after = opt.kl_coeff;
1670
1671 assert!(
1672 beta_after < beta_before,
1673 "β should decrease on small KL: before={beta_before}, after={beta_after}"
1674 );
1675 assert!((beta_after - beta_before / 2.0).abs() < 1e-9);
1676 }
1677
1678 #[test]
1679 fn test_adaptive_kl_beta_persists_across_updates() {
1680 let mut opt = make_optimizer(-1.0, 0.2, 0.01);
1683 let beta0 = opt.kl_coeff;
1684
1685 let _ = opt.update(make_trajectory()).expect("update 1");
1686 let beta1 = opt.kl_coeff;
1687 let _ = opt.update(make_trajectory()).expect("update 2");
1688 let beta2 = opt.kl_coeff;
1689
1690 assert!(beta1 > beta0);
1691 assert!(beta2 > beta1);
1692 assert!((beta2 - beta0 * 4.0).abs() < 1e-9);
1694 }
1695
1696 fn make_method_optimizer(
1703 method: PolicyGradientMethod,
1704 log_prob_offset: f64,
1705 value_baseline: f64,
1706 ) -> PolicyGradientOptimizer<f64, MockPolicy, MockValue> {
1707 let base_config = RLOptimizerConfig::<f64> {
1708 n_epochs: 1,
1709 mini_batchsize: 4,
1710 ..RLOptimizerConfig::default()
1711 };
1712 let config = PolicyGradientConfig::<f64> {
1713 base_config,
1714 method,
1715 ..PolicyGradientConfig::default()
1716 };
1717 PolicyGradientOptimizer::new(
1718 config,
1719 MockPolicy::new(log_prob_offset),
1720 Some(MockValue::with_value(value_baseline)),
1721 )
1722 }
1723
1724 #[test]
1725 fn test_actor_critic_runs_and_forwards_updates() {
1726 let mut opt = make_method_optimizer(PolicyGradientMethod::ActorCritic, -0.2, 0.1);
1728 let traj = make_trajectory();
1729 let metrics = opt.update(traj).expect("A2C update should succeed");
1730
1731 assert!(
1733 metrics.policy_loss.is_finite(),
1734 "policy loss must be finite"
1735 );
1736 assert!(metrics.value_loss.is_finite(), "value loss must be finite");
1737 assert!(
1738 metrics.entropy_loss.is_finite(),
1739 "entropy loss must be finite"
1740 );
1741 assert!(metrics.total_loss.is_finite(), "total loss must be finite");
1742 assert!(metrics.clip_fraction.is_none());
1744 assert_eq!(metrics.kl_divergence, Some(0.0));
1745 assert!(metrics.value_loss > 0.0);
1747
1748 assert!(
1750 opt.policy_network.update_calls > 0,
1751 "policy network must receive parameter updates"
1752 );
1753 let value_net = opt.value_network.as_ref().expect("value net present");
1754 assert!(
1755 value_net.update_calls > 0,
1756 "value network must receive parameter updates"
1757 );
1758 let v = &value_net.get_parameters()["v"];
1761 assert!(v.iter().any(|&x| x != 0.0), "value params must change");
1762 }
1763
1764 #[test]
1765 fn test_a3c_dispatches_and_equals_a2c() {
1766 let mut a2c = make_method_optimizer(PolicyGradientMethod::ActorCritic, -0.2, 0.1);
1769 let mut a3c = make_method_optimizer(PolicyGradientMethod::A3C, -0.2, 0.1);
1770
1771 let m_a2c = a2c.update(make_trajectory()).expect("A2C update");
1772 let m_a3c = a3c.update(make_trajectory()).expect("A3C update");
1773
1774 assert_eq!(m_a2c.policy_loss, m_a3c.policy_loss);
1776 assert_eq!(m_a2c.value_loss, m_a3c.value_loss);
1777 assert_eq!(m_a2c.entropy_loss, m_a3c.entropy_loss);
1778 assert_eq!(m_a2c.total_loss, m_a3c.total_loss);
1779 assert_eq!(m_a2c.kl_divergence, m_a3c.kl_divergence);
1780 assert_eq!(m_a2c.clip_fraction, m_a3c.clip_fraction);
1781
1782 assert_eq!(
1784 a2c.policy_network.get_parameters()["w"],
1785 a3c.policy_network.get_parameters()["w"]
1786 );
1787 }
1791
1792 #[test]
1793 fn test_impala_dispatches_and_forwards_updates() {
1794 let offset = 0.5_f64.ln();
1797 let mut opt = make_method_optimizer(PolicyGradientMethod::IMPALA, offset, 0.1);
1798 let metrics = opt
1799 .update(make_trajectory())
1800 .expect("IMPALA update should succeed");
1801
1802 assert!(metrics.policy_loss.is_finite());
1803 assert!(metrics.value_loss.is_finite());
1804 assert!(metrics.total_loss.is_finite());
1805 assert!(metrics.clip_fraction.is_none());
1806 let mean_rho = metrics
1808 .custom_metrics
1809 .get("mean_rho")
1810 .copied()
1811 .expect("mean_rho must be surfaced");
1812 assert!(
1813 (mean_rho - 0.5).abs() < 1e-9,
1814 "mean ρ should be 0.5, got {mean_rho}"
1815 );
1816
1817 assert!(opt.policy_network.update_calls > 0);
1819 assert!(opt.value_network.as_ref().expect("value net").update_calls > 0);
1820 }
1821
1822 #[test]
1823 fn test_impala_vtrace_target_matches_discounted_return() {
1824 let base_config = RLOptimizerConfig::<f64> {
1829 n_epochs: 1,
1830 mini_batchsize: 2,
1831 ..RLOptimizerConfig::default()
1832 };
1833 let config = PolicyGradientConfig::<f64> {
1834 base_config,
1835 method: PolicyGradientMethod::IMPALA,
1836 ..PolicyGradientConfig::default()
1837 };
1838 let mut opt = PolicyGradientOptimizer::new(
1839 config,
1840 MockPolicy::new(0.0), Some(MockValue::with_value(0.0)),
1842 );
1843
1844 let observations = arr2(&[[0.1, 0.2], [0.3, 0.4]]);
1846 let actions = arr2(&[[0.0], [1.0]]);
1847 let log_probs = arr1(&[0.0, 0.0]);
1848 let rewards = arr1(&[1.0, 0.5]);
1849 let values = arr1(&[0.0, 0.0]);
1850 let dones = Array1::from_vec(vec![false, true]);
1851 let traj = TrajectoryBatch::new(observations, actions, log_probs, rewards, values, dones)
1852 .expect("valid trajectory");
1853
1854 let metrics = opt.update(traj).expect("IMPALA update should succeed");
1855
1856 let gamma = 0.99_f64;
1858 let v1 = 0.5;
1859 let v0 = 1.0 + gamma * v1;
1860 let expected_value_loss = (v0 * v0 + v1 * v1) / 2.0;
1861
1862 assert!(
1863 (metrics.value_loss - expected_value_loss).abs() < 1e-9,
1864 "V-trace value loss {} should equal discounted-return MSE {}",
1865 metrics.value_loss,
1866 expected_value_loss
1867 );
1868 let mean_rho = metrics
1870 .custom_metrics
1871 .get("mean_rho")
1872 .copied()
1873 .expect("mean_rho");
1874 assert!((mean_rho - 1.0).abs() < 1e-9);
1875 }
1876}