1use ndarray::{Array2, Array3, ArrayView2};
7use std::collections::{HashMap, VecDeque};
8use std::sync::Mutex;
9
10use crate::error::{Result, ShapError};
11
12pub trait Predict {
21 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>>;
33
34 fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
40 self.predict(x.view())
41 }
42
43 fn n_features(&self) -> Option<usize> {
48 None
49 }
50
51 fn n_outputs(&self) -> Option<usize> {
56 None
57 }
58}
59impl<T: Predict + ?Sized> Predict for &T {
60 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
61 (**self).predict(x)
62 }
63 fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
64 (**self).predict_owned(x)
65 }
66 fn n_features(&self) -> Option<usize> {
67 (**self).n_features()
68 }
69 fn n_outputs(&self) -> Option<usize> {
70 (**self).n_outputs()
71 }
72}
73
74#[derive(Debug)]
75struct PredictionCacheState {
76 rows: HashMap<Vec<u64>, Vec<f64>>,
77 order: VecDeque<Vec<u64>>,
78 outputs: Option<usize>,
79}
80
81pub struct CachedModel<M> {
90 inner: M,
91 capacity: usize,
92 state: Mutex<PredictionCacheState>,
93}
94
95impl<M> CachedModel<M> {
96 pub fn new(inner: M, capacity: usize) -> Result<Self> {
97 if capacity == 0 {
98 return Err(ShapError::InvalidConfiguration(
99 "prediction cache capacity must be positive".into(),
100 ));
101 }
102 Ok(Self {
103 inner,
104 capacity,
105 state: Mutex::new(PredictionCacheState {
106 rows: HashMap::new(),
107 order: VecDeque::new(),
108 outputs: None,
109 }),
110 })
111 }
112
113 pub fn inner(&self) -> &M {
114 &self.inner
115 }
116
117 pub fn capacity(&self) -> usize {
118 self.capacity
119 }
120
121 pub fn len(&self) -> Result<usize> {
122 Ok(self.lock_state()?.rows.len())
123 }
124
125 pub fn is_empty(&self) -> Result<bool> {
126 Ok(self.len()? == 0)
127 }
128
129 pub fn clear(&self) -> Result<()> {
130 let mut state = self.lock_state()?;
131 state.rows.clear();
132 state.order.clear();
133 state.outputs = None;
134 Ok(())
135 }
136
137 fn lock_state(&self) -> Result<std::sync::MutexGuard<'_, PredictionCacheState>> {
138 self.state
139 .lock()
140 .map_err(|_| ShapError::Other("prediction cache lock was poisoned".into()))
141 }
142
143 fn touch(state: &mut PredictionCacheState, key: &[u64]) {
144 if let Some(position) = state.order.iter().position(|cached| cached == key) {
145 state.order.remove(position);
146 }
147 state.order.push_back(key.to_vec());
148 }
149}
150
151impl<M: Predict> Predict for CachedModel<M> {
152 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
153 if let Some(features) = self.inner.n_features() {
154 if features != x.ncols() {
155 return Err(ShapError::DimensionMismatch {
156 expected: format!("{features} model features"),
157 found: format!("{}", x.ncols()),
158 });
159 }
160 }
161 if x.nrows() == 0 {
162 let predictions = self.inner.predict(x)?;
163 if predictions.nrows() != 0 || predictions.ncols() == 0 {
164 return Err(ShapError::DimensionMismatch {
165 expected: "(0, outputs>0)".into(),
166 found: format!("{:?}", predictions.dim()),
167 });
168 }
169 let mut state = self.lock_state()?;
170 if state
171 .outputs
172 .is_some_and(|outputs| outputs != predictions.ncols())
173 {
174 return Err(ShapError::OutputDimensionMismatch {
175 expected: state.outputs.unwrap(),
176 found: predictions.ncols(),
177 });
178 }
179 state.outputs = Some(predictions.ncols());
180 return Ok(predictions);
181 }
182 let keys = x
183 .rows()
184 .into_iter()
185 .map(|row| row.iter().map(|value| value.to_bits()).collect::<Vec<_>>())
186 .collect::<Vec<_>>();
187 let mut values = vec![None; x.nrows()];
188 let mut missing = Vec::<Vec<u64>>::new();
189 let mut missing_lookup = HashMap::<Vec<u64>, usize>::new();
190 {
191 let mut state = self.lock_state()?;
192 for (row, key) in keys.iter().enumerate() {
193 if let Some(cached) = state.rows.get(key).cloned() {
194 Self::touch(&mut state, key);
195 values[row] = Some(cached);
196 } else if !missing_lookup.contains_key(key) {
197 missing_lookup.insert(key.clone(), missing.len());
198 missing.push(key.clone());
199 }
200 }
201 }
202
203 let mut predicted_missing = Vec::new();
204 if !missing.is_empty() {
205 crate::error::checked_f64_shape(
206 &[missing.len(), x.ncols()],
207 "prediction cache miss batch",
208 )?;
209 let mut batch = Array2::zeros((missing.len(), x.ncols()));
210 for (row, key) in missing.iter().enumerate() {
211 for (column, bits) in key.iter().enumerate() {
212 batch[[row, column]] = f64::from_bits(*bits);
213 }
214 }
215 let predictions = self.inner.predict_owned(batch)?;
216 if predictions.nrows() != missing.len() || predictions.ncols() == 0 {
217 return Err(ShapError::DimensionMismatch {
218 expected: format!("({}, outputs>0)", missing.len()),
219 found: format!("{:?}", predictions.dim()),
220 });
221 }
222 if predictions.iter().any(|value| !value.is_finite()) {
223 return Err(ShapError::ModelError(
224 "prediction contains a non-finite value".into(),
225 ));
226 }
227 predicted_missing = predictions
228 .rows()
229 .into_iter()
230 .map(|row| row.to_vec())
231 .collect();
232 let mut state = self.lock_state()?;
233 if state
234 .outputs
235 .is_some_and(|outputs| outputs != predictions.ncols())
236 {
237 return Err(ShapError::OutputDimensionMismatch {
238 expected: state.outputs.unwrap(),
239 found: predictions.ncols(),
240 });
241 }
242 state.outputs = Some(predictions.ncols());
243 for (key, prediction) in missing.iter().zip(&predicted_missing) {
244 while state.rows.len() >= self.capacity {
245 let Some(evicted) = state.order.pop_front() else {
246 break;
247 };
248 state.rows.remove(&evicted);
249 }
250 state.rows.insert(key.clone(), prediction.clone());
251 Self::touch(&mut state, key);
252 }
253 }
254
255 for (row, key) in keys.iter().enumerate() {
256 if values[row].is_none() {
257 values[row] = Some(predicted_missing[missing_lookup[key]].clone());
258 }
259 }
260 let outputs = values
261 .first()
262 .and_then(Option::as_ref)
263 .map(Vec::len)
264 .or_else(|| self.lock_state().ok().and_then(|state| state.outputs))
265 .or_else(|| self.inner.n_outputs())
266 .unwrap_or(0);
267 let flat = values.into_iter().flatten().flatten().collect::<Vec<_>>();
268 Array2::from_shape_vec((x.nrows(), outputs), flat)
269 .map_err(|error| ShapError::ModelError(error.to_string()))
270 }
271
272 fn n_features(&self) -> Option<usize> {
273 self.inner.n_features()
274 }
275
276 fn n_outputs(&self) -> Option<usize> {
277 self.inner
278 .n_outputs()
279 .or_else(|| self.lock_state().ok().and_then(|state| state.outputs))
280 }
281}
282pub trait DifferentiablePredict: Predict {
285 fn gradients(&self, x: ArrayView2<'_, f64>) -> Result<Array3<f64>>;
286}
287impl<T: DifferentiablePredict + ?Sized> DifferentiablePredict for &T {
288 fn gradients(&self, x: ArrayView2<'_, f64>) -> Result<Array3<f64>> {
289 (**self).gradients(x)
290 }
291}
292pub trait DeepAttribution: Predict {
294 fn deep_contributions(
295 &self,
296 x: ArrayView2<'_, f64>,
297 background: ArrayView2<'_, f64>,
298 ) -> Result<Array3<f64>>;
299}
300impl<T: DeepAttribution + ?Sized> DeepAttribution for &T {
301 fn deep_contributions(
302 &self,
303 x: ArrayView2<'_, f64>,
304 background: ArrayView2<'_, f64>,
305 ) -> Result<Array3<f64>> {
306 (**self).deep_contributions(x, background)
307 }
308}
309#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
311pub enum ExecutionDevice {
312 Cpu,
313 Cuda(u16),
314 Metal,
315 Vulkan,
316 WebGpu,
317}
318pub trait AcceleratedPredict: Predict {
319 fn predict_on(&self, x: ArrayView2<'_, f64>, device: ExecutionDevice) -> Result<Array2<f64>>;
320
321 fn predict_owned_on(&self, x: Array2<f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
323 self.predict_on(x.view(), device)
324 }
325}
326pub struct FnAcceleratedModel<F> {
331 predict_fn: F,
332 n_features: Option<usize>,
333 n_outputs: Option<usize>,
334}
335impl<F> FnAcceleratedModel<F> {
336 pub fn new(predict_fn: F) -> Self {
337 Self {
338 predict_fn,
339 n_features: None,
340 n_outputs: None,
341 }
342 }
343 pub fn with_n_features(mut self, n_features: usize) -> Self {
344 self.n_features = Some(n_features);
345 self
346 }
347 pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
348 self.n_outputs = Some(n_outputs);
349 self
350 }
351}
352impl<F> Predict for FnAcceleratedModel<F>
353where
354 F: Fn(ArrayView2<'_, f64>, ExecutionDevice) -> Result<Array2<f64>>,
355{
356 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
357 (self.predict_fn)(x, ExecutionDevice::Cpu)
358 }
359 fn n_features(&self) -> Option<usize> {
360 self.n_features
361 }
362 fn n_outputs(&self) -> Option<usize> {
363 self.n_outputs
364 }
365}
366impl<F> AcceleratedPredict for FnAcceleratedModel<F>
367where
368 F: Fn(ArrayView2<'_, f64>, ExecutionDevice) -> Result<Array2<f64>>,
369{
370 fn predict_on(&self, x: ArrayView2<'_, f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
371 (self.predict_fn)(x, device)
372 }
373}
374
375pub struct FnOwnedAcceleratedModel<F> {
380 predict_fn: F,
381 n_features: Option<usize>,
382 n_outputs: Option<usize>,
383}
384
385impl<F> FnOwnedAcceleratedModel<F> {
386 pub fn new(predict_fn: F) -> Self {
387 Self {
388 predict_fn,
389 n_features: None,
390 n_outputs: None,
391 }
392 }
393
394 pub fn with_n_features(mut self, n_features: usize) -> Self {
395 self.n_features = Some(n_features);
396 self
397 }
398
399 pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
400 self.n_outputs = Some(n_outputs);
401 self
402 }
403}
404
405impl<F> Predict for FnOwnedAcceleratedModel<F>
406where
407 F: Fn(Array2<f64>, ExecutionDevice) -> Result<Array2<f64>>,
408{
409 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
410 (self.predict_fn)(x.to_owned(), ExecutionDevice::Cpu)
411 }
412
413 fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
414 (self.predict_fn)(x, ExecutionDevice::Cpu)
415 }
416
417 fn n_features(&self) -> Option<usize> {
418 self.n_features
419 }
420
421 fn n_outputs(&self) -> Option<usize> {
422 self.n_outputs
423 }
424}
425
426impl<F> AcceleratedPredict for FnOwnedAcceleratedModel<F>
427where
428 F: Fn(Array2<f64>, ExecutionDevice) -> Result<Array2<f64>>,
429{
430 fn predict_on(&self, x: ArrayView2<'_, f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
431 (self.predict_fn)(x.to_owned(), device)
432 }
433
434 fn predict_owned_on(&self, x: Array2<f64>, device: ExecutionDevice) -> Result<Array2<f64>> {
435 (self.predict_fn)(x, device)
436 }
437}
438pub struct DeviceModel<'a, M> {
441 model: &'a M,
442 device: ExecutionDevice,
443}
444impl<'a, M> DeviceModel<'a, M> {
445 pub fn new(model: &'a M, device: ExecutionDevice) -> Self {
446 Self { model, device }
447 }
448 pub fn device(&self) -> ExecutionDevice {
449 self.device
450 }
451}
452impl<M: AcceleratedPredict> Predict for DeviceModel<'_, M> {
453 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
454 self.model.predict_on(x, self.device)
455 }
456 fn predict_owned(&self, x: Array2<f64>) -> Result<Array2<f64>> {
457 self.model.predict_owned_on(x, self.device)
458 }
459 fn n_features(&self) -> Option<usize> {
460 self.model.n_features()
461 }
462 fn n_outputs(&self) -> Option<usize> {
463 self.model.n_outputs()
464 }
465}
466
467pub struct FnModel<F> {
489 predict_fn: F,
490 n_features: Option<usize>,
491 n_outputs: Option<usize>,
492}
493
494impl<F> FnModel<F> {
495 pub fn new(predict_fn: F) -> Self {
497 Self {
498 predict_fn,
499 n_features: None,
500 n_outputs: None,
501 }
502 }
503
504 pub fn with_n_features(mut self, n_features: usize) -> Self {
506 self.n_features = Some(n_features);
507 self
508 }
509
510 pub fn with_n_outputs(mut self, n_outputs: usize) -> Self {
512 self.n_outputs = Some(n_outputs);
513 self
514 }
515
516 pub fn configured_n_features(&self) -> Option<usize> {
518 self.n_features
519 }
520
521 pub fn configured_n_outputs(&self) -> Option<usize> {
523 self.n_outputs
524 }
525}
526
527impl<F> Predict for FnModel<F>
528where
529 F: Fn(ArrayView2<'_, f64>) -> Result<Array2<f64>>,
530{
531 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
532 (self.predict_fn)(x)
533 }
534
535 fn n_features(&self) -> Option<usize> {
536 self.n_features
537 }
538
539 fn n_outputs(&self) -> Option<usize> {
540 self.n_outputs
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use ndarray::array;
548 use std::cell::Cell;
549
550 #[test]
551 fn fn_model_predicts_batch() {
552 let model = FnModel::new(|x: ArrayView2<'_, f64>| {
553 let mut output = Array2::<f64>::zeros((x.nrows(), 1));
554
555 for i in 0..x.nrows() {
556 output[[i, 0]] = x[[i, 0]] + x[[i, 1]];
557 }
558
559 Ok(output)
560 });
561
562 let x = array![[1.0, 2.0], [3.0, 4.0],];
563
564 let predictions = model.predict(x.view()).unwrap();
565
566 assert_eq!(predictions.shape(), &[2, 1]);
567 assert_eq!(predictions[[0, 0]], 3.0);
568 assert_eq!(predictions[[1, 0]], 7.0);
569 }
570
571 #[test]
572 fn accelerated_closure_receives_bound_device() {
573 let model = FnAcceleratedModel::new(|x: ArrayView2<'_, f64>, device| {
574 let offset = if device == ExecutionDevice::Cuda(2) {
575 10.0
576 } else {
577 0.0
578 };
579 Ok(Array2::from_shape_fn((x.nrows(), 1), |(i, _)| {
580 x[[i, 0]] + offset
581 }))
582 });
583 let bound = DeviceModel::new(&model, ExecutionDevice::Cuda(2));
584 assert_eq!(bound.predict(array![[3.0]].view()).unwrap()[[0, 0]], 13.0);
585 assert_eq!(model.predict(array![[3.0]].view()).unwrap()[[0, 0]], 3.0);
586 }
587
588 #[test]
589 fn device_model_dispatches_owned_batches_to_transfer_fast_path() {
590 struct TrackingModel {
591 borrowed_calls: Cell<usize>,
592 owned_calls: Cell<usize>,
593 }
594 impl Predict for TrackingModel {
595 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
596 Ok(x.to_owned())
597 }
598 }
599 impl AcceleratedPredict for TrackingModel {
600 fn predict_on(
601 &self,
602 x: ArrayView2<'_, f64>,
603 _: ExecutionDevice,
604 ) -> Result<Array2<f64>> {
605 self.borrowed_calls.set(self.borrowed_calls.get() + 1);
606 Ok(x.to_owned())
607 }
608 fn predict_owned_on(&self, x: Array2<f64>, _: ExecutionDevice) -> Result<Array2<f64>> {
609 self.owned_calls.set(self.owned_calls.get() + 1);
610 Ok(x)
611 }
612 }
613 let model = TrackingModel {
614 borrowed_calls: Cell::new(0),
615 owned_calls: Cell::new(0),
616 };
617 let bound = DeviceModel::new(&model, ExecutionDevice::Cuda(0));
618 let output = bound.predict_owned(array![[1., 2.]]).unwrap();
619 assert_eq!(output, array![[1., 2.]]);
620 assert_eq!(model.owned_calls.get(), 1);
621 assert_eq!(model.borrowed_calls.get(), 0);
622 }
623
624 #[test]
625 fn owned_accelerated_closure_receives_batch_and_device() {
626 let model = FnOwnedAcceleratedModel::new(|x: Array2<f64>, device| {
627 let offset = if device == ExecutionDevice::Vulkan {
628 2.0
629 } else {
630 0.0
631 };
632 Ok(x + offset)
633 });
634 let bound = DeviceModel::new(&model, ExecutionDevice::Vulkan);
635 assert_eq!(
636 bound.predict_owned(array![[1., 3.]]).unwrap(),
637 array![[3., 5.]]
638 );
639 }
640
641 #[test]
642 fn fn_model_can_store_dimensions() {
643 let model = FnModel::new(|x: ArrayView2<'_, f64>| Ok(Array2::zeros((x.nrows(), 2))))
644 .with_n_features(4)
645 .with_n_outputs(2);
646
647 assert_eq!(model.n_features(), Some(4));
648 assert_eq!(model.n_outputs(), Some(2));
649 }
650
651 #[test]
652 fn fn_model_can_be_used_through_predict_trait() {
653 let model = FnModel::new(|x: ArrayView2<'_, f64>| Ok(Array2::ones((x.nrows(), 1))));
654
655 let x = Array2::<f64>::zeros((3, 2));
656
657 let predictions = Predict::predict(&model, x.view()).unwrap();
658
659 assert_eq!(predictions.shape(), &[3, 1]);
660 assert!(predictions.iter().all(|&value| value == 1.0));
661 }
662
663 #[test]
664 fn cached_model_reuses_rows_across_batches_and_deduplicates_misses() {
665 let calls = Cell::new(0usize);
666 let rows = Cell::new(0usize);
667 let model = FnModel::new(|x: ArrayView2<'_, f64>| {
668 calls.set(calls.get() + 1);
669 rows.set(rows.get() + x.nrows());
670 Ok(x.sum_axis(ndarray::Axis(1)).insert_axis(ndarray::Axis(1)))
671 })
672 .with_n_features(2)
673 .with_n_outputs(1);
674 let cached = CachedModel::new(model, 4).unwrap();
675
676 let first = cached
677 .predict(array![[1., 2.], [3., 4.], [1., 2.]].view())
678 .unwrap();
679 assert_eq!(first, array![[3.], [7.], [3.]]);
680 assert_eq!(calls.get(), 1);
681 assert_eq!(rows.get(), 2);
682
683 let second = cached.predict(array![[3., 4.], [5., 6.]].view()).unwrap();
684 assert_eq!(second, array![[7.], [11.]]);
685 assert_eq!(calls.get(), 2);
686 assert_eq!(rows.get(), 3);
687 assert_eq!(cached.len().unwrap(), 3);
688 }
689
690 #[test]
691 fn cached_model_has_bounded_lru_eviction_and_can_be_cleared() {
692 let rows = Cell::new(0usize);
693 let model = FnModel::new(|x: ArrayView2<'_, f64>| {
694 rows.set(rows.get() + x.nrows());
695 Ok(x.to_owned())
696 });
697 let cached = CachedModel::new(model, 2).unwrap();
698 cached.predict(array![[1.], [2.], [3.]].view()).unwrap();
699 assert_eq!(cached.len().unwrap(), 2);
700 cached.predict(array![[1.]].view()).unwrap();
701 assert_eq!(rows.get(), 4);
702 cached.clear().unwrap();
703 assert!(cached.is_empty().unwrap());
704 }
705
706 #[test]
707 fn cached_model_rejects_zero_capacity() {
708 let model =
709 FnModel::new(|x: ArrayView2<'_, f64>| -> Result<Array2<f64>> { Ok(x.to_owned()) });
710 assert!(CachedModel::new(model, 0).is_err());
711 }
712}