22#
33# License: Apache Software License 2.0
44
5- """ This module contains the different drift detection method implementations.
5+ """This module contains the different drift detection method implementations.
66
77The :class:`~nannyml.drift.univariate.methods.MethodFactory` will convert the drift detection method names
88into an instance of the base :class:`~nannyml.drift.univariate.methods.Method` class.
@@ -62,10 +62,8 @@ def __init__(
6262 computation_params : dict, default=None
6363 A dictionary specifying parameter names and values to be used in the computation of the
6464 drift method.
65- upper_threshold : float, default=None
66- An optional upper threshold for the data quality metric.
67- lower_threshold : float, default=None
68- An optional lower threshold for the data quality metric.
65+ threshold : Threshold
66+ Threshold class defining threshold strategy.
6967 upper_threshold_limit : float, default=None
7068 An optional upper threshold limit for the data quality metric.
7169 lower_threshold_limit : float, default=0
@@ -257,6 +255,7 @@ class JensenShannonDistance(Method):
257255 """
258256
259257 def __init__ (self , ** kwargs ) -> None :
258+ """Initialize Jensen-Shannon method."""
260259 super ().__init__ (
261260 display_name = 'Jensen-Shannon distance' ,
262261 column_name = 'jensen_shannon' ,
@@ -339,6 +338,7 @@ class KolmogorovSmirnovStatistic(Method):
339338 """
340339
341340 def __init__ (self , ** kwargs ) -> None :
341+ """Initialize Kolmogorov-Smirnov method."""
342342 super ().__init__ (
343343 display_name = 'Kolmogorov-Smirnov statistic' ,
344344 column_name = 'kolmogorov_smirnov' ,
@@ -405,7 +405,7 @@ def _calculate(self, data: pd.Series):
405405 chunk_rel_freqs = chunk_proba_in_qts / len (data )
406406 rel_freq_lower_than_edges = len (data [data < self ._qts [0 ]]) / len (data )
407407 chunk_rel_freqs = rel_freq_lower_than_edges + np .cumsum (chunk_rel_freqs )
408- stat = np .max (abs (self ._ref_rel_freqs - chunk_rel_freqs ))
408+ stat = np .max (abs (self ._ref_rel_freqs - chunk_rel_freqs )) # type: ignore
409409 else :
410410 stat , _ = ks_2samp (self ._reference_data , data )
411411
@@ -420,6 +420,7 @@ class Chi2Statistic(Method):
420420 """
421421
422422 def __init__ (self , ** kwargs ) -> None :
423+ """Initialize Chi2-contingency method."""
423424 super ().__init__ (
424425 display_name = 'Chi2 statistic' ,
425426 column_name = 'chi2' ,
@@ -444,6 +445,16 @@ def __init__(self, **kwargs) -> None:
444445 self ._fitted = False
445446
446447 def fit (self , reference_data : pd .Series , timestamps : Optional [pd .Series ] = None ) -> Self :
448+ """Fits Chi2 Method on reference data.
449+
450+ Parameters
451+ ----------
452+ reference_data: pd.DataFrame
453+ The reference data used for fitting a Method. Must have target data available.
454+ timestamps: Optional[pd.Series], default=None
455+ A series containing the reference data Timestamps
456+
457+ """
447458 super ().fit (reference_data , timestamps )
448459
449460 # Thresholding is based on p-values. Ignoring all custom thresholding and disable plotting a threshold
@@ -470,6 +481,16 @@ def _calculate(self, data: pd.Series):
470481 return stat
471482
472483 def alert (self , value : float ):
484+ """Evaluates if an alert has occurred for Chi2 on the current chunk data.
485+
486+ For Chi2 alerts are based on p-values rather than the actual method values like
487+ in all other Univariate drift methods.
488+
489+ Parameters
490+ ----------
491+ value: float
492+ The method value for a given chunk
493+ """
473494 return self ._p_value < 0.05
474495
475496 def _calc_chi2 (self , data : pd .Series ):
@@ -491,6 +512,7 @@ class LInfinityDistance(Method):
491512 """
492513
493514 def __init__ (self , ** kwargs ) -> None :
515+ """Initialize L-Infinity Distance method."""
494516 super ().__init__ (
495517 display_name = 'L-Infinity distance' ,
496518 column_name = 'l_infinity' ,
@@ -537,6 +559,7 @@ class WassersteinDistance(Method):
537559 """
538560
539561 def __init__ (self , ** kwargs ) -> None :
562+ """Initialize Wasserstein Distance method."""
540563 super ().__init__ (
541564 display_name = 'Wasserstein distance' ,
542565 column_name = 'wasserstein' ,
@@ -559,6 +582,9 @@ def __init__(self, **kwargs) -> None:
559582 self ._bin_width : float
560583 self ._bin_edges : np .ndarray
561584 self ._ref_rel_freqs : Optional [np .ndarray ] = None
585+ self ._ref_min : float
586+ self ._ref_max : float
587+ self ._ref_cdf : np .ndarray
562588 self ._fitted = False
563589 if (
564590 (not kwargs )
@@ -579,6 +605,9 @@ def _fit(self, reference_data: pd.Series, timestamps: Optional[pd.Series] = None
579605 reference_proba_in_bins , self ._bin_edges = np .histogram (reference_data , bins = self .n_bins )
580606 self ._ref_rel_freqs = reference_proba_in_bins / len (reference_data )
581607 self ._bin_width = self ._bin_edges [1 ] - self ._bin_edges [0 ]
608+ self ._ref_min = self ._bin_edges [0 ]
609+ self ._ref_max = self ._bin_edges [- 1 ]
610+ self ._ref_cdf = np .cumsum (self ._ref_rel_freqs )
582611
583612 self ._fitted = True
584613 self ._reference_size = len (reference_data )
@@ -596,54 +625,57 @@ def _calculate(self, data: pd.Series):
596625 if (
597626 self .calculation_method == 'auto' and self ._reference_size >= 10_000
598627 ) or self .calculation_method == 'estimated' :
599- min_chunk = np .min (data )
600-
601- if min_chunk < self ._bin_edges [0 ]:
602- extra_bins_left = (min_chunk - self ._bin_edges [0 ]) / self ._bin_width
603- extra_bins_left = np .ceil (extra_bins_left )
628+ data_smaller = data [data < self ._ref_min ]
629+ data_bigger = data [data > self ._ref_max ]
630+ n_smaller = len (data_smaller )
631+ n_bigger = len (data_bigger )
632+
633+ if n_smaller > 0 :
634+ amount_smaller = (n_smaller + 1 ) / len (data )
635+ smaller_with_first_ref_value = np .concatenate ((data_smaller , [self ._ref_min ]))
636+ x , y = self ._ecdf (smaller_with_first_ref_value )
637+ term_smaller = np .sum ((y )[:- 1 ] * np .diff (x ))
638+ term_smaller = term_smaller * amount_smaller
604639 else :
605- extra_bins_left = 0
606-
607- max_chunk = np .max (data )
608-
609- if max_chunk > self ._bin_edges [- 1 ]:
610- extra_bins_right = (max_chunk - self ._bin_edges [- 1 ]) / self ._bin_width
611- extra_bins_right = np .ceil (extra_bins_right )
640+ term_smaller , amount_smaller = 0 , 0
641+
642+ if n_bigger > 0 :
643+ amount_bigger = (n_bigger + 1 ) / len (data )
644+ bigger_with_last_ref_value = np .concatenate (([self ._ref_max ], data_bigger ))
645+ x , y = self ._ecdf (bigger_with_last_ref_value )
646+ term_bigger = np .sum ((1 - y )[:- 1 ] * np .diff (x ))
647+ term_bigger = term_bigger * amount_bigger
612648 else :
613- extra_bins_right = 0
649+ term_bigger , amount_bigger = 0 , 0
614650
615- left_edges_to_prepand = np .arange (
616- min_chunk - self ._bin_width , self ._bin_edges [0 ] - self ._bin_width , self ._bin_width
617- )
618- right_edges_to_append = np .arange (
619- self ._bin_edges [- 1 ] + self ._bin_width , max_chunk + self ._bin_width , self ._bin_width
620- )
621-
622- updated_edges = np .concatenate ([left_edges_to_prepand , self ._bin_edges , right_edges_to_append ])
623- updated_ref_binned_pdf = np .concatenate (
624- [np .zeros (len (left_edges_to_prepand )), self ._ref_rel_freqs , np .zeros (len (right_edges_to_append ))]
625- )
651+ data_histogram , _ = np .histogram (data , bins = self ._bin_edges )
652+ data_histogram = data_histogram / len (data )
626653
627- chunk_histogram , _ = np .histogram (data , bins = updated_edges )
654+ data_cdf = np .cumsum (data_histogram )
655+ data_cdf = data_cdf + amount_smaller # if there's some data on the left-hand side
656+ term_within = np .sum (np .abs (self ._ref_cdf - data_cdf ) * self ._bin_width )
628657
629- chunk_binned_pdf = chunk_histogram / len (data )
630-
631- ref_binned_cdf = np .cumsum (updated_ref_binned_pdf )
632- chunk_binned_cdf = np .cumsum (chunk_binned_pdf )
633-
634- distance = np .sum (np .abs (ref_binned_cdf - chunk_binned_cdf ) * self ._bin_width )
658+ distance = term_within + term_smaller + term_bigger
635659 else :
636660 distance = wasserstein_distance (self ._reference_data , data )
637661
638662 return distance
639663
664+ def _ecdf (self , vec : np .ndarray ):
665+ """Custom implementation to calculate ECDF."""
666+ vec = np .sort (vec )
667+ x , counts = np .unique (vec , return_counts = True )
668+ cdf = np .cumsum (counts ) / len (vec )
669+ return x , cdf
670+
640671
641672@MethodFactory .register (key = 'hellinger' , feature_type = FeatureType .CONTINUOUS )
642673@MethodFactory .register (key = 'hellinger' , feature_type = FeatureType .CATEGORICAL )
643674class HellingerDistance (Method ):
644675 """Calculates the Hellinger Distance between two distributions."""
645676
646677 def __init__ (self , ** kwargs ) -> None :
678+ """Initialize Hellinger Distance method."""
647679 super ().__init__ (
648680 display_name = 'Hellinger distance' ,
649681 column_name = 'hellinger' ,
0 commit comments