1use std::sync::Arc;
17
18use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
19
20use crate::error::{DmlError, Result};
21
22#[derive(Debug, Clone)]
25pub struct Term {
26 pub w: Array2<f64>,
27 pub weight: Array1<f64>,
28}
29
30impl Term {
31 pub fn new(w: Array2<f64>, weight: Array1<f64>) -> Self {
32 Self { w, weight }
33 }
34
35 pub fn constant(w: Array2<f64>, c: f64) -> Self {
37 let n = w.nrows();
38 Self::new(w, Array1::from_elem(n, c))
39 }
40
41 pub fn rows(&self, idx: &[usize]) -> Term {
43 Term {
44 w: self.w.select(Axis(0), idx),
45 weight: idx.iter().map(|&i| self.weight[i]).collect(),
46 }
47 }
48}
49
50pub fn apply_terms(
52 terms: &[Term],
53 mut f: impl FnMut(ArrayView2<'_, f64>) -> Result<Array1<f64>>,
54) -> Result<Array1<f64>> {
55 let n = terms.first().map_or(0, |t| t.w.nrows());
56 let mut out = Array1::zeros(n);
57 for t in terms {
58 let v = f(t.w.view())?;
59 if v.len() != n {
60 return Err(DmlError::DimensionMismatch(
61 "functional term predictions".into(),
62 ));
63 }
64 out += &(&t.weight * &v);
65 }
66 Ok(out)
67}
68
69pub trait LinearFunctional: Send + Sync {
73 fn name(&self) -> String;
75
76 fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>>;
79}
80
81fn with_treatment(w: ArrayView2<'_, f64>, d: impl Fn(usize, f64) -> f64) -> Array2<f64> {
82 let mut out = w.to_owned();
83 for (i, v) in out.column_mut(0).iter_mut().enumerate() {
84 *v = d(i, *v);
85 }
86 out
87}
88
89fn std_dev(v: ArrayView1<'_, f64>) -> f64 {
90 let mean = v.mean().unwrap_or(0.0);
91 (v.mapv(|x| (x - mean).powi(2)).mean().unwrap_or(0.0)).sqrt()
92}
93
94#[derive(Debug, Clone, Copy, Default)]
96pub struct Ate;
97
98impl LinearFunctional for Ate {
99 fn name(&self) -> String {
100 "ATE".into()
101 }
102
103 fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
104 if w.column(0).iter().any(|&d| d != 0.0 && d != 1.0) {
105 return Err(DmlError::InvalidArgument(
106 "the ATE needs a 0/1 treatment".into(),
107 ));
108 }
109 Ok(vec![
110 Term::constant(with_treatment(w, |_, _| 1.0), 1.0),
111 Term::constant(with_treatment(w, |_, _| 0.0), -1.0),
112 ])
113 }
114}
115
116#[derive(Debug, Clone, Copy, Default)]
121pub struct AverageDerivative {
122 pub step: Option<f64>,
123}
124
125impl AverageDerivative {
126 pub fn new() -> Self {
127 Self::default()
128 }
129
130 pub fn step(mut self, h: f64) -> Self {
131 self.step = Some(h);
132 self
133 }
134}
135
136impl LinearFunctional for AverageDerivative {
137 fn name(&self) -> String {
138 "average derivative".into()
139 }
140
141 fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
142 let h = match self.step {
143 Some(h) => h,
144 None => 1e-4 * std_dev(w.column(0)),
145 };
146 if !(h.is_finite() && h > 0.0) {
147 return Err(DmlError::InvalidArgument(
148 "the derivative step must be positive (is the treatment constant?)".into(),
149 ));
150 }
151 Ok(vec![
152 Term::constant(with_treatment(w, |_, d| d + h), 0.5 / h),
153 Term::constant(with_treatment(w, |_, d| d - h), -0.5 / h),
154 ])
155 }
156}
157
158#[derive(Debug, Clone, Copy)]
161pub struct ShiftEffect {
162 pub delta: f64,
163 pub per_unit: bool,
164}
165
166impl ShiftEffect {
167 pub fn new(delta: f64) -> Self {
168 Self {
169 delta,
170 per_unit: false,
171 }
172 }
173
174 pub fn per_unit(mut self, per_unit: bool) -> Self {
176 self.per_unit = per_unit;
177 self
178 }
179}
180
181impl LinearFunctional for ShiftEffect {
182 fn name(&self) -> String {
183 format!("shift effect (δ = {})", self.delta)
184 }
185
186 fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
187 if !(self.delta.is_finite() && self.delta != 0.0) {
188 return Err(DmlError::InvalidArgument(
189 "the shift must be non-zero".into(),
190 ));
191 }
192 let c = if self.per_unit { 1.0 / self.delta } else { 1.0 };
193 let delta = self.delta;
194 Ok(vec![
195 Term::constant(with_treatment(w, |_, d| d + delta), c),
196 Term::constant(w.to_owned(), -c),
197 ])
198 }
199}
200
201#[derive(Debug, Clone)]
204pub struct PolicyEffect {
205 pub treatment: Array1<f64>,
206}
207
208impl PolicyEffect {
209 pub fn new(treatment: Array1<f64>) -> Self {
211 Self { treatment }
212 }
213}
214
215impl LinearFunctional for PolicyEffect {
216 fn name(&self) -> String {
217 "policy effect".into()
218 }
219
220 fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
221 if self.treatment.len() != w.nrows() {
222 return Err(DmlError::DimensionMismatch(
223 "the policy needs one treatment value per observation".into(),
224 ));
225 }
226 Ok(vec![
227 Term::constant(with_treatment(w, |i, _| self.treatment[i]), 1.0),
228 Term::constant(w.to_owned(), -1.0),
229 ])
230 }
231}
232
233#[derive(Clone)]
235pub struct CustomFunctional {
236 name: String,
237 #[allow(clippy::type_complexity)]
238 terms: Arc<dyn Fn(ArrayView2<'_, f64>) -> Result<Vec<Term>> + Send + Sync>,
239}
240
241impl CustomFunctional {
242 pub fn new(
243 name: impl Into<String>,
244 terms: impl Fn(ArrayView2<'_, f64>) -> Result<Vec<Term>> + Send + Sync + 'static,
245 ) -> Self {
246 Self {
247 name: name.into(),
248 terms: Arc::new(terms),
249 }
250 }
251}
252
253impl LinearFunctional for CustomFunctional {
254 fn name(&self) -> String {
255 self.name.clone()
256 }
257
258 fn terms(&self, w: ArrayView2<'_, f64>) -> Result<Vec<Term>> {
259 let terms = (self.terms)(w)?;
260 for t in &terms {
261 if t.w.dim() != w.dim() || t.weight.len() != w.nrows() {
262 return Err(DmlError::DimensionMismatch(format!(
263 "a functional term has shape {:?} and {} weights; expected {:?}",
264 t.w.dim(),
265 t.weight.len(),
266 w.dim()
267 )));
268 }
269 }
270 Ok(terms)
271 }
272}
273
274pub trait RieszLearner: Send + Sync {
280 fn fit(&mut self, w: ArrayView2<'_, f64>, terms: &[Term]) -> Result<()>;
283
284 fn predict(&self, w: ArrayView2<'_, f64>) -> Result<Array1<f64>>;
286
287 fn predict_functional(&self, terms: &[Term]) -> Result<Array1<f64>> {
289 apply_terms(terms, |w| self.predict(w))
290 }
291
292 fn fresh(&self) -> Box<dyn RieszLearner>;
294}
295
296#[derive(Clone)]
298pub enum Dictionary {
299 Polynomial {
303 degree: usize,
304 treatment_degree: usize,
305 },
306 #[allow(clippy::type_complexity)]
309 Custom(Arc<dyn Fn(ArrayView2<'_, f64>) -> Result<Array2<f64>> + Send + Sync>),
310}
311
312impl std::fmt::Debug for Dictionary {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 match self {
315 Dictionary::Polynomial {
316 degree,
317 treatment_degree,
318 } => write!(
319 f,
320 "Polynomial {{ degree: {degree}, treatment_degree: {treatment_degree} }}"
321 ),
322 Dictionary::Custom(_) => write!(f, "Custom(..)"),
323 }
324 }
325}
326
327impl Default for Dictionary {
328 fn default() -> Self {
329 Dictionary::Polynomial {
330 degree: 2,
331 treatment_degree: 1,
332 }
333 }
334}
335
336fn monomials(p: usize, degree: usize) -> Vec<Vec<usize>> {
339 let mut out = vec![vec![0; p]];
340 let mut last = vec![vec![0; p]];
341 for _ in 0..degree {
342 let mut next = Vec::new();
343 for e in &last {
344 let start = e.iter().rposition(|&k| k > 0).unwrap_or(0);
346 for j in start..p {
347 let mut f = e.clone();
348 f[j] += 1;
349 next.push(f);
350 }
351 }
352 out.extend(next.iter().cloned());
353 last = next;
354 }
355 out
356}
357
358impl Dictionary {
359 pub fn custom(
360 basis: impl Fn(ArrayView2<'_, f64>) -> Result<Array2<f64>> + Send + Sync + 'static,
361 ) -> Self {
362 Dictionary::Custom(Arc::new(basis))
363 }
364
365 pub fn apply(&self, w: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
367 match self {
368 Dictionary::Polynomial {
369 degree,
370 treatment_degree,
371 } => {
372 let (n, cols) = w.dim();
373 if cols == 0 {
374 return Err(DmlError::InvalidArgument("no treatment column".into()));
375 }
376 let monos = monomials(cols - 1, *degree);
377 let k = monos.len() * (treatment_degree + 1);
378 let mut b = Array2::zeros((n, k));
379 for i in 0..n {
380 let row = w.row(i);
381 let mut c = 0;
382 for a in 0..=*treatment_degree {
383 let da = row[0].powi(a as i32);
384 for e in &monos {
385 let mut v = da;
386 for (j, &p) in e.iter().enumerate() {
387 if p > 0 {
388 v *= row[j + 1].powi(p as i32);
389 }
390 }
391 b[[i, c]] = v;
392 c += 1;
393 }
394 }
395 }
396 Ok(b)
397 }
398 Dictionary::Custom(f) => {
399 let b = f(w)?;
400 if b.nrows() != w.nrows() {
401 return Err(DmlError::DimensionMismatch(
402 "the dictionary must return one row per observation".into(),
403 ));
404 }
405 Ok(b)
406 }
407 }
408 }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq)]
413pub enum RieszPenalty {
414 CrossValidated,
416 Fixed(f64),
418}
419
420#[derive(Debug, Clone)]
432pub struct RieszLasso {
433 dictionary: Dictionary,
434 penalty: RieszPenalty,
435 n_alphas: usize,
436 eps: f64,
437 cv: usize,
438 tol: f64,
439 max_iter: usize,
440 rho: Option<Array1<f64>>,
441 chosen: Option<f64>,
442}
443
444impl Default for RieszLasso {
445 fn default() -> Self {
446 Self {
447 dictionary: Dictionary::default(),
448 penalty: RieszPenalty::CrossValidated,
449 n_alphas: 30,
450 eps: 1e-3,
451 cv: 5,
452 tol: 1e-10,
453 max_iter: 100_000,
454 rho: None,
455 chosen: None,
456 }
457 }
458}
459
460struct Moments {
462 g: Array2<f64>,
463 m: Array1<f64>,
464}
465
466impl Moments {
467 fn new(b: ArrayView2<'_, f64>, mb: ArrayView2<'_, f64>) -> Self {
468 let n = b.nrows().max(1) as f64;
469 Self {
470 g: b.t().dot(&b) / n,
471 m: mb
472 .mean_axis(Axis(0))
473 .unwrap_or_else(|| Array1::zeros(b.ncols())),
474 }
475 }
476
477 fn loss(&self, rho: &Array1<f64>) -> f64 {
479 rho.dot(&self.g.dot(rho)) - 2.0 * self.m.dot(rho)
480 }
481}
482
483impl RieszLasso {
484 pub fn new() -> Self {
485 Self::default()
486 }
487
488 pub fn dictionary(mut self, dictionary: Dictionary) -> Self {
489 self.dictionary = dictionary;
490 self
491 }
492
493 pub fn penalty(mut self, penalty: RieszPenalty) -> Self {
494 self.penalty = penalty;
495 self
496 }
497
498 pub fn n_alphas(mut self, n_alphas: usize) -> Self {
500 self.n_alphas = n_alphas;
501 self
502 }
503
504 pub fn eps(mut self, eps: f64) -> Self {
506 self.eps = eps;
507 self
508 }
509
510 pub fn cv(mut self, cv: usize) -> Self {
512 self.cv = cv;
513 self
514 }
515
516 pub fn coef(&self) -> Option<&Array1<f64>> {
518 self.rho.as_ref()
519 }
520
521 pub fn alpha(&self) -> Option<f64> {
523 self.chosen
524 }
525
526 fn design(&self, w: ArrayView2<'_, f64>, terms: &[Term]) -> Result<(Array2<f64>, Array2<f64>)> {
528 let b = self.dictionary.apply(w)?;
529 let mut mb = Array2::zeros(b.dim());
530 for t in terms {
531 let bk = self.dictionary.apply(t.w.view())?;
532 if bk.dim() != b.dim() {
533 return Err(DmlError::DimensionMismatch(
534 "dictionary at transformed points".into(),
535 ));
536 }
537 mb += &(&bk * &t.weight.view().insert_axis(Axis(1)));
538 }
539 Ok((b, mb))
540 }
541
542 fn loadings(b: ArrayView2<'_, f64>, g: &Array2<f64>) -> Array1<f64> {
544 (0..b.ncols())
545 .map(|j| {
546 let col = b.column(j);
547 let first = col.first().copied().unwrap_or(0.0);
548 if col.iter().all(|&v| v == first) {
549 0.0
550 } else {
551 g[[j, j]].sqrt()
552 }
553 })
554 .collect()
555 }
556
557 fn solve(&self, mom: &Moments, psi: &Array1<f64>, r: f64, rho: &mut Array1<f64>) -> Result<()> {
559 let p = rho.len();
560 let mut grho = mom.g.dot(rho);
561 let scale = mom
562 .g
563 .diag()
564 .iter()
565 .fold(0.0_f64, |a, &v| a.max(v))
566 .max(1e-300);
567 for _ in 0..self.max_iter {
568 let mut max_step = 0.0_f64;
569 for j in 0..p {
570 let gjj = mom.g[[j, j]];
571 if gjj <= 0.0 {
572 continue;
573 }
574 let z = mom.m[j] - (grho[j] - gjj * rho[j]);
575 let thr = if psi[j] > 0.0 { r * psi[j] } else { 0.0 };
577 let new = if z > thr {
578 (z - thr) / gjj
579 } else if z < -thr {
580 (z + thr) / gjj
581 } else {
582 0.0
583 };
584 let delta = new - rho[j];
585 if delta != 0.0 {
586 grho.scaled_add(delta, &mom.g.column(j));
587 rho[j] = new;
588 max_step = max_step.max(delta.abs() * gjj.sqrt());
589 }
590 }
591 if max_step <= self.tol * scale.sqrt().max(1.0) {
592 return Ok(());
593 }
594 }
595 Err(DmlError::InvalidArgument(
596 "the Riesz Lasso did not converge; increase the penalty or simplify the dictionary"
597 .into(),
598 ))
599 }
600
601 fn null_fit(&self, mom: &Moments, psi: &Array1<f64>) -> Result<(Array1<f64>, f64)> {
603 let mut rho = Array1::zeros(psi.len());
604 self.solve(mom, psi, f64::INFINITY, &mut rho)?;
605 let grad = &mom.m - &mom.g.dot(&rho);
606 let r_max = (0..psi.len())
607 .filter(|&j| psi[j] > 0.0)
608 .map(|j| grad[j].abs() / psi[j])
609 .fold(0.0_f64, f64::max);
610 Ok((rho, r_max))
611 }
612
613 fn grid(&self, r_max: f64) -> Vec<f64> {
614 if r_max <= 0.0 {
615 return vec![0.0];
616 }
617 let k = self.n_alphas.max(1);
618 (0..k)
619 .map(|i| {
620 let t = if k == 1 {
621 0.0
622 } else {
623 i as f64 / (k - 1) as f64
624 };
625 r_max * self.eps.powf(t)
626 })
627 .collect()
628 }
629}
630
631impl RieszLearner for RieszLasso {
632 fn fit(&mut self, w: ArrayView2<'_, f64>, terms: &[Term]) -> Result<()> {
633 let (b, mb) = self.design(w, terms)?;
634 let n = b.nrows();
635 let mom = Moments::new(b.view(), mb.view());
636 let psi = Self::loadings(b.view(), &mom.g);
637 let r = match self.penalty {
638 RieszPenalty::Fixed(r) => {
639 if r.is_nan() || r < 0.0 {
640 return Err(DmlError::InvalidArgument(
641 "the penalty must be non-negative".into(),
642 ));
643 }
644 r
645 }
646 RieszPenalty::CrossValidated => {
647 let (_, r_max) = self.null_fit(&mom, &psi)?;
648 let grid = self.grid(r_max);
649 let k = self.cv.max(2).min(n);
650 let mut cv_loss = vec![0.0; grid.len()];
651 for fold in 0..k {
652 let train: Vec<usize> = (0..n).filter(|i| i % k != fold).collect();
653 let test: Vec<usize> = (0..n).filter(|i| i % k == fold).collect();
654 let tr = Moments::new(
655 b.select(Axis(0), &train).view(),
656 mb.select(Axis(0), &train).view(),
657 );
658 let te = Moments::new(
659 b.select(Axis(0), &test).view(),
660 mb.select(Axis(0), &test).view(),
661 );
662 let psi_tr = Self::loadings(b.select(Axis(0), &train).view(), &tr.g);
663 let mut rho = Array1::zeros(psi.len());
664 for (loss, &r) in cv_loss.iter_mut().zip(&grid) {
665 self.solve(&tr, &psi_tr, r, &mut rho)?;
666 *loss += te.loss(&rho) * test.len() as f64 / n as f64;
667 }
668 }
669 let best = cv_loss
670 .iter()
671 .enumerate()
672 .fold(
673 (0, f64::INFINITY),
674 |acc, (i, &l)| if l < acc.1 { (i, l) } else { acc },
675 )
676 .0;
677 grid[best]
678 }
679 };
680 let (mut rho, r_max) = self.null_fit(&mom, &psi)?;
682 for &rr in self.grid(r_max).iter().filter(|&&g| g > r) {
683 self.solve(&mom, &psi, rr, &mut rho)?;
684 }
685 self.solve(&mom, &psi, r, &mut rho)?;
686 self.rho = Some(rho);
687 self.chosen = Some(r);
688 Ok(())
689 }
690
691 fn predict(&self, w: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
692 let rho = self
693 .rho
694 .as_ref()
695 .ok_or_else(|| DmlError::InvalidArgument("RieszLasso is not fitted".into()))?;
696 let b = self.dictionary.apply(w)?;
697 if b.ncols() != rho.len() {
698 return Err(DmlError::DimensionMismatch(
699 "dictionary size changed".into(),
700 ));
701 }
702 Ok(b.dot(rho))
703 }
704
705 fn fresh(&self) -> Box<dyn RieszLearner> {
706 Box::new(Self {
707 rho: None,
708 chosen: None,
709 ..self.clone()
710 })
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use ndarray::array;
718
719 #[test]
720 fn monomials_count_and_order() {
721 let m = monomials(3, 2);
722 assert_eq!(m.len(), 10); assert_eq!(m[0], vec![0, 0, 0]);
724 assert_eq!(m[1], vec![1, 0, 0]);
725 assert!(m.contains(&vec![0, 1, 1]));
726 let d = Dictionary::Polynomial {
727 degree: 1,
728 treatment_degree: 1,
729 };
730 let b = d.apply(array![[2.0, 3.0, 5.0]].view()).unwrap();
731 assert_eq!(b, array![[1.0, 3.0, 5.0, 2.0, 6.0, 10.0]]);
732 }
733
734 #[test]
735 fn unpenalised_fit_solves_the_normal_equations() {
736 let n = 200;
738 let w = Array2::from_shape_fn((n, 2), |(i, j)| {
739 if j == 0 {
740 ((i * 7) % 5) as f64 / 2.0
741 } else {
742 ((i * 3) % 11) as f64 / 5.0
743 }
744 });
745 let functional = ShiftEffect::new(0.5);
746 let terms = functional.terms(w.view()).unwrap();
747 let mut lasso = RieszLasso::new()
748 .dictionary(Dictionary::Polynomial {
749 degree: 1,
750 treatment_degree: 1,
751 })
752 .penalty(RieszPenalty::Fixed(0.0));
753 lasso.fit(w.view(), &terms).unwrap();
754 let (b, mb) = lasso.design(w.view(), &terms).unwrap();
755 let mom = Moments::new(b.view(), mb.view());
756 let rho = lasso.coef().unwrap();
757 let resid = mom.g.dot(rho) - &mom.m;
758 assert!(resid.iter().all(|v| v.abs() < 1e-8), "{resid}");
759 }
760
761 #[test]
762 fn saturated_ate_representer_is_the_inverse_propensity_weight() {
763 let n = 300;
766 let w = Array2::from_shape_fn((n, 2), |(i, j)| {
767 if j == 0 {
768 f64::from(((i * 7 + i / 3) % 5) < 2 + (i % 3) % 2)
769 } else {
770 (i % 3) as f64
771 }
772 });
773 let dict = Dictionary::custom(|w: ArrayView2<'_, f64>| {
774 Ok(Array2::from_shape_fn((w.nrows(), 6), |(i, k)| {
775 let (d, x) = (w[[i, 0]], w[[i, 1]]);
776 let arm = if k < 3 { d } else { 1.0 - d };
777 if x == (k % 3) as f64 {
778 arm
779 } else {
780 0.0
781 }
782 }))
783 });
784 let terms = Ate.terms(w.view()).unwrap();
785 let mut lasso = RieszLasso::new()
786 .dictionary(dict)
787 .penalty(RieszPenalty::Fixed(0.0));
788 lasso.fit(w.view(), &terms).unwrap();
789 let alpha = lasso.predict(w.view()).unwrap();
790 for c in 0..3 {
791 let rows: Vec<usize> = (0..n).filter(|&i| w[[i, 1]] == c as f64).collect();
792 let pi = rows.iter().map(|&i| w[[i, 0]]).sum::<f64>() / rows.len() as f64;
793 for &i in &rows {
794 let d = w[[i, 0]];
795 let expected = d / pi - (1.0 - d) / (1.0 - pi);
796 assert!(
797 (alpha[i] - expected).abs() < 1e-8,
798 "{} vs {expected}",
799 alpha[i]
800 );
801 }
802 }
803 }
804
805 #[test]
806 fn cross_validated_penalty_is_on_the_grid_and_predicts_m() {
807 let n = 400;
808 let w = Array2::from_shape_fn((n, 3), |(i, j)| {
809 (((i + 1) * (j + 3) * 7919) % 1000) as f64 / 500.0 - 1.0
810 });
811 let terms = AverageDerivative::new().terms(w.view()).unwrap();
812 let mut lasso = RieszLasso::new();
813 lasso.fit(w.view(), &terms).unwrap();
814 assert!(lasso.alpha().unwrap() >= 0.0);
815 let m_alpha = lasso.predict_functional(&terms).unwrap();
816 assert_eq!(m_alpha.len(), n);
817 assert!(m_alpha.iter().all(|v| v.is_finite()));
818 }
819
820 #[test]
821 fn functional_terms() {
822 let w = array![[0.0, 1.0], [1.0, 2.0]];
823 let f = |w: ArrayView2<'_, f64>| Ok(w.column(0).mapv(|d| 3.0 * d) + w.column(1));
824 assert_eq!(
825 apply_terms(&Ate.terms(w.view()).unwrap(), f).unwrap(),
826 array![3.0, 3.0]
827 );
828 let shift = ShiftEffect::new(2.0).per_unit(true);
829 assert_eq!(
830 apply_terms(&shift.terms(w.view()).unwrap(), f).unwrap(),
831 array![3.0, 3.0]
832 );
833 let deriv = AverageDerivative::new().step(0.1);
834 let v = apply_terms(&deriv.terms(w.view()).unwrap(), f).unwrap();
835 assert!((v[0] - 3.0).abs() < 1e-12);
836 let policy = PolicyEffect::new(array![1.0, 1.0]);
837 assert_eq!(
838 apply_terms(&policy.terms(w.view()).unwrap(), f).unwrap(),
839 array![3.0, 0.0]
840 );
841 assert!(Ate.terms(array![[0.5, 1.0]].view()).is_err());
842 }
843}