Skip to main content

scouter_drift/psi/
drift.rs

1#[cfg(feature = "sql")]
2pub mod psi_drifter {
3
4    use crate::error::DriftError;
5    use crate::psi::monitor::PsiMonitor;
6    use crate::psi::types::{FeatureBinMapping, FeatureBinProportionPairs};
7    use chrono::{DateTime, Utc};
8    use rayon::iter::{IntoParallelIterator, ParallelIterator};
9    use scouter_dispatch::AlertDispatcher;
10    use scouter_settings::ObjectStorageSettings;
11    use scouter_sql::sql::traits::PsiSqlLogic;
12    use scouter_sql::{sql::cache::entity_cache, PostgresClient};
13    use scouter_types::psi::{
14        BinnedPsiFeatureMetrics, BinnedPsiMetric, FeatureDistributions, PsiDriftProfile,
15        PsiFeatureAlert, PsiFeatureAlerts, PsiFeatureDriftProfile,
16    };
17    use scouter_types::AlertMap;
18    use scouter_types::{contracts::DriftRequest, ProfileBaseArgs};
19
20    use sqlx::{Pool, Postgres};
21    use std::collections::{BTreeMap, HashMap};
22    use tracing::info;
23    use tracing::{debug, error, instrument};
24
25    pub struct PsiDrifter {
26        profile: PsiDriftProfile,
27    }
28
29    impl PsiDrifter {
30        pub fn new(profile: PsiDriftProfile) -> Self {
31            Self { profile }
32        }
33
34        fn get_monitored_profiles(&self) -> Vec<PsiFeatureDriftProfile> {
35            self.profile
36                .config
37                .alert_config
38                .features_to_monitor
39                .iter()
40                .map(|key| self.profile.features[key].clone())
41                .collect()
42        }
43
44        async fn resolve_target_feature_distributions(
45            &self,
46            limit_datetime: &DateTime<Utc>,
47            db_pool: &Pool<Postgres>,
48        ) -> Result<Option<FeatureDistributions>, DriftError> {
49            let entity_id = entity_cache()
50                .get_entity_id_from_uid(db_pool, &self.profile.config.uid)
51                .await?;
52            let feature_distributions = PostgresClient::get_feature_distributions(
53                db_pool,
54                limit_datetime,
55                &self.profile.config.alert_config.features_to_monitor,
56                &entity_id,
57            )
58            .await
59            .inspect_err(|e| {
60                error!(
61                    "Error: Unable to fetch feature bin proportions from DB for {}/{}/{}: {}",
62                    self.profile.space(),
63                    self.profile.name(),
64                    self.profile.version(),
65                    e
66                );
67            })?;
68
69            if feature_distributions.is_empty() {
70                info!(
71                    "No enough target samples collected for {}/{}/{}. Skipping alert processing.",
72                    self.profile.space(),
73                    self.profile.name(),
74                    self.profile.version(),
75                );
76                return Ok(None);
77            }
78
79            Ok(Some(feature_distributions))
80        }
81
82        fn get_feature_alerts(
83            &self,
84            drift_map: &HashMap<String, f64>,
85            target_feature_distributions: &FeatureDistributions,
86        ) -> Vec<PsiFeatureAlert> {
87            let threshold_cfg = &self.profile.config.alert_config.threshold;
88
89            drift_map
90                .iter()
91                .filter_map(|(feature, drift)| {
92                    let target_sample_size = target_feature_distributions
93                        .distributions
94                        .get(feature)?
95                        .sample_size;
96                    let number_of_bins = self.profile.features.get(feature)?.bins.len();
97                    let threshold =
98                        threshold_cfg.compute_threshold(target_sample_size, number_of_bins as u64);
99
100                    (*drift > threshold).then(|| PsiFeatureAlert {
101                        feature: feature.clone(),
102                        drift: *drift,
103                        threshold,
104                    })
105                })
106                .collect()
107        }
108
109        async fn generate_alerts(
110            &self,
111            drift_map: &HashMap<String, f64>,
112            target_feature_distributions: &FeatureDistributions,
113        ) -> Result<Option<Vec<AlertMap>>, DriftError> {
114            let alert_dispatcher = AlertDispatcher::new(&self.profile.config).inspect_err(|e| {
115                error!(
116                    "Error creating alert dispatcher for {}/{}/{}: {}",
117                    self.profile.space(),
118                    self.profile.name(),
119                    self.profile.version(),
120                    e
121                );
122            })?;
123
124            let alerts = self.get_feature_alerts(drift_map, target_feature_distributions);
125
126            if alerts.is_empty() {
127                info!(
128                    "No alerts to process for {}/{}/{}",
129                    self.profile.space(),
130                    self.profile.name(),
131                    self.profile.version(),
132                );
133                return Ok(None);
134            }
135
136            alert_dispatcher
137                .process_alerts(&PsiFeatureAlerts {
138                    alerts: alerts.clone(),
139                })
140                .await
141                .inspect_err(|e| {
142                    error!(
143                        "Error processing alerts for {}/{}/{}: {}",
144                        self.profile.space(),
145                        self.profile.name(),
146                        self.profile.version(),
147                        e
148                    );
149                })?;
150            Ok(Some(alerts.into_iter().map(|a| a.into()).collect()))
151        }
152
153        fn get_drift_map(
154            target_feature_distributions: &FeatureDistributions,
155            profiles_to_monitor: &[PsiFeatureDriftProfile],
156        ) -> Result<HashMap<String, f64>, DriftError> {
157            let feature_bin_proportion_pairs = FeatureBinMapping::from_observed_bin_proportions(
158                target_feature_distributions,
159                profiles_to_monitor,
160            )?;
161
162            Ok(feature_bin_proportion_pairs
163                .features
164                .iter()
165                .map(|(feature, pairs)| (feature.clone(), PsiMonitor::compute_psi(&pairs.pairs)))
166                .collect())
167        }
168
169        pub async fn check_for_alerts(
170            &self,
171            db_pool: &Pool<Postgres>,
172            previous_run: &DateTime<Utc>,
173        ) -> Result<Option<Vec<AlertMap>>, DriftError> {
174            // Check if there are any feature profiles to monitor
175            let profiles_to_monitor = self.get_monitored_profiles();
176
177            if profiles_to_monitor.is_empty() {
178                return Ok(None);
179            }
180
181            // Fetch, if any, target feature distributions
182            let Some(target_feature_distributions) = self
183                .resolve_target_feature_distributions(previous_run, db_pool)
184                .await?
185            else {
186                return Ok(None);
187            };
188
189            // Compute drift for each feature
190            let drift_map =
191                Self::get_drift_map(&target_feature_distributions, &profiles_to_monitor)?;
192
193            // Generate alerts, if any
194            let Some(alerts) = self
195                .generate_alerts(&drift_map, &target_feature_distributions)
196                .await
197                .inspect_err(|e| {
198                    error!(
199                        "Error generating alerts for {}/{}/{}: {}",
200                        self.profile.space(),
201                        self.profile.name(),
202                        self.profile.version(),
203                        e
204                    );
205                })?
206            else {
207                return Ok(None);
208            };
209
210            Ok(Some(alerts))
211        }
212
213        fn create_feature_bin_proportion_pairs(
214            &self,
215            feature: &str,
216            bin_proportions: &BTreeMap<i32, f64>,
217        ) -> Result<FeatureBinProportionPairs, DriftError> {
218            // get profile
219            let profile = match self.profile.features.get(feature) {
220                Some(profile) => profile,
221                None => {
222                    error!("Error: Unable to fetch profile for feature {}", feature);
223                    return Err(DriftError::ProcessAlertError);
224                }
225            };
226
227            let proportion_pairs =
228                FeatureBinProportionPairs::from_observed_bin_proportions(bin_proportions, profile)
229                    .unwrap();
230
231            Ok(proportion_pairs)
232        }
233
234        /// Get a binned drift map. This is used on the server for vizualization purposes
235        ///
236        /// # Arguments
237        ///
238        /// * `drift_request` - DriftRequest containing the profile to monitor
239        /// * `db_client` - PostgresClient to use for fetching data
240        ///
241        #[instrument(skip_all)]
242        pub async fn get_binned_drift_map(
243            &self,
244            drift_request: &DriftRequest,
245            db_pool: &Pool<Postgres>,
246            retention_period: &i32,
247            storage_settings: &ObjectStorageSettings,
248            entity_id: &i32,
249        ) -> Result<BinnedPsiFeatureMetrics, DriftError> {
250            debug!(
251                "Getting binned drift map for {}/{}/{}",
252                self.profile.space(),
253                self.profile.name(),
254                self.profile.version(),
255            );
256            let binned_records = PostgresClient::get_binned_psi_drift_records(
257                db_pool,
258                drift_request,
259                retention_period,
260                storage_settings,
261                entity_id,
262            )
263            .await?;
264
265            if binned_records.is_empty() {
266                info!(
267                    "No binned drift records available for {}/{}/{}",
268                    self.profile.space(),
269                    self.profile.name(),
270                    self.profile.version(),
271                );
272                return Ok(BinnedPsiFeatureMetrics::default());
273            }
274
275            // iterate over each feature and calculate psi for each time period
276            let binned_map = binned_records
277                .into_par_iter()
278                // filter out any records that are not in the profile
279                .filter(|record| self.profile.features.contains_key(&record.feature))
280                // get psi for binned features and create data structure that's usable for UI
281                .map(|record| -> Result<_, DriftError> {
282                    let psi_vec: Result<Vec<_>, DriftError> = record
283                        .bin_proportions
284                        .iter()
285                        .map(|bin_proportion| {
286                            let proportions = self.create_feature_bin_proportion_pairs(
287                                &record.feature,
288                                bin_proportion,
289                            )?;
290                            let psi = PsiMonitor::compute_psi(&proportions.pairs);
291                            Ok(psi)
292                        })
293                        .collect();
294
295                    let overall_proportions = self.create_feature_bin_proportion_pairs(
296                        &record.feature,
297                        &record.overall_proportions,
298                    )?;
299                    let overall_psi = PsiMonitor::compute_psi(&overall_proportions.pairs);
300
301                    Ok((
302                        record.feature.clone(),
303                        BinnedPsiMetric {
304                            created_at: record.created_at,
305                            psi: psi_vec?,
306                            overall_psi,
307                            bins: record.overall_proportions,
308                        },
309                    ))
310
311                    // calculate overall psi
312                })
313                .collect::<Result<BTreeMap<String, BinnedPsiMetric>, DriftError>>()?;
314
315            Ok(BinnedPsiFeatureMetrics {
316                features: binned_map,
317            })
318        }
319    }
320
321    #[cfg(test)]
322    mod tests {
323        use super::*;
324        use ndarray::Array;
325        use ndarray_rand::rand_distr::Uniform;
326        use ndarray_rand::RandomExt;
327        use scouter_types::psi::{Bin, BinType, PsiNormalThreshold, PsiThreshold};
328        use scouter_types::psi::{
329            DistributionData, FeatureDistributions, PsiAlertConfig, PsiDriftConfig,
330        };
331
332        fn get_test_drifter(threshold: PsiThreshold) -> PsiDrifter {
333            let alert_config = PsiAlertConfig {
334                features_to_monitor: vec!["feature_1".to_string(), "feature_3".to_string()],
335                threshold,
336                ..Default::default()
337            };
338            let config = PsiDriftConfig {
339                space: "name".to_string(),
340                name: "repo".to_string(),
341                alert_config,
342                ..Default::default()
343            };
344
345            let array = Array::random((1030, 3), Uniform::new(1.0, 100.0).unwrap());
346
347            let features = vec![
348                "feature_1".to_string(),
349                "feature_2".to_string(),
350                "feature_3".to_string(),
351            ];
352
353            let monitor = PsiMonitor::new();
354
355            let profile = monitor
356                .create_2d_drift_profile(&features, &array.view(), &config)
357                .unwrap();
358
359            PsiDrifter::new(profile)
360        }
361
362        #[test]
363        fn test_get_drift_map_only_maps_matching_features() {
364            // Arrange
365            // Create target distributions
366            let mut distributions = BTreeMap::new();
367
368            let mut bins1 = BTreeMap::new();
369            bins1.insert(0, 0.3);
370            bins1.insert(1, 0.4);
371            bins1.insert(2, 0.3);
372
373            distributions.insert(
374                "feature1".to_string(),
375                DistributionData {
376                    sample_size: 1000,
377                    bins: bins1,
378                },
379            );
380
381            let mut bins2 = BTreeMap::new();
382            bins2.insert(0, 0.25);
383            bins2.insert(1, 0.5);
384            bins2.insert(2, 0.25);
385
386            distributions.insert(
387                "feature2".to_string(),
388                DistributionData {
389                    sample_size: 800,
390                    bins: bins2,
391                },
392            );
393
394            let target_distributions = FeatureDistributions { distributions };
395
396            // Create profiles with one matching and one non-matching feature
397            let profiles = vec![
398                PsiFeatureDriftProfile {
399                    id: "feature1".to_string(), // This exists in target_distributions
400                    bins: vec![Bin {
401                        id: 0,
402                        lower_limit: None,
403                        upper_limit: Some(10.0),
404                        proportion: 0.35,
405                    }],
406                    timestamp: Utc::now(),
407                    bin_type: BinType::Numeric,
408                },
409                PsiFeatureDriftProfile {
410                    id: "feature2".to_string(), // This doesn't exist
411                    bins: vec![Bin {
412                        id: 0,
413                        lower_limit: None,
414                        upper_limit: Some(10.0),
415                        proportion: 0.5,
416                    }],
417                    timestamp: Utc::now(),
418                    bin_type: BinType::Numeric,
419                },
420            ];
421
422            // Act
423            let result = PsiDrifter::get_drift_map(&target_distributions, &profiles);
424
425            // Assert
426            assert!(result.is_ok());
427            let drift_map = result.unwrap();
428
429            // Only the matching feature should be in the result
430            assert_eq!(drift_map.len(), 2);
431            assert!(drift_map.contains_key("feature1"));
432            assert!(drift_map.contains_key("feature2"));
433        }
434
435        #[test]
436        fn test_get_feature_alerts_all_above() {
437            let bin_count = 10;
438            let sample_size = 10000;
439            let threshold = PsiNormalThreshold { alpha: 0.05 };
440            let result = threshold.compute_threshold(sample_size, bin_count);
441
442            let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
443
444            let feature_1 = "feature_1";
445            let feature_2 = "feature_2";
446            let feature_3 = "feature_3";
447
448            let mut drift_map = HashMap::new();
449            drift_map.insert(feature_1.to_string(), result + 0.1);
450            drift_map.insert(feature_2.to_string(), result + 0.1);
451            drift_map.insert(feature_3.to_string(), result + 0.1);
452
453            let mut distributions = BTreeMap::new();
454            let mut bins = BTreeMap::new();
455            for i in 0..bin_count {
456                bins.insert(i as i32, (sample_size / bin_count) as f64);
457            }
458
459            distributions.insert(
460                feature_1.to_string(),
461                DistributionData {
462                    sample_size,
463                    bins: bins.clone(),
464                },
465            );
466            distributions.insert(
467                feature_2.to_string(),
468                DistributionData {
469                    sample_size,
470                    bins: bins.clone(),
471                },
472            );
473            distributions.insert(
474                feature_3.to_string(),
475                DistributionData {
476                    sample_size,
477                    bins: bins.clone(),
478                },
479            );
480
481            let target_feature_distributions = FeatureDistributions { distributions };
482
483            let alerts = drifter_with_normal_threshold
484                .get_feature_alerts(&drift_map, &target_feature_distributions);
485
486            assert_eq!(alerts.len(), 3);
487        }
488
489        #[test]
490        fn test_get_feature_alerts_all_below() {
491            let bin_count = 10;
492            let sample_size = 10000;
493            let threshold = PsiNormalThreshold { alpha: 0.05 };
494            let result = threshold.compute_threshold(sample_size, bin_count);
495
496            let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
497
498            let feature_1 = "feature_1";
499            let feature_2 = "feature_2";
500            let feature_3 = "feature_3";
501
502            let mut drift_map = HashMap::new();
503            drift_map.insert(feature_1.to_string(), result - 0.1); // Below threshold
504            drift_map.insert(feature_2.to_string(), result - 0.1); // Below threshold
505            drift_map.insert(feature_3.to_string(), result - 0.1); // Below threshold
506
507            let mut distributions = BTreeMap::new();
508            let mut bins = BTreeMap::new();
509            for i in 0..bin_count {
510                bins.insert(i as i32, (sample_size / bin_count) as f64);
511            }
512
513            distributions.insert(
514                feature_1.to_string(),
515                DistributionData {
516                    sample_size,
517                    bins: bins.clone(),
518                },
519            );
520            distributions.insert(
521                feature_2.to_string(),
522                DistributionData {
523                    sample_size,
524                    bins: bins.clone(),
525                },
526            );
527            distributions.insert(
528                feature_3.to_string(),
529                DistributionData {
530                    sample_size,
531                    bins: bins.clone(),
532                },
533            );
534
535            let target_feature_distributions = FeatureDistributions { distributions };
536
537            let alerts = drifter_with_normal_threshold
538                .get_feature_alerts(&drift_map, &target_feature_distributions);
539
540            assert_eq!(alerts.len(), 0); // No alerts expected
541        }
542
543        #[test]
544        fn test_get_feature_alerts_mixed_above_below() {
545            let bin_count = 10;
546            let sample_size = 10000;
547            let threshold = PsiNormalThreshold { alpha: 0.05 };
548            let result = threshold.compute_threshold(sample_size, bin_count);
549
550            let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
551
552            let feature_1 = "feature_1";
553            let feature_2 = "feature_2";
554            let feature_3 = "feature_3";
555
556            let mut drift_map = HashMap::new();
557            drift_map.insert(feature_1.to_string(), result + 0.1); // Above threshold
558            drift_map.insert(feature_2.to_string(), result - 0.1); // Below threshold
559            drift_map.insert(feature_3.to_string(), result + 0.2); // Above threshold
560
561            let mut distributions = BTreeMap::new();
562            let mut bins = BTreeMap::new();
563            for i in 0..bin_count {
564                bins.insert(i as i32, (sample_size / bin_count) as f64);
565            }
566
567            distributions.insert(
568                feature_1.to_string(),
569                DistributionData {
570                    sample_size,
571                    bins: bins.clone(),
572                },
573            );
574            distributions.insert(
575                feature_2.to_string(),
576                DistributionData {
577                    sample_size,
578                    bins: bins.clone(),
579                },
580            );
581            distributions.insert(
582                feature_3.to_string(),
583                DistributionData {
584                    sample_size,
585                    bins: bins.clone(),
586                },
587            );
588
589            let target_feature_distributions = FeatureDistributions { distributions };
590
591            let alerts = drifter_with_normal_threshold
592                .get_feature_alerts(&drift_map, &target_feature_distributions);
593
594            assert_eq!(alerts.len(), 2); // Only feature_1 and feature_3 should alert
595
596            // Verify correct features alerted
597            let alert_features: Vec<String> = alerts.iter().map(|a| a.feature.clone()).collect();
598            assert!(alert_features.contains(&feature_1.to_string()));
599            assert!(alert_features.contains(&feature_3.to_string()));
600            assert!(!alert_features.contains(&feature_2.to_string()));
601        }
602
603        #[test]
604        fn test_get_feature_alerts_drift_exactly_at_threshold() {
605            let bin_count = 10;
606            let sample_size = 10000;
607            let threshold = PsiNormalThreshold { alpha: 0.05 };
608            let result = threshold.compute_threshold(sample_size, bin_count);
609
610            let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
611
612            let feature_1 = "feature_1";
613
614            let mut drift_map = HashMap::new();
615            drift_map.insert(feature_1.to_string(), result); // Exactly at threshold
616
617            let mut distributions = BTreeMap::new();
618            let mut bins = BTreeMap::new();
619            for i in 0..bin_count {
620                bins.insert(i as i32, (sample_size / bin_count) as f64);
621            }
622
623            distributions.insert(
624                feature_1.to_string(),
625                DistributionData {
626                    sample_size,
627                    bins: bins.clone(),
628                },
629            );
630
631            let target_feature_distributions = FeatureDistributions { distributions };
632
633            let alerts = drifter_with_normal_threshold
634                .get_feature_alerts(&drift_map, &target_feature_distributions);
635
636            assert_eq!(alerts.len(), 0); // Should be 0 since drift is not > threshold
637        }
638
639        #[test]
640        fn test_get_monitored_profiles() {
641            let drifter = get_test_drifter(PsiThreshold::default());
642
643            let profiles_to_monitor = drifter.get_monitored_profiles();
644
645            assert_eq!(profiles_to_monitor.len(), 2);
646
647            assert!(
648                profiles_to_monitor[0].id == "feature_1"
649                    || profiles_to_monitor[0].id == "feature_3"
650            );
651            assert!(
652                profiles_to_monitor[1].id == "feature_1"
653                    || profiles_to_monitor[1].id == "feature_3"
654            );
655        }
656    }
657}