1use super::{
22 ActionDistribution, DistributionType, KroneckerBlock, PolicyEvaluation, PolicyNetwork,
23 QNetwork, ValueNetwork,
24};
25use crate::error::{OptimError, Result};
26use scirs2_core::ndarray::{Array1, Array2};
27use scirs2_core::numeric::Float;
28use std::collections::HashMap;
29use std::fmt::Debug;
30
31const WEIGHTS: &str = "weights";
33
34const LOG_STD: &str = "log_std";
36
37fn min_sigma<T: Float>() -> T {
41 T::from(1e-6).unwrap_or_else(T::epsilon)
42}
43
44fn scalar<T: Float>(value: f64) -> T {
45 T::from(value).unwrap_or_else(T::zero)
46}
47
48fn flat_from_matrix<T: Float + Debug + Send + Sync + 'static>(matrix: &Array2<T>) -> Array1<T> {
50 let (rows, cols) = matrix.dim();
51 let mut flat = Array1::zeros(rows * cols);
52 for r in 0..rows {
53 for c in 0..cols {
54 flat[r * cols + c] = matrix[[r, c]];
55 }
56 }
57 flat
58}
59
60fn add_flat_to_matrix<T: Float + Debug + Send + Sync + 'static>(
62 matrix: &mut Array2<T>,
63 delta: &Array1<T>,
64 what: &str,
65) -> Result<()> {
66 let (rows, cols) = matrix.dim();
67 if delta.len() != rows * cols {
68 return Err(OptimError::DimensionMismatch(format!(
69 "{what} delta length ({}) does not match parameter size ({}x{})",
70 delta.len(),
71 rows,
72 cols
73 )));
74 }
75 for r in 0..rows {
76 for c in 0..cols {
77 matrix[[r, c]] = matrix[[r, c]] + delta[r * cols + c];
78 }
79 }
80 Ok(())
81}
82
83fn check_batch<T: Float + Debug + Send + Sync + 'static>(
85 observations: &Array2<T>,
86 n_features: usize,
87 actions: Option<(&Array2<T>, usize)>,
88 coefficients: Option<&Array1<T>>,
89) -> Result<usize> {
90 let n = observations.nrows();
91 if observations.ncols() != n_features {
92 return Err(OptimError::DimensionMismatch(format!(
93 "observation dimension ({}) does not match model feature dimension ({})",
94 observations.ncols(),
95 n_features
96 )));
97 }
98 if let Some((actions, expected)) = actions {
99 if actions.nrows() != n {
100 return Err(OptimError::DimensionMismatch(format!(
101 "action batch ({}) does not match observation batch ({})",
102 actions.nrows(),
103 n
104 )));
105 }
106 if actions.ncols() != expected {
107 return Err(OptimError::DimensionMismatch(format!(
108 "action dimension ({}) does not match model action dimension ({})",
109 actions.ncols(),
110 expected
111 )));
112 }
113 }
114 if let Some(coefficients) = coefficients {
115 if coefficients.len() != n {
116 return Err(OptimError::DimensionMismatch(format!(
117 "coefficient count ({}) does not match batch size ({})",
118 coefficients.len(),
119 n
120 )));
121 }
122 }
123 Ok(n)
124}
125
126#[derive(Debug, Clone)]
143pub struct LinearSoftmaxPolicy<T: Float + Debug + Send + Sync + 'static> {
144 weights: Array2<T>,
146}
147
148impl<T: Float + Debug + Send + Sync + 'static> LinearSoftmaxPolicy<T> {
149 pub fn new(n_actions: usize, n_features: usize) -> Result<Self> {
151 if n_actions == 0 || n_features == 0 {
152 return Err(OptimError::InvalidConfig(
153 "LinearSoftmaxPolicy requires n_actions > 0 and n_features > 0".to_string(),
154 ));
155 }
156 Ok(Self {
157 weights: Array2::zeros((n_actions, n_features)),
158 })
159 }
160
161 pub fn from_weights(weights: Array2<T>) -> Result<Self> {
163 if weights.nrows() == 0 || weights.ncols() == 0 {
164 return Err(OptimError::InvalidConfig(
165 "LinearSoftmaxPolicy weights must be non-empty".to_string(),
166 ));
167 }
168 Ok(Self { weights })
169 }
170
171 pub fn n_actions(&self) -> usize {
173 self.weights.nrows()
174 }
175
176 pub fn n_features(&self) -> usize {
178 self.weights.ncols()
179 }
180
181 pub fn weights(&self) -> &Array2<T> {
183 &self.weights
184 }
185
186 pub fn logits(&self, observations: &Array2<T>) -> Result<Array2<T>> {
188 let n = check_batch(observations, self.n_features(), None, None)?;
189 let mut logits = Array2::zeros((n, self.n_actions()));
190 for i in 0..n {
191 for a in 0..self.n_actions() {
192 let mut acc = T::zero();
193 for f in 0..self.n_features() {
194 acc = acc + self.weights[[a, f]] * observations[[i, f]];
195 }
196 logits[[i, a]] = acc;
197 }
198 }
199 Ok(logits)
200 }
201
202 pub fn probabilities(&self, observations: &Array2<T>) -> Result<Array2<T>> {
204 let logits = self.logits(observations)?;
205 Ok(softmax_rows(&logits))
206 }
207
208 pub fn one_hot(&self, index: usize) -> Result<Array1<T>> {
210 if index >= self.n_actions() {
211 return Err(OptimError::InvalidParameter(format!(
212 "action index {index} out of range for {} actions",
213 self.n_actions()
214 )));
215 }
216 let mut row = Array1::zeros(self.n_actions());
217 row[index] = T::one();
218 Ok(row)
219 }
220
221 pub fn sample_actions_with(
228 &self,
229 observations: &Array2<T>,
230 mut uniform: impl FnMut() -> f64,
231 ) -> Result<(Array2<T>, Array1<T>, Vec<usize>)> {
232 let probs = self.probabilities(observations)?;
233 let n = probs.nrows();
234 let n_actions = self.n_actions();
235
236 let mut actions = Array2::zeros((n, n_actions));
237 let mut log_probs = Array1::zeros(n);
238 let mut indices = Vec::with_capacity(n);
239
240 for i in 0..n {
241 let u = scalar::<T>(uniform());
242 let mut cumulative = T::zero();
243 let mut chosen = n_actions - 1;
246 for a in 0..n_actions {
247 cumulative = cumulative + probs[[i, a]];
248 if u <= cumulative {
249 chosen = a;
250 break;
251 }
252 }
253 actions[[i, chosen]] = T::one();
254 log_probs[i] = safe_ln(probs[[i, chosen]]);
255 indices.push(chosen);
256 }
257
258 Ok((actions, log_probs, indices))
259 }
260
261 fn action_index(&self, actions: &Array2<T>, i: usize) -> usize {
263 let mut best = 0usize;
264 let mut best_value = actions[[i, 0]];
265 for a in 1..actions.ncols() {
266 if actions[[i, a]] > best_value {
267 best_value = actions[[i, a]];
268 best = a;
269 }
270 }
271 best
272 }
273
274 fn deltas(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
276 let n = check_batch(
277 observations,
278 self.n_features(),
279 Some((actions, self.n_actions())),
280 None,
281 )?;
282 let probs = self.probabilities(observations)?;
283 let mut deltas = Array2::zeros((n, self.n_actions()));
284 for i in 0..n {
285 let chosen = self.action_index(actions, i);
286 for a in 0..self.n_actions() {
287 let indicator = if a == chosen { T::one() } else { T::zero() };
288 deltas[[i, a]] = indicator - probs[[i, a]];
289 }
290 }
291 Ok(deltas)
292 }
293}
294
295impl<T: Float + Debug + Send + Sync + 'static> PolicyNetwork<T> for LinearSoftmaxPolicy<T> {
296 fn evaluate_actions(
297 &self,
298 observations: &Array2<T>,
299 actions: &Array2<T>,
300 ) -> Result<PolicyEvaluation<T>> {
301 let n = check_batch(
302 observations,
303 self.n_features(),
304 Some((actions, self.n_actions())),
305 None,
306 )?;
307 let probs = self.probabilities(observations)?;
308
309 let mut log_probs = Array1::zeros(n);
310 let mut entropy = Array1::zeros(n);
311 for i in 0..n {
312 let chosen = self.action_index(actions, i);
313 log_probs[i] = safe_ln(probs[[i, chosen]]);
314
315 let mut h = T::zero();
316 for a in 0..self.n_actions() {
317 let p = probs[[i, a]];
318 if p > T::zero() {
319 h = h - p * p.ln();
320 }
321 }
322 entropy[i] = h;
323 }
324
325 Ok(PolicyEvaluation {
326 log_probs,
327 entropy,
328 metrics: HashMap::new(),
329 })
330 }
331
332 fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>> {
333 Ok(ActionDistribution {
334 mean: None,
335 std: None,
336 logits: Some(self.logits(observations)?),
337 distribution_type: DistributionType::Categorical,
338 })
339 }
340
341 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
342 if let Some(delta) = deltas.get(WEIGHTS) {
343 add_flat_to_matrix(&mut self.weights, delta, "LinearSoftmaxPolicy weights")?;
344 }
345 Ok(())
346 }
347
348 fn get_parameters(&self) -> HashMap<String, Array1<T>> {
349 let mut map = HashMap::with_capacity(1);
350 map.insert(WEIGHTS.to_string(), flat_from_matrix(&self.weights));
351 map
352 }
353
354 fn log_prob_gradient(
355 &self,
356 observations: &Array2<T>,
357 actions: &Array2<T>,
358 coefficients: &Array1<T>,
359 ) -> Result<HashMap<String, Array1<T>>> {
360 let n = check_batch(
361 observations,
362 self.n_features(),
363 Some((actions, self.n_actions())),
364 Some(coefficients),
365 )?;
366 let deltas = self.deltas(observations, actions)?;
367
368 let mut grad = Array2::zeros((self.n_actions(), self.n_features()));
369 for i in 0..n {
370 let c = coefficients[i];
371 if c == T::zero() {
372 continue;
373 }
374 for a in 0..self.n_actions() {
375 let d = c * deltas[[i, a]];
376 if d == T::zero() {
377 continue;
378 }
379 for f in 0..self.n_features() {
380 grad[[a, f]] = grad[[a, f]] + d * observations[[i, f]];
381 }
382 }
383 }
384
385 let mut map = HashMap::with_capacity(1);
386 map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad));
387 Ok(map)
388 }
389
390 fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
391 let n = check_batch(observations, self.n_features(), None, None)?;
392 let probs = self.probabilities(observations)?;
393
394 let mut grad = Array2::zeros((self.n_actions(), self.n_features()));
395 if n == 0 {
396 let mut map = HashMap::with_capacity(1);
397 map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad));
398 return Ok(map);
399 }
400 let inv_n = T::one() / scalar::<T>(n as f64);
401
402 for i in 0..n {
403 let mut h = T::zero();
405 for a in 0..self.n_actions() {
406 let p = probs[[i, a]];
407 if p > T::zero() {
408 h = h - p * p.ln();
409 }
410 }
411 for a in 0..self.n_actions() {
412 let p = probs[[i, a]];
413 if p <= T::zero() {
414 continue;
415 }
416 let d = -p * (p.ln() + h) * inv_n;
417 for f in 0..self.n_features() {
418 grad[[a, f]] = grad[[a, f]] + d * observations[[i, f]];
419 }
420 }
421 }
422
423 let mut map = HashMap::with_capacity(1);
424 map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad));
425 Ok(map)
426 }
427
428 fn score_matrix(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
429 let n = check_batch(
430 observations,
431 self.n_features(),
432 Some((actions, self.n_actions())),
433 None,
434 )?;
435 let deltas = self.deltas(observations, actions)?;
436
437 let dim = self.n_actions() * self.n_features();
438 let mut scores = Array2::zeros((n, dim));
439 for i in 0..n {
440 for a in 0..self.n_actions() {
441 let d = deltas[[i, a]];
442 for f in 0..self.n_features() {
443 scores[[i, a * self.n_features() + f]] = d * observations[[i, f]];
444 }
445 }
446 }
447 Ok(scores)
448 }
449
450 fn kronecker_factors(
451 &self,
452 observations: &Array2<T>,
453 actions: &Array2<T>,
454 ) -> Result<Vec<KroneckerBlock<T>>> {
455 let deltas = self.deltas(observations, actions)?;
456 Ok(vec![KroneckerBlock {
457 name: WEIGHTS.to_string(),
458 inputs: observations.clone(),
459 outputs: deltas,
460 }])
461 }
462}
463
464#[derive(Debug, Clone)]
481pub struct LinearGaussianPolicy<T: Float + Debug + Send + Sync + 'static> {
482 weights: Array2<T>,
484 log_std: Array1<T>,
486}
487
488impl<T: Float + Debug + Send + Sync + 'static> LinearGaussianPolicy<T> {
489 pub fn new(action_dim: usize, n_features: usize, init_std: T) -> Result<Self> {
491 if action_dim == 0 || n_features == 0 {
492 return Err(OptimError::InvalidConfig(
493 "LinearGaussianPolicy requires action_dim > 0 and n_features > 0".to_string(),
494 ));
495 }
496 if !matches!(
497 init_std.partial_cmp(&T::zero()),
498 Some(std::cmp::Ordering::Greater)
499 ) {
500 return Err(OptimError::InvalidConfig(
501 "LinearGaussianPolicy requires init_std > 0".to_string(),
502 ));
503 }
504 Ok(Self {
505 weights: Array2::zeros((action_dim, n_features)),
506 log_std: Array1::from_elem(action_dim, init_std.ln()),
507 })
508 }
509
510 pub fn action_dim(&self) -> usize {
512 self.weights.nrows()
513 }
514
515 pub fn n_features(&self) -> usize {
517 self.weights.ncols()
518 }
519
520 pub fn weights(&self) -> &Array2<T> {
522 &self.weights
523 }
524
525 pub fn std(&self) -> Array1<T> {
527 self.log_std.mapv(|l| l.exp().max(min_sigma::<T>()))
528 }
529
530 pub fn mean_actions(&self, observations: &Array2<T>) -> Result<Array2<T>> {
532 let n = check_batch(observations, self.n_features(), None, None)?;
533 let mut mean = Array2::zeros((n, self.action_dim()));
534 for i in 0..n {
535 for a in 0..self.action_dim() {
536 let mut acc = T::zero();
537 for f in 0..self.n_features() {
538 acc = acc + self.weights[[a, f]] * observations[[i, f]];
539 }
540 mean[[i, a]] = acc;
541 }
542 }
543 Ok(mean)
544 }
545
546 pub fn sample_actions_with(
550 &self,
551 observations: &Array2<T>,
552 mut standard_normal: impl FnMut() -> f64,
553 ) -> Result<(Array2<T>, Array1<T>)> {
554 let mean = self.mean_actions(observations)?;
555 let std = self.std();
556 let n = mean.nrows();
557
558 let mut actions = Array2::zeros((n, self.action_dim()));
559 for i in 0..n {
560 for a in 0..self.action_dim() {
561 let z = scalar::<T>(standard_normal());
562 actions[[i, a]] = mean[[i, a]] + std[a] * z;
563 }
564 }
565
566 let evaluation = self.evaluate_actions(observations, &actions)?;
567 Ok((actions, evaluation.log_probs))
568 }
569
570 fn standardized_residual(
572 &self,
573 observations: &Array2<T>,
574 actions: &Array2<T>,
575 ) -> Result<Array2<T>> {
576 let n = check_batch(
577 observations,
578 self.n_features(),
579 Some((actions, self.action_dim())),
580 None,
581 )?;
582 let mean = self.mean_actions(observations)?;
583 let std = self.std();
584
585 let mut residual = Array2::zeros((n, self.action_dim()));
586 for i in 0..n {
587 for a in 0..self.action_dim() {
588 let sigma = std[a];
589 residual[[i, a]] = (actions[[i, a]] - mean[[i, a]]) / (sigma * sigma);
590 }
591 }
592 Ok(residual)
593 }
594}
595
596impl<T: Float + Debug + Send + Sync + 'static> PolicyNetwork<T> for LinearGaussianPolicy<T> {
597 fn evaluate_actions(
598 &self,
599 observations: &Array2<T>,
600 actions: &Array2<T>,
601 ) -> Result<PolicyEvaluation<T>> {
602 let n = check_batch(
603 observations,
604 self.n_features(),
605 Some((actions, self.action_dim())),
606 None,
607 )?;
608 let mean = self.mean_actions(observations)?;
609 let std = self.std();
610
611 let half = scalar::<T>(0.5);
612 let half_log_two_pi = scalar::<T>(0.5 * (2.0 * std::f64::consts::PI).ln());
613 let half_log_two_pi_e = scalar::<T>(0.5 * (2.0 * std::f64::consts::PI * 1.0f64.exp()).ln());
615
616 let mut log_probs = Array1::zeros(n);
617 let mut entropy_value = T::zero();
618 for a in 0..self.action_dim() {
619 entropy_value = entropy_value + std[a].ln() + half_log_two_pi_e;
620 }
621
622 for i in 0..n {
623 let mut lp = T::zero();
624 for a in 0..self.action_dim() {
625 let sigma = std[a];
626 let z = (actions[[i, a]] - mean[[i, a]]) / sigma;
627 lp = lp - half * z * z - sigma.ln() - half_log_two_pi;
628 }
629 log_probs[i] = lp;
630 }
631
632 Ok(PolicyEvaluation {
633 log_probs,
634 entropy: Array1::from_elem(n, entropy_value),
635 metrics: HashMap::new(),
636 })
637 }
638
639 fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>> {
640 let mean = self.mean_actions(observations)?;
641 let std_vec = self.std();
642 let mut std = Array2::zeros(mean.dim());
643 for i in 0..mean.nrows() {
644 for a in 0..self.action_dim() {
645 std[[i, a]] = std_vec[a];
646 }
647 }
648 Ok(ActionDistribution {
649 mean: Some(mean),
650 std: Some(std),
651 logits: None,
652 distribution_type: DistributionType::Gaussian,
653 })
654 }
655
656 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
657 if let Some(delta) = deltas.get(WEIGHTS) {
658 add_flat_to_matrix(&mut self.weights, delta, "LinearGaussianPolicy weights")?;
659 }
660 if let Some(delta) = deltas.get(LOG_STD) {
661 if delta.len() != self.log_std.len() {
662 return Err(OptimError::DimensionMismatch(format!(
663 "LinearGaussianPolicy log_std delta length ({}) does not match action_dim ({})",
664 delta.len(),
665 self.log_std.len()
666 )));
667 }
668 for a in 0..self.log_std.len() {
669 self.log_std[a] = self.log_std[a] + delta[a];
670 }
671 }
672 Ok(())
673 }
674
675 fn get_parameters(&self) -> HashMap<String, Array1<T>> {
676 let mut map = HashMap::with_capacity(2);
677 map.insert(WEIGHTS.to_string(), flat_from_matrix(&self.weights));
678 map.insert(LOG_STD.to_string(), self.log_std.clone());
679 map
680 }
681
682 fn log_prob_gradient(
683 &self,
684 observations: &Array2<T>,
685 actions: &Array2<T>,
686 coefficients: &Array1<T>,
687 ) -> Result<HashMap<String, Array1<T>>> {
688 let n = check_batch(
689 observations,
690 self.n_features(),
691 Some((actions, self.action_dim())),
692 Some(coefficients),
693 )?;
694 let mean = self.mean_actions(observations)?;
695 let std = self.std();
696
697 let mut grad_w = Array2::zeros((self.action_dim(), self.n_features()));
698 let mut grad_log_std = Array1::zeros(self.action_dim());
699
700 for i in 0..n {
701 let c = coefficients[i];
702 if c == T::zero() {
703 continue;
704 }
705 for a in 0..self.action_dim() {
706 let sigma = std[a];
707 let diff = actions[[i, a]] - mean[[i, a]];
708 let z = diff / sigma;
709
710 let dmu = c * diff / (sigma * sigma);
712 for f in 0..self.n_features() {
713 grad_w[[a, f]] = grad_w[[a, f]] + dmu * observations[[i, f]];
714 }
715
716 grad_log_std[a] = grad_log_std[a] + c * (z * z - T::one());
718 }
719 }
720
721 let mut map = HashMap::with_capacity(2);
722 map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad_w));
723 map.insert(LOG_STD.to_string(), grad_log_std);
724 Ok(map)
725 }
726
727 fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
728 check_batch(observations, self.n_features(), None, None)?;
729 let mut map = HashMap::with_capacity(2);
731 map.insert(
732 WEIGHTS.to_string(),
733 Array1::zeros(self.action_dim() * self.n_features()),
734 );
735 map.insert(
736 LOG_STD.to_string(),
737 Array1::from_elem(self.action_dim(), T::one()),
738 );
739 Ok(map)
740 }
741
742 fn mean_action_gradient(
743 &self,
744 observations: &Array2<T>,
745 weights: &Array2<T>,
746 ) -> Result<HashMap<String, Array1<T>>> {
747 let n = check_batch(
748 observations,
749 self.n_features(),
750 Some((weights, self.action_dim())),
751 None,
752 )?;
753
754 let mut grad_w = Array2::zeros((self.action_dim(), self.n_features()));
755 for i in 0..n {
756 for a in 0..self.action_dim() {
757 let w = weights[[i, a]];
758 if w == T::zero() {
759 continue;
760 }
761 for f in 0..self.n_features() {
762 grad_w[[a, f]] = grad_w[[a, f]] + w * observations[[i, f]];
763 }
764 }
765 }
766
767 let mut map = HashMap::with_capacity(2);
768 map.insert(WEIGHTS.to_string(), flat_from_matrix(&grad_w));
769 map.insert(LOG_STD.to_string(), Array1::zeros(self.action_dim()));
770 Ok(map)
771 }
772
773 fn kronecker_factors(
774 &self,
775 observations: &Array2<T>,
776 actions: &Array2<T>,
777 ) -> Result<Vec<KroneckerBlock<T>>> {
778 let residual = self.standardized_residual(observations, actions)?;
779 let n = residual.nrows();
780 let std = self.std();
781
782 let mut log_std_outputs = Array2::zeros((n, self.action_dim()));
785 let mean = self.mean_actions(observations)?;
786 for i in 0..n {
787 for a in 0..self.action_dim() {
788 let z = (actions[[i, a]] - mean[[i, a]]) / std[a];
789 log_std_outputs[[i, a]] = z * z - T::one();
790 }
791 }
792
793 Ok(vec![
794 KroneckerBlock {
795 name: WEIGHTS.to_string(),
796 inputs: observations.clone(),
797 outputs: residual,
798 },
799 KroneckerBlock {
800 name: LOG_STD.to_string(),
801 inputs: Array2::from_elem((n, 1), T::one()),
802 outputs: log_std_outputs,
803 },
804 ])
805 }
806}
807
808#[derive(Debug, Clone)]
814pub struct LinearValueFunction<T: Float + Debug + Send + Sync + 'static> {
815 weights: Array1<T>,
816}
817
818impl<T: Float + Debug + Send + Sync + 'static> LinearValueFunction<T> {
819 pub fn new(n_features: usize) -> Result<Self> {
821 if n_features == 0 {
822 return Err(OptimError::InvalidConfig(
823 "LinearValueFunction requires n_features > 0".to_string(),
824 ));
825 }
826 Ok(Self {
827 weights: Array1::zeros(n_features),
828 })
829 }
830
831 pub fn n_features(&self) -> usize {
833 self.weights.len()
834 }
835
836 pub fn weights(&self) -> &Array1<T> {
838 &self.weights
839 }
840}
841
842impl<T: Float + Debug + Send + Sync + 'static> ValueNetwork<T> for LinearValueFunction<T> {
843 fn evaluate_value(&self, observations: &Array2<T>) -> Result<Array1<T>> {
844 let n = check_batch(observations, self.n_features(), None, None)?;
845 let mut values = Array1::zeros(n);
846 for i in 0..n {
847 let mut acc = T::zero();
848 for f in 0..self.n_features() {
849 acc = acc + self.weights[f] * observations[[i, f]];
850 }
851 values[i] = acc;
852 }
853 Ok(values)
854 }
855
856 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
857 if let Some(delta) = deltas.get(WEIGHTS) {
858 if delta.len() != self.weights.len() {
859 return Err(OptimError::DimensionMismatch(format!(
860 "LinearValueFunction delta length ({}) does not match n_features ({})",
861 delta.len(),
862 self.weights.len()
863 )));
864 }
865 for f in 0..self.weights.len() {
866 self.weights[f] = self.weights[f] + delta[f];
867 }
868 }
869 Ok(())
870 }
871
872 fn get_parameters(&self) -> HashMap<String, Array1<T>> {
873 let mut map = HashMap::with_capacity(1);
874 map.insert(WEIGHTS.to_string(), self.weights.clone());
875 map
876 }
877
878 fn value_gradient(
879 &self,
880 observations: &Array2<T>,
881 residuals: &Array1<T>,
882 ) -> Result<HashMap<String, Array1<T>>> {
883 let n = check_batch(observations, self.n_features(), None, Some(residuals))?;
884 let mut grad = Array1::zeros(self.n_features());
885 for i in 0..n {
886 let r = residuals[i];
887 if r == T::zero() {
888 continue;
889 }
890 for f in 0..self.n_features() {
891 grad[f] = grad[f] + r * observations[[i, f]];
892 }
893 }
894 let mut map = HashMap::with_capacity(1);
895 map.insert(WEIGHTS.to_string(), grad);
896 Ok(map)
897 }
898}
899
900#[derive(Debug, Clone)]
918pub struct LinearQFunction<T: Float + Debug + Send + Sync + 'static> {
919 weights: Array1<T>,
920 state_dim: usize,
921 action_dim: usize,
922}
923
924impl<T: Float + Debug + Send + Sync + 'static> LinearQFunction<T> {
925 pub fn new(state_dim: usize, action_dim: usize) -> Result<Self> {
927 if state_dim == 0 || action_dim == 0 {
928 return Err(OptimError::InvalidConfig(
929 "LinearQFunction requires state_dim > 0 and action_dim > 0".to_string(),
930 ));
931 }
932 Ok(Self {
933 weights: Array1::zeros(state_dim + action_dim + state_dim * action_dim),
934 state_dim,
935 action_dim,
936 })
937 }
938
939 pub fn state_dim(&self) -> usize {
941 self.state_dim
942 }
943
944 pub fn action_dim(&self) -> usize {
946 self.action_dim
947 }
948
949 pub fn weights(&self) -> &Array1<T> {
951 &self.weights
952 }
953
954 fn bilinear_offset(&self) -> usize {
956 self.state_dim + self.action_dim
957 }
958
959 fn features(&self, states: &Array2<T>, actions: &Array2<T>, i: usize) -> Array1<T> {
961 let mut phi = Array1::zeros(self.weights.len());
962 for s in 0..self.state_dim {
963 phi[s] = states[[i, s]];
964 }
965 for a in 0..self.action_dim {
966 phi[self.state_dim + a] = actions[[i, a]];
967 }
968 let base = self.bilinear_offset();
969 for s in 0..self.state_dim {
970 for a in 0..self.action_dim {
971 phi[base + s * self.action_dim + a] = states[[i, s]] * actions[[i, a]];
972 }
973 }
974 phi
975 }
976
977 fn check_pairs(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<usize> {
978 check_batch(
979 states,
980 self.state_dim,
981 Some((actions, self.action_dim)),
982 None,
983 )
984 }
985}
986
987impl<T: Float + Debug + Send + Sync + 'static> ValueNetwork<T> for LinearQFunction<T> {
988 fn evaluate_value(&self, observations: &Array2<T>) -> Result<Array1<T>> {
989 let _ = observations;
990 Err(OptimError::UnsupportedOperation(
991 "LinearQFunction is an action-value critic: use QNetwork::evaluate_q(states, actions). \
992 A state value V(s) is only defined relative to a policy."
993 .to_string(),
994 ))
995 }
996
997 fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
998 if let Some(delta) = deltas.get(WEIGHTS) {
999 if delta.len() != self.weights.len() {
1000 return Err(OptimError::DimensionMismatch(format!(
1001 "LinearQFunction delta length ({}) does not match feature count ({})",
1002 delta.len(),
1003 self.weights.len()
1004 )));
1005 }
1006 for f in 0..self.weights.len() {
1007 self.weights[f] = self.weights[f] + delta[f];
1008 }
1009 }
1010 Ok(())
1011 }
1012
1013 fn get_parameters(&self) -> HashMap<String, Array1<T>> {
1014 let mut map = HashMap::with_capacity(1);
1015 map.insert(WEIGHTS.to_string(), self.weights.clone());
1016 map
1017 }
1018
1019 fn value_gradient(
1020 &self,
1021 observations: &Array2<T>,
1022 residuals: &Array1<T>,
1023 ) -> Result<HashMap<String, Array1<T>>> {
1024 let _ = (observations, residuals);
1025 Err(OptimError::UnsupportedOperation(
1026 "LinearQFunction is an action-value critic: use QNetwork::q_gradient".to_string(),
1027 ))
1028 }
1029}
1030
1031impl<T: Float + Debug + Send + Sync + 'static> QNetwork<T> for LinearQFunction<T> {
1032 fn evaluate_q(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array1<T>> {
1033 let n = self.check_pairs(states, actions)?;
1034 let mut q = Array1::zeros(n);
1035 for i in 0..n {
1036 let phi = self.features(states, actions, i);
1037 let mut acc = T::zero();
1038 for f in 0..self.weights.len() {
1039 acc = acc + self.weights[f] * phi[f];
1040 }
1041 q[i] = acc;
1042 }
1043 Ok(q)
1044 }
1045
1046 fn q_gradient(
1047 &self,
1048 states: &Array2<T>,
1049 actions: &Array2<T>,
1050 residuals: &Array1<T>,
1051 ) -> Result<HashMap<String, Array1<T>>> {
1052 let n = self.check_pairs(states, actions)?;
1053 if residuals.len() != n {
1054 return Err(OptimError::DimensionMismatch(format!(
1055 "residual count ({}) does not match batch size ({n})",
1056 residuals.len()
1057 )));
1058 }
1059
1060 let mut grad = Array1::zeros(self.weights.len());
1061 for i in 0..n {
1062 let r = residuals[i];
1063 if r == T::zero() {
1064 continue;
1065 }
1066 let phi = self.features(states, actions, i);
1067 for f in 0..self.weights.len() {
1068 grad[f] = grad[f] + r * phi[f];
1069 }
1070 }
1071
1072 let mut map = HashMap::with_capacity(1);
1073 map.insert(WEIGHTS.to_string(), grad);
1074 Ok(map)
1075 }
1076
1077 fn action_gradient(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
1078 let n = self.check_pairs(states, actions)?;
1079 let base = self.bilinear_offset();
1080
1081 let mut grad = Array2::zeros((n, self.action_dim));
1082 for i in 0..n {
1083 for a in 0..self.action_dim {
1084 let mut acc = self.weights[self.state_dim + a];
1086 for s in 0..self.state_dim {
1087 acc = acc + self.weights[base + s * self.action_dim + a] * states[[i, s]];
1088 }
1089 grad[[i, a]] = acc;
1090 }
1091 }
1092 Ok(grad)
1093 }
1094}
1095
1096fn softmax_rows<T: Float + Debug + Send + Sync + 'static>(logits: &Array2<T>) -> Array2<T> {
1102 let (n, k) = logits.dim();
1103 let mut probs = Array2::zeros((n, k));
1104 for i in 0..n {
1105 let mut max_logit = T::neg_infinity();
1106 for a in 0..k {
1107 if logits[[i, a]] > max_logit {
1108 max_logit = logits[[i, a]];
1109 }
1110 }
1111 let mut sum = T::zero();
1112 for a in 0..k {
1113 let e = (logits[[i, a]] - max_logit).exp();
1114 probs[[i, a]] = e;
1115 sum = sum + e;
1116 }
1117 if sum > T::zero() {
1118 for a in 0..k {
1119 probs[[i, a]] = probs[[i, a]] / sum;
1120 }
1121 } else {
1122 let uniform = T::one() / scalar::<T>(k as f64);
1125 for a in 0..k {
1126 probs[[i, a]] = uniform;
1127 }
1128 }
1129 }
1130 probs
1131}
1132
1133fn safe_ln<T: Float>(x: T) -> T {
1135 let floor = T::from(1e-300).unwrap_or_else(T::min_positive_value);
1136 x.max(floor).ln()
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141 use super::*;
1142 use scirs2_core::ndarray::{arr1, arr2};
1143
1144 fn fd_gradient(len: usize, eps: f64, mut f: impl FnMut(&[f64]) -> f64, at: &[f64]) -> Vec<f64> {
1146 let mut out = vec![0.0; len];
1147 let mut probe = at.to_vec();
1148 for i in 0..len {
1149 probe[i] = at[i] + eps;
1150 let plus = f(&probe);
1151 probe[i] = at[i] - eps;
1152 let minus = f(&probe);
1153 probe[i] = at[i];
1154 out[i] = (plus - minus) / (2.0 * eps);
1155 }
1156 out
1157 }
1158
1159 fn observations() -> Array2<f64> {
1160 arr2(&[[1.0, 0.5], [0.2, -1.3], [-0.7, 0.9]])
1161 }
1162
1163 #[test]
1164 fn softmax_log_prob_gradient_matches_finite_differences() {
1165 let obs = observations();
1166 let actions = arr2(&[[1.0, 0.0], [0.0, 1.0], [1.0, 0.0]]);
1167 let coeffs = arr1(&[0.7, -1.1, 0.3]);
1168
1169 let init = vec![0.3, -0.2, 0.1, 0.4];
1170 let policy = LinearSoftmaxPolicy::from_weights(
1171 Array2::from_shape_vec((2, 2), init.clone()).expect("shape"),
1172 )
1173 .expect("policy");
1174
1175 let analytic = policy
1176 .log_prob_gradient(&obs, &actions, &coeffs)
1177 .expect("grad");
1178 let analytic = analytic[WEIGHTS].to_vec();
1179
1180 let numeric = fd_gradient(
1181 4,
1182 1e-6,
1183 |w| {
1184 let p = LinearSoftmaxPolicy::from_weights(
1185 Array2::from_shape_vec((2, 2), w.to_vec()).expect("shape"),
1186 )
1187 .expect("policy");
1188 let eval = p.evaluate_actions(&obs, &actions).expect("eval");
1189 eval.log_probs
1190 .iter()
1191 .zip(coeffs.iter())
1192 .map(|(&lp, &c)| c * lp)
1193 .sum::<f64>()
1194 },
1195 &init,
1196 );
1197
1198 for (a, n) in analytic.iter().zip(numeric.iter()) {
1199 assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1200 }
1201 }
1202
1203 #[test]
1204 fn softmax_entropy_gradient_matches_finite_differences() {
1205 let obs = observations();
1206 let init = vec![0.3, -0.2, 0.1, 0.4];
1207 let policy = LinearSoftmaxPolicy::from_weights(
1208 Array2::from_shape_vec((2, 2), init.clone()).expect("shape"),
1209 )
1210 .expect("policy");
1211
1212 let analytic = policy.entropy_gradient(&obs).expect("grad")[WEIGHTS].to_vec();
1213
1214 let numeric = fd_gradient(
1215 4,
1216 1e-6,
1217 |w| {
1218 let p = LinearSoftmaxPolicy::from_weights(
1219 Array2::from_shape_vec((2, 2), w.to_vec()).expect("shape"),
1220 )
1221 .expect("policy");
1222 let probs = p.probabilities(&obs).expect("probs");
1223 let n = probs.nrows() as f64;
1224 let mut total = 0.0;
1225 for i in 0..probs.nrows() {
1226 for a in 0..probs.ncols() {
1227 let pv = probs[[i, a]];
1228 if pv > 0.0 {
1229 total -= pv * pv.ln();
1230 }
1231 }
1232 }
1233 total / n
1234 },
1235 &init,
1236 );
1237
1238 for (a, n) in analytic.iter().zip(numeric.iter()) {
1239 assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1240 }
1241 }
1242
1243 #[test]
1244 fn softmax_score_matrix_matches_kronecker_factors() {
1245 let obs = observations();
1246 let actions = arr2(&[[1.0, 0.0], [0.0, 1.0], [1.0, 0.0]]);
1247 let policy = LinearSoftmaxPolicy::from_weights(
1248 Array2::from_shape_vec((2, 2), vec![0.3, -0.2, 0.1, 0.4]).expect("shape"),
1249 )
1250 .expect("policy");
1251
1252 let scores = policy.score_matrix(&obs, &actions).expect("scores");
1253 let blocks = policy.kronecker_factors(&obs, &actions).expect("kfac");
1254 assert_eq!(blocks.len(), 1);
1255 let block = &blocks[0];
1256
1257 for i in 0..obs.nrows() {
1259 for a in 0..2 {
1260 for f in 0..2 {
1261 let expected = block.outputs[[i, a]] * block.inputs[[i, f]];
1262 assert!((scores[[i, a * 2 + f]] - expected).abs() < 1e-12);
1263 }
1264 }
1265 }
1266 }
1267
1268 #[test]
1269 fn gaussian_log_prob_gradient_matches_finite_differences() {
1270 let obs = observations();
1271 let actions = arr2(&[[0.4], [-0.9], [1.2]]);
1272 let coeffs = arr1(&[1.0, -0.5, 0.25]);
1273
1274 let init = vec![-0.3_f64, 0.6, -0.4];
1276 let build = |p: &[f64]| {
1277 let mut policy = LinearGaussianPolicy::<f64>::new(1, 2, 1.0).expect("policy");
1278 let mut deltas = HashMap::new();
1279 deltas.insert(LOG_STD.to_string(), arr1(&[p[0]]));
1280 deltas.insert(WEIGHTS.to_string(), arr1(&[p[1], p[2]]));
1281 policy.update_parameters(&deltas).expect("update");
1282 policy
1283 };
1284
1285 let policy = build(&init);
1286 let grad = policy
1287 .log_prob_gradient(&obs, &actions, &coeffs)
1288 .expect("grad");
1289 let mut analytic = grad[LOG_STD].to_vec();
1290 analytic.extend(grad[WEIGHTS].to_vec());
1291
1292 let numeric = fd_gradient(
1293 3,
1294 1e-6,
1295 |p| {
1296 let policy = build(p);
1297 let eval = policy.evaluate_actions(&obs, &actions).expect("eval");
1298 eval.log_probs
1299 .iter()
1300 .zip(coeffs.iter())
1301 .map(|(&lp, &c)| c * lp)
1302 .sum::<f64>()
1303 },
1304 &init,
1305 );
1306
1307 for (a, n) in analytic.iter().zip(numeric.iter()) {
1308 assert!((a - n).abs() < 1e-5, "analytic {a} vs numeric {n}");
1309 }
1310 }
1311
1312 #[test]
1313 fn gaussian_mean_action_gradient_matches_finite_differences() {
1314 let obs = observations();
1315 let weights = arr2(&[[0.5], [-1.0], [2.0]]);
1316
1317 let init = vec![0.6_f64, -0.4];
1318 let build = |p: &[f64]| {
1319 let mut policy = LinearGaussianPolicy::<f64>::new(1, 2, 1.0).expect("policy");
1320 let mut deltas = HashMap::new();
1321 deltas.insert(WEIGHTS.to_string(), arr1(&[p[0], p[1]]));
1322 policy.update_parameters(&deltas).expect("update");
1323 policy
1324 };
1325
1326 let policy = build(&init);
1327 let analytic = policy.mean_action_gradient(&obs, &weights).expect("grad")[WEIGHTS].to_vec();
1328
1329 let numeric = fd_gradient(
1330 2,
1331 1e-6,
1332 |p| {
1333 let policy = build(p);
1334 let mean = policy.mean_actions(&obs).expect("mean");
1335 let mut total = 0.0;
1336 for i in 0..mean.nrows() {
1337 total += weights[[i, 0]] * mean[[i, 0]];
1338 }
1339 total
1340 },
1341 &init,
1342 );
1343
1344 for (a, n) in analytic.iter().zip(numeric.iter()) {
1345 assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1346 }
1347 }
1348
1349 #[test]
1350 fn q_function_action_gradient_is_state_dependent() {
1351 let mut q = LinearQFunction::<f64>::new(2, 1).expect("q");
1352 let mut deltas = HashMap::new();
1354 deltas.insert(WEIGHTS.to_string(), arr1(&[1.0, 2.0, 0.5, 1.0, -3.0]));
1355 q.update_parameters(&deltas).expect("update");
1356
1357 let states = arr2(&[[1.0, 0.0], [0.0, 1.0]]);
1358 let actions = arr2(&[[0.3], [0.3]]);
1359 let grad = q.action_gradient(&states, &actions).expect("dq/da");
1360
1361 assert!((grad[[0, 0]] - (0.5 + 1.0)).abs() < 1e-12);
1362 assert!((grad[[1, 0]] - (0.5 - 3.0)).abs() < 1e-12);
1363 assert!(
1364 (grad[[0, 0]] - grad[[1, 0]]).abs() > 1e-6,
1365 "bilinear features must make dQ/da state dependent"
1366 );
1367 }
1368
1369 #[test]
1370 fn q_function_gradient_matches_finite_differences() {
1371 let states = arr2(&[[1.0, 0.5], [-0.3, 0.8]]);
1372 let actions = arr2(&[[0.4], [-0.6]]);
1373 let residuals = arr1(&[1.5, -0.5]);
1374
1375 let init = vec![0.2_f64, -0.1, 0.7, 0.3, -0.4];
1376 let build = |p: &[f64]| {
1377 let mut q = LinearQFunction::<f64>::new(2, 1).expect("q");
1378 let mut deltas = HashMap::new();
1379 deltas.insert(WEIGHTS.to_string(), Array1::from_vec(p.to_vec()));
1380 q.update_parameters(&deltas).expect("update");
1381 q
1382 };
1383
1384 let q = build(&init);
1385 let analytic = q.q_gradient(&states, &actions, &residuals).expect("grad")[WEIGHTS].to_vec();
1386
1387 let numeric = fd_gradient(
1388 5,
1389 1e-6,
1390 |p| {
1391 let q = build(p);
1392 let values = q.evaluate_q(&states, &actions).expect("q");
1393 values
1394 .iter()
1395 .zip(residuals.iter())
1396 .map(|(&v, &r)| r * v)
1397 .sum::<f64>()
1398 },
1399 &init,
1400 );
1401
1402 for (a, n) in analytic.iter().zip(numeric.iter()) {
1403 assert!((a - n).abs() < 1e-6, "analytic {a} vs numeric {n}");
1404 }
1405 }
1406
1407 #[test]
1408 fn q_function_rejects_state_value_queries() {
1409 let q = LinearQFunction::<f64>::new(2, 1).expect("q");
1410 assert!(q.evaluate_value(&arr2(&[[1.0, 2.0]])).is_err());
1411 }
1412
1413 #[test]
1414 fn value_gradient_matches_finite_differences() {
1415 let obs = observations();
1416 let residuals = arr1(&[0.5, -1.0, 2.0]);
1417
1418 let init = vec![0.3_f64, -0.6];
1419 let build = |p: &[f64]| {
1420 let mut v = LinearValueFunction::<f64>::new(2).expect("value");
1421 let mut deltas = HashMap::new();
1422 deltas.insert(WEIGHTS.to_string(), Array1::from_vec(p.to_vec()));
1423 v.update_parameters(&deltas).expect("update");
1424 v
1425 };
1426
1427 let v = build(&init);
1428 let analytic = v.value_gradient(&obs, &residuals).expect("grad")[WEIGHTS].to_vec();
1429
1430 let numeric = fd_gradient(
1431 2,
1432 1e-6,
1433 |p| {
1434 let v = build(p);
1435 let values = v.evaluate_value(&obs).expect("values");
1436 values
1437 .iter()
1438 .zip(residuals.iter())
1439 .map(|(&val, &r)| r * val)
1440 .sum::<f64>()
1441 },
1442 &init,
1443 );
1444
1445 for (a, n) in analytic.iter().zip(numeric.iter()) {
1446 assert!((a - n).abs() < 1e-8, "analytic {a} vs numeric {n}");
1447 }
1448 }
1449
1450 #[test]
1451 fn softmax_sampling_follows_the_distribution() {
1452 let policy = LinearSoftmaxPolicy::from_weights(
1454 Array2::from_shape_vec((2, 1), vec![10.0, 0.0]).expect("shape"),
1455 )
1456 .expect("policy");
1457 let obs = Array2::from_elem((50, 1), 1.0_f64);
1458
1459 let mut counter = 0usize;
1460 let (actions, log_probs, indices) = policy
1461 .sample_actions_with(&obs, || {
1462 counter += 1;
1463 (counter as f64) / 51.0
1465 })
1466 .expect("sample");
1467
1468 assert_eq!(actions.nrows(), 50);
1469 assert!(indices.iter().all(|&i| i == 0), "class 0 should dominate");
1470 assert!(log_probs.iter().all(|lp| lp.is_finite()));
1471 }
1472
1473 #[test]
1474 fn dimension_mismatches_are_rejected() {
1475 let policy = LinearSoftmaxPolicy::<f64>::new(2, 2).expect("policy");
1476 assert!(policy.logits(&arr2(&[[1.0, 2.0, 3.0]])).is_err());
1478 assert!(policy
1480 .evaluate_actions(&arr2(&[[1.0, 2.0]]), &arr2(&[[1.0, 0.0, 0.0]]))
1481 .is_err());
1482 assert!(policy
1484 .log_prob_gradient(
1485 &arr2(&[[1.0, 2.0]]),
1486 &arr2(&[[1.0, 0.0]]),
1487 &arr1(&[1.0, 2.0])
1488 )
1489 .is_err());
1490 }
1491}