tinyshift 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,249 @@
1
+ from ..plot import plot
2
+ import numpy as np
3
+ from scipy.stats import norm
4
+ from typing import Callable, Union, Tuple, Dict
5
+ import pandas as pd
6
+
7
+
8
+ class BaseModel:
9
+ def __init__(
10
+ self,
11
+ reference: pd.DataFrame,
12
+ confidence_level: float,
13
+ statistic: Callable,
14
+ n_resamples: int,
15
+ random_state: int,
16
+ drift_limit: Union[str, Tuple[float, float]],
17
+ ):
18
+ """
19
+ Initializes the BaseModel class with reference distribution, statistics, and drift limits.
20
+
21
+ Parameters:
22
+ ----------
23
+ reference : pd.DataFrame
24
+ Data containing the reference distribution with a "metric" column.
25
+ confidence_level : float
26
+ Desired confidence level for statistical calculations (e.g., 0.95).
27
+ statistic : Callable
28
+ Function to compute summary statistics (e.g., np.mean).
29
+ n_resamples : int
30
+ Number of bootstrap resamples.
31
+ random_state : int
32
+ Seed for reproducibility.
33
+ drift_limit : Union[str, Tuple[float, float]]
34
+ Method ("deviation" or "mad") or custom limits for drift thresholding.
35
+ """
36
+
37
+ self.statistics = self._statistic_generate(
38
+ reference,
39
+ confidence_level,
40
+ statistic,
41
+ n_resamples,
42
+ random_state,
43
+ )
44
+ self.plot = plot.Plot(self.statistics, reference)
45
+ self._drift_limit_generate(self.statistics, reference, drift_limit)
46
+
47
+ def _jackknife_acceleration(self, data: np.ndarray, statistic: Callable) -> float:
48
+ """Calculate the acceleration parameter using jackknife resampling."""
49
+ n = len(data)
50
+ jackknife = np.array([statistic(np.delete(data, i)) for i in range(n)])
51
+ jackknife_mean = jackknife.mean()
52
+ diffs = jackknife - jackknife_mean
53
+ acceleration = np.sum(diffs**3) / (6.0 * (np.sum(diffs**2) ** 1.5))
54
+ return acceleration
55
+
56
+ def _bootstrap_statistics(
57
+ self, data: np.ndarray, statistic: Callable, n_resamples: int
58
+ ) -> np.ndarray:
59
+ """Perform bootstrap resampling and calculate statistics."""
60
+ return np.array(
61
+ [
62
+ statistic(np.random.choice(data, size=len(data), replace=True))
63
+ for _ in range(n_resamples)
64
+ ]
65
+ )
66
+
67
+ def _bootstrapping_bca(
68
+ self,
69
+ data: pd.Series,
70
+ confidence_level: float,
71
+ statistic: Callable,
72
+ n_resamples: int,
73
+ random_state: int,
74
+ ) -> Union[float, float]:
75
+ """
76
+ Calculates the bias-corrected and accelerated (BCa) bootstrap confidence interval for the given data.
77
+
78
+ Parameters:
79
+ - data (list or numpy array): Sample data.
80
+ - confidence_level (float): Desired confidence level (e.g., 0.95 for 95%).
81
+ - statistic (function): Statistical function to apply to the data. Default is np.mean.
82
+ - n_resamples (int): Number of bootstrap resamples to perform. Default is 1000.
83
+ - random_state (int): Random seed for reproducibility.
84
+
85
+ Returns:
86
+ - tuple: A tuple containing the lower and upper bounds of the BCa confidence interval.
87
+ """
88
+ np.random.seed(random_state)
89
+ data = np.asarray(data)
90
+
91
+ # Bootstrap resampling
92
+ sample_statistics = self._bootstrap_statistics(data, statistic, n_resamples)
93
+
94
+ # Jackknife resampling for acceleration
95
+ acceleration = self._jackknife_acceleration(data, statistic)
96
+
97
+ # Bias correction
98
+ observed_stat = statistic(data)
99
+ bias = np.mean(sample_statistics < observed_stat)
100
+ z0 = norm.ppf(bias)
101
+
102
+ # Adjusting percentiles
103
+ alpha = 1 - confidence_level
104
+ z_alpha = norm.ppf(1 - alpha / 2)
105
+
106
+ z_lower_bound = (z0 - z_alpha) / (1 - acceleration * (z0 - z_alpha)) + z0
107
+ z_upper_bound = (z0 + z_alpha) / (1 - acceleration * (z0 + z_alpha)) + z0
108
+
109
+ alpha_lower = norm.cdf(z_lower_bound)
110
+ alpha_upper = norm.cdf(z_upper_bound)
111
+
112
+ # Calculate lower and upper bounds from the percentiles
113
+ lower_bound = np.quantile(sample_statistics, alpha_lower)
114
+ upper_bound = np.quantile(sample_statistics, alpha_upper)
115
+
116
+ return lower_bound, upper_bound
117
+
118
+ def _statistic_generate(
119
+ self,
120
+ df: pd.DataFrame,
121
+ confidence_level: float,
122
+ statistic: Callable,
123
+ n_resamples: int,
124
+ random_state: int,
125
+ ):
126
+ """
127
+ Calculate statistics for the reference distances, including confidence intervals and thresholds.
128
+ """
129
+ ci_lower, ci_upper = self._bootstrapping_bca(
130
+ df["metric"],
131
+ confidence_level,
132
+ statistic,
133
+ n_resamples,
134
+ random_state,
135
+ )
136
+ estimated_mean = np.mean(df["metric"])
137
+
138
+ return {
139
+ "ci_lower": ci_lower,
140
+ "ci_upper": ci_upper,
141
+ "mean": estimated_mean,
142
+ }
143
+
144
+ def _custom_threshold(
145
+ self, data: pd.Series, custom_func: Callable
146
+ ) -> Tuple[float, float]:
147
+ """Calculate thresholds using a custom function."""
148
+ return custom_func(data)
149
+
150
+ def _calculate_threshold(
151
+ self, data: pd.Series, center: Callable, spread: Callable, factor: float = 3
152
+ ) -> Tuple[float, float]:
153
+ """Calculate thresholds using a central tendency and spread function."""
154
+ center_value = center(data)
155
+ spread_value = spread(data)
156
+ lower_limit = center_value - factor * spread_value
157
+ upper_limit = center_value + factor * spread_value
158
+ return lower_limit, upper_limit
159
+
160
+ def _iqr_threshold(self, df: pd.DataFrame) -> Tuple[float, float]:
161
+ """
162
+ Calculates thresholds using IQR and median with a default factor of 1.5.
163
+ """
164
+
165
+ def iqr(x):
166
+ q75, q25 = np.percentile(x, [75, 25])
167
+ return q75 - q25
168
+
169
+ return self._calculate_threshold(df["metric"], np.median, iqr, factor=1.5)
170
+
171
+ def _deviation_threshold(self, df: pd.DataFrame) -> Tuple[float, float]:
172
+ """Calculates thresholds using mean and standard deviation."""
173
+ return self._calculate_threshold(df["metric"], np.mean, np.std)
174
+
175
+ def _mad_threshold(self, df: pd.DataFrame) -> Tuple[float, float]:
176
+ """Calculates thresholds using mean and Median Absolute Deviation (MAD)."""
177
+ mad = lambda x: np.median(np.abs(x - np.median(x)))
178
+ return self._calculate_threshold(df["metric"], np.mean, mad)
179
+
180
+ def _drift_limit_generate(
181
+ self,
182
+ statistics: Dict,
183
+ distribution: pd.DataFrame,
184
+ drift_limit: Union[str, Callable, Tuple[float]],
185
+ ):
186
+ """
187
+ Determines the drift limits based on the specified method.
188
+ """
189
+ if isinstance(drift_limit, str):
190
+ if drift_limit == "deviation":
191
+ lower_limit, upper_limit = self._deviation_threshold(distribution)
192
+ elif drift_limit == "mad":
193
+ lower_limit, upper_limit = self._mad_threshold(distribution)
194
+ elif drift_limit == "iqr":
195
+ lower_limit, upper_limit = self._iqr_threshold(distribution)
196
+ else:
197
+ raise ValueError(f"Unsupported drift limit method: {drift_limit}")
198
+ elif callable(drift_limit):
199
+ lower_limit, upper_limit = self._custom_threshold(
200
+ distribution["metric"], drift_limit
201
+ )
202
+ elif isinstance(drift_limit, tuple) and len(drift_limit) == 2:
203
+ lower_limit, upper_limit = drift_limit
204
+ else:
205
+ raise ValueError("Invalid drift limit specification.")
206
+
207
+ # Update the statistics dictionary with the new thresholds
208
+ statistics["lower_limit"] = lower_limit
209
+ statistics["upper_limit"] = upper_limit
210
+
211
+ def _validate_columns(
212
+ self,
213
+ df: pd.DataFrame,
214
+ target_col: str,
215
+ datetime_col: str,
216
+ ):
217
+ if target_col not in df.columns:
218
+ raise KeyError(f"Column {target_col} is not in the DataFrame.")
219
+ if datetime_col not in df.columns:
220
+ raise KeyError(f"Datetime column {datetime_col} is not in the DataFrame.")
221
+ if not pd.api.types.is_datetime64_any_dtype(df[datetime_col]):
222
+ raise TypeError(f"Column {datetime_col} must be of datetime type.")
223
+
224
+ def _validate_params(
225
+ self,
226
+ confidence_level: float,
227
+ n_resamples: int,
228
+ period: str,
229
+ ):
230
+ if not 0 < confidence_level <= 1:
231
+ raise ValueError("confidence_level must be between 0 and 1.")
232
+ if n_resamples <= 0:
233
+ raise ValueError("n_resamples must be a positive integer.")
234
+ if not isinstance(period, str):
235
+ raise TypeError("period must be a string (e.g., 'W', 'M').")
236
+
237
+ def _is_drifted(self, df: pd.DataFrame) -> pd.Series:
238
+ """
239
+ Checks if metrics in the DataFrame are outside specified limits
240
+ and returns the drift status.
241
+ """
242
+ is_drifted = pd.Series([False] * len(df))
243
+
244
+ if self.statistics["lower_limit"] is not None:
245
+ is_drifted |= df["metric"] <= self.statistics["lower_limit"]
246
+ if self.statistics["upper_limit"] is not None:
247
+ is_drifted |= df["metric"] >= self.statistics["upper_limit"]
248
+
249
+ return is_drifted
@@ -0,0 +1,2 @@
1
+ from .continuous import ContinuousDriftDetector
2
+ from .categorical import CategoricalDriftDetector
@@ -0,0 +1,226 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy.spatial.distance import jensenshannon
4
+ from ..base.model import BaseModel
5
+ from typing import Callable, Tuple, Union
6
+
7
+
8
+ def l_infinity(a, b):
9
+ """
10
+ Compute the L-infinity distance between two distributions.
11
+ """
12
+ return np.max(np.abs(a - b))
13
+
14
+
15
+ class CategoricalDriftDetector(BaseModel):
16
+ def __init__(
17
+ self,
18
+ reference: pd.DataFrame,
19
+ target_col: str,
20
+ datetime_col: str,
21
+ period: str,
22
+ func: str = "l_infinity",
23
+ statistic: Callable = np.mean,
24
+ confidence_level: float = 0.997,
25
+ n_resamples: int = 1000,
26
+ random_state: int = 42,
27
+ drift_limit: Union[str, Tuple[float, float]] = "deviation",
28
+ ):
29
+ """
30
+ A detector for identifying drift in categorical data over time. The detector uses
31
+ a reference dataset to compute a baseline distribution and compare subsequent data
32
+ for deviations based on a distance metric and drift limits.
33
+
34
+ Parameters:
35
+ ----------
36
+ reference : DataFrame
37
+ The reference dataset used to compute the baseline distribution.
38
+ target_col : str
39
+ The name of the column containing the categorical variable to analyze.
40
+ datetime_col : str
41
+ The name of the column containing datetime values for temporal grouping.
42
+ period : str
43
+ The frequency for grouping data (e.g., 'D' for daily, 'M' for monthly).
44
+ func : str, optional
45
+ The distance function to use ('l_infinity' or 'jensenshannon').
46
+ Default is 'l_infinity'.
47
+ statistic : callable, optional
48
+ The statistic function used to summarize the reference distances.
49
+ Default is `np.mean`.
50
+ confidence_level : float, optional
51
+ The confidence level for calculating statistical thresholds.
52
+ Default is 0.997.
53
+ n_resamples : int, optional
54
+ Number of resamples for bootstrapping when calculating statistics.
55
+ Default is 1000.
56
+ random_state : int, optional
57
+ Seed for reproducibility of random resampling.
58
+ Default is 42.
59
+ drift_limit : tuple, optional
60
+ User-defined thresholds for drift detection.
61
+ Default is the 'deviation method'.
62
+
63
+ Attributes:
64
+ ----------
65
+ period : str
66
+ The grouping frequency used for analysis.
67
+ reference_frequency : DataFrame
68
+ The frequency distribution of the reference dataset.
69
+ reference_distance : DataFrame
70
+ The distance metric values for the reference dataset.
71
+ statistics : dict
72
+ Statistical thresholds and summary statistics for drift detection.
73
+ plot : Plot
74
+ A plotting utility for visualizing drift results.
75
+ """
76
+
77
+ self._validate_columns(reference, target_col, datetime_col)
78
+ self._validate_params(confidence_level, n_resamples, period)
79
+
80
+ self.period = period
81
+ self.func = self._selection_function(func)
82
+
83
+ self.reference_frequency = self._calculate_frequency(
84
+ reference,
85
+ target_col,
86
+ datetime_col,
87
+ period,
88
+ )
89
+
90
+ self.reference_distance = self._generate_distance(
91
+ self.reference_frequency,
92
+ )
93
+
94
+ super().__init__(
95
+ self.reference_distance,
96
+ confidence_level,
97
+ statistic,
98
+ n_resamples,
99
+ random_state,
100
+ drift_limit,
101
+ )
102
+
103
+ def _calculate_frequency(
104
+ self,
105
+ df: pd.DataFrame,
106
+ target_col: str,
107
+ datetime_col: str,
108
+ period: str,
109
+ ) -> pd.DataFrame:
110
+ """
111
+ Calculate the frequency distribution of the target column grouped by a time period.
112
+
113
+ Parameters:
114
+ ----------
115
+ df : DataFrame
116
+ The dataset to analyze.
117
+ target_col : str
118
+ The name of the categorical column.
119
+ datetime_col : str
120
+ The name of the datetime column for temporal grouping.
121
+ period : str
122
+ The frequency for grouping (e.g., 'D', 'M').
123
+
124
+ Returns:
125
+ -------
126
+ DataFrame
127
+ A pivot table of frequencies with time periods as rows and categorical
128
+ values as columns.
129
+ """
130
+ freq = (
131
+ df.groupby([pd.Grouper(key=datetime_col, freq=period), target_col])
132
+ .size()
133
+ .unstack(fill_value=0)
134
+ )
135
+
136
+ return freq
137
+
138
+ def _selection_function(self, func_name: str) -> Callable:
139
+ """Returns a specific function based on the given function name."""
140
+
141
+ if func_name == "l_infinity":
142
+ selected_func = l_infinity
143
+ elif func_name == "jensenshannon":
144
+ selected_func = jensenshannon
145
+ else:
146
+ raise ValueError(f"Unsupported distance function: {func_name}")
147
+ return selected_func
148
+
149
+ def _generate_distance(
150
+ self,
151
+ p: pd.DataFrame,
152
+ ) -> pd.DataFrame:
153
+ """
154
+ Compute a distance metric between consecutive periods in the frequency distribution.
155
+
156
+ Parameters:
157
+ ----------
158
+ p : DataFrame
159
+ The frequency distribution with time periods as rows and categorical values as columns.
160
+ func : str
161
+ The distance function to use ('l_infinity' or 'jensenshannon').
162
+
163
+ Returns:
164
+ -------
165
+ DataFrame
166
+ A DataFrame containing datetime values and the calculated distances.
167
+ """
168
+ n = p.shape[0]
169
+ distances = np.zeros(n)
170
+ past_value = np.zeros(p.shape[1], dtype=np.int32)
171
+ index = p.index[1:]
172
+ p = np.asarray(p)
173
+
174
+ for i in range(1, n):
175
+ past_value = past_value + p[i - 1]
176
+ past_value = past_value / np.sum(past_value)
177
+ current_value = p[i] / np.sum(p[i])
178
+ dist = self.func(past_value, current_value)
179
+ distances[i] = dist
180
+
181
+ return pd.DataFrame({"datetime": index, "metric": distances[1:]})
182
+
183
+ def score(
184
+ self,
185
+ analysis: pd.DataFrame,
186
+ target_col: str,
187
+ datetime_col: str,
188
+ ) -> pd.DataFrame:
189
+ """
190
+ Assess drift in the provided dataset by comparing its distribution to the reference.
191
+
192
+ Parameters:
193
+ ----------
194
+ analysis : DataFrame
195
+ The dataset to analyze for drift.
196
+ target_col : str
197
+ The name of the categorical column in the analysis dataset.
198
+ datetime_col : str
199
+ The name of the datetime column in the analysis dataset.
200
+
201
+ Returns:
202
+ -------
203
+ DataFrame
204
+ A DataFrame containing metrics and drift detection results for each time period.
205
+ """
206
+ self._validate_columns(analysis, target_col, datetime_col)
207
+
208
+ # Calculate frequency and percentage distribution
209
+ freq = self._calculate_frequency(
210
+ analysis, target_col, datetime_col, self.period
211
+ )
212
+ percent = freq.div(freq.sum(axis=1), axis=0)
213
+
214
+ # Calculate percentage distribution
215
+ ref_freq = self.reference_frequency.sum(axis=0)
216
+ ref_dist = ref_freq / np.sum(ref_freq)
217
+
218
+ # Calculate drift metrics for each time period
219
+ metrics = (
220
+ percent.apply(lambda row: self.func(row, ref_dist), axis=1)
221
+ .rename("metric")
222
+ .reset_index()
223
+ )
224
+ metrics["is_drifted"] = self._is_drifted(metrics)
225
+
226
+ return metrics
@@ -0,0 +1,222 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy.stats import ks_2samp, wasserstein_distance
4
+ from ..base.model import BaseModel
5
+ from typing import Callable, Tuple, Union
6
+
7
+
8
+ class ContinuousDriftDetector(BaseModel):
9
+ def __init__(
10
+ self,
11
+ reference: pd.DataFrame,
12
+ target_col: str,
13
+ datetime_col: str,
14
+ period: str,
15
+ func: str = "ws",
16
+ statistic: Callable = np.mean,
17
+ confidence_level: float = 0.997,
18
+ n_resamples: int = 1000,
19
+ random_state: int = 42,
20
+ drift_limit: Union[str, Tuple[float, float]] = "deviation",
21
+ ):
22
+ """
23
+ A detector for identifying drift in continuous data over time. The detector uses
24
+ a reference dataset to compute a baseline distribution and compare subsequent data
25
+ for deviations using the Kolmogorov-Smirnov test and statistical thresholds.
26
+
27
+ Parameters:
28
+ ----------
29
+ reference : DataFrame
30
+ The reference dataset used to compute the baseline distribution.
31
+ target_col : str
32
+ The name of the column containing the continuous variable to analyze.
33
+ datetime_col : str
34
+ The name of the column containing datetime values for temporal grouping.
35
+ period : str
36
+ The frequency for grouping data (e.g., '1D' for daily, '1H' for hourly).
37
+ statistic : callable, optional
38
+ The statistic function used to summarize the reference KS metrics.
39
+ Default is `np.mean`.
40
+ confidence_level : float, optional
41
+ The confidence level for calculating statistical thresholds.
42
+ Default is 0.997.
43
+ n_resamples : int, optional
44
+ Number of resamples for bootstrapping when calculating statistics.
45
+ Default is 1000.
46
+ random_state : int, optional
47
+ Seed for reproducibility of random resampling.
48
+ Default is 42.
49
+ thresholds : tuple, optional
50
+ User-defined thresholds for drift detection.
51
+ Default is an empty tuple.
52
+
53
+ Attributes:
54
+ ----------
55
+ period : str
56
+ The grouping frequency used for analysis.
57
+ reference_distribution : Series
58
+ The distribution of the reference dataset grouped by the specified period.
59
+ reference_ks : DataFrame
60
+ The Kolmogorov-Smirnov test results for the reference dataset.
61
+ statistics : dict
62
+ Statistical thresholds and summary statistics for drift detection.
63
+ plot : Plot
64
+ A plotting utility for visualizing drift results.
65
+ """
66
+
67
+ self._validate_columns(reference, target_col, datetime_col)
68
+ self._validate_params(confidence_level, n_resamples, period)
69
+
70
+ self.period = period
71
+ self.func = func
72
+
73
+ # Initialize frequency and statistics
74
+ self.reference_distribution = self._calculate_distribution(
75
+ reference,
76
+ target_col,
77
+ datetime_col,
78
+ period,
79
+ )
80
+
81
+ self.reference_distance = self._generate_distance(
82
+ self.reference_distribution, func
83
+ )
84
+
85
+ super().__init__(
86
+ self.reference_distance,
87
+ confidence_level,
88
+ statistic,
89
+ n_resamples,
90
+ random_state,
91
+ drift_limit,
92
+ )
93
+
94
+ def _calculate_distribution(
95
+ self,
96
+ df: pd.DataFrame,
97
+ column_name: str,
98
+ timestamp: str,
99
+ period: str,
100
+ ) -> pd.Series:
101
+ """
102
+ Calculate the continuous distribution of a target column grouped by a given period.
103
+
104
+ Parameters:
105
+ ----------
106
+ df : pd.DataFrame
107
+ The dataset to analyze.
108
+ column_name : str
109
+ The name of the column containing the continuous variable.
110
+ timestamp : str
111
+ The name of the datetime column for temporal grouping.
112
+ period : str
113
+ The frequency for grouping (e.g., '1D', '1H').
114
+
115
+ Returns:
116
+ -------
117
+ pd.Series
118
+ A Pandas Series where each index corresponds to a time period, and each value is
119
+ a list of continuous values for that period.
120
+ """
121
+ return (
122
+ df[[timestamp, column_name]]
123
+ .copy()
124
+ .groupby(pd.Grouper(key=timestamp, freq=period))[column_name]
125
+ .agg(list)
126
+ )
127
+
128
+ def _ks(self, a, b):
129
+ """Calculate the Kolmogorov-Smirnov test and return the p_value."""
130
+ _, p_value = ks_2samp(a, b)
131
+ return p_value
132
+
133
+ def _wasserstein(self, a, b):
134
+ """Calculate the Wasserstein Distance."""
135
+ return wasserstein_distance(a, b)
136
+
137
+ def _selection_function(self, func_name: str) -> Callable:
138
+ """Returns a specific function based on the given function name."""
139
+
140
+ if func_name == "ws":
141
+ selected_func = self._wasserstein
142
+ elif func_name == "ks":
143
+ selected_func = self._ks
144
+ else:
145
+ raise ValueError(f"Unsupported function: {func_name}")
146
+ return selected_func
147
+
148
+ def _generate_distance(
149
+ self,
150
+ p: pd.Series,
151
+ func_name: Callable,
152
+ ) -> pd.DataFrame:
153
+ """
154
+ Calculate the Kolmogorov-Smirnov test metric over a rolling cumulative window.
155
+
156
+ Parameters:
157
+ ----------
158
+ p : Series
159
+ A Pandas Series where each element is a list representing the distribution
160
+ of values for a specific period.
161
+
162
+ Returns:
163
+ -------
164
+ DataFrame
165
+ A DataFrame containing datetime indices and the calculated KS test metric
166
+ for each period.
167
+ """
168
+ func = self._selection_function(func_name)
169
+
170
+ n = p.shape[0]
171
+ values = np.zeros(n)
172
+ past_values = np.array([], dtype=float)
173
+
174
+ for i in range(1, n):
175
+ past_values = np.concatenate([past_values, p[i - 1]])
176
+ value = func(past_values, p[i])
177
+ values[i] = value
178
+
179
+ return pd.DataFrame({"datetime": p.index[1:], "metric": values[1:]})
180
+
181
+ def score(
182
+ self,
183
+ analysis: pd.DataFrame,
184
+ target_col: str,
185
+ datetime_col: str,
186
+ ) -> pd.DataFrame:
187
+ """
188
+ Assess drift in the provided dataset by comparing its distribution to the reference.
189
+
190
+ Parameters:
191
+ ----------
192
+ analysis : DataFrame
193
+ The dataset to analyze for drift.
194
+ target_col : str
195
+ The name of the continuous column in the analysis dataset.
196
+ datetime_col : str
197
+ The name of the datetime column in the analysis dataset.
198
+
199
+ Returns:
200
+ -------
201
+ DataFrame
202
+ A DataFrame containing datetime values, drift metrics, and a boolean
203
+ indicating whether drift was detected for each time period.
204
+ """
205
+
206
+ self._validate_columns(analysis, target_col, datetime_col)
207
+
208
+ reference = np.concatenate(self.reference_distribution)
209
+ dist = self._calculate_distribution(
210
+ analysis, target_col, datetime_col, self.period
211
+ )
212
+
213
+ func = self._selection_function(self.func)
214
+ metrics = np.array([func(reference, row) for row in dist])
215
+ metrics = pd.DataFrame(
216
+ {
217
+ "datetime": dist.index,
218
+ "metric": metrics,
219
+ },
220
+ )
221
+ metrics["is_drifted"] = self._is_drifted(metrics)
222
+ return metrics
File without changes
tinyshift/plot/plot.py ADDED
@@ -0,0 +1,213 @@
1
+ import plotly.graph_objects as go
2
+ import numpy as np
3
+ import plotly.express as px
4
+ import scipy.stats
5
+ import pandas as pd
6
+
7
+
8
+ class Plot:
9
+ def __init__(self, statistics, distribution):
10
+ self.statistics = statistics
11
+ self.distribution = distribution
12
+
13
+ def _update_layout(
14
+ self,
15
+ title: str,
16
+ xaxis_title: str,
17
+ yaxis_title: str,
18
+ width: int,
19
+ height: int,
20
+ ):
21
+ """
22
+ Helper function to update layout settings for the plots.
23
+ """
24
+ return dict(
25
+ title=title,
26
+ xaxis_title=xaxis_title,
27
+ yaxis_title=yaxis_title,
28
+ width=width,
29
+ height=height,
30
+ showlegend=True,
31
+ bargap=0,
32
+ bargroupgap=0,
33
+ )
34
+
35
+ def _add_limits(self, fig):
36
+ """
37
+ Helper function to add the lower and upper limits and the mean line to the plot.
38
+ """
39
+ lower_limit, upper_limit = self.statistics.get(
40
+ "lower_limit"
41
+ ), self.statistics.get("upper_limit")
42
+ if lower_limit:
43
+ fig.add_hline(
44
+ y=lower_limit,
45
+ line_dash="dash",
46
+ line_color="firebrick",
47
+ name="Lower Limit",
48
+ opacity=0.5,
49
+ )
50
+ if upper_limit:
51
+ fig.add_hline(
52
+ y=upper_limit,
53
+ line_dash="dash",
54
+ line_color="firebrick",
55
+ name="Upper Limit",
56
+ opacity=0.5,
57
+ )
58
+
59
+ fig.add_hline(
60
+ y=self.statistics["mean"],
61
+ line_dash="dash",
62
+ line_color="darkslateblue",
63
+ opacity=0.3,
64
+ name="Mean",
65
+ )
66
+
67
+ def kde(
68
+ self,
69
+ width: int = 600,
70
+ height: int = 400,
71
+ fig_type: str = None,
72
+ ):
73
+ """
74
+ Generate a Kernel Density Estimate (KDE) plot for the distribution's metric.
75
+ """
76
+ x_vals = np.linspace(
77
+ self.distribution["metric"].min(), self.distribution["metric"].max(), 1000
78
+ )
79
+ kde = scipy.stats.gaussian_kde(self.distribution["metric"])
80
+
81
+ # Create KDE plot using plotly.express
82
+ fig = px.line(x=x_vals, y=kde(x_vals))
83
+ fig.update_layout(
84
+ self._update_layout(
85
+ "Distribution of metric with Kernel Density Estimate (KDE)",
86
+ "Metric",
87
+ "Density",
88
+ width,
89
+ height,
90
+ )
91
+ )
92
+
93
+ return fig.show(fig_type)
94
+
95
+ def diverging_bar_over_time(
96
+ self,
97
+ analysis: pd.DataFrame,
98
+ width: int = 800,
99
+ height: int = 400,
100
+ fig_type: str = None,
101
+ ):
102
+ """
103
+ Generate a diverging bar plot showing metric over time relative to a reference line.
104
+ """
105
+ reference_line = self.statistics["mean"]
106
+ positive_bars = np.maximum(analysis["metric"] - reference_line, 0)
107
+ negative_bars = np.maximum(reference_line - analysis["metric"], 0)
108
+
109
+ fig = go.Figure()
110
+
111
+ fig.add_trace(
112
+ go.Bar(
113
+ x=analysis["datetime"],
114
+ y=positive_bars,
115
+ base=[reference_line] * len(positive_bars),
116
+ name="Above Reference",
117
+ marker_color="lightslategrey",
118
+ customdata=analysis.loc[analysis["metric"] >= reference_line, "metric"],
119
+ hovertemplate="(%{x},%{y:.3f})",
120
+ opacity=0.7,
121
+ )
122
+ )
123
+
124
+ fig.add_trace(
125
+ go.Bar(
126
+ x=analysis["datetime"],
127
+ y=negative_bars,
128
+ base=reference_line - negative_bars,
129
+ name="Below Reference",
130
+ marker_color="crimson",
131
+ customdata=analysis.loc[analysis["metric"] < reference_line, "metric"],
132
+ hovertemplate="(%{x},%{base:.3f})",
133
+ opacity=0.7,
134
+ )
135
+ )
136
+
137
+ fig.add_hrect(
138
+ y0=self.statistics["ci_lower"],
139
+ y1=self.statistics["ci_upper"],
140
+ line_width=0,
141
+ fillcolor="lightblue",
142
+ opacity=0.5,
143
+ name="Fixed Confidence Interval",
144
+ )
145
+
146
+ self._add_limits(fig)
147
+
148
+ fig.update_layout(
149
+ self._update_layout(
150
+ "Metric Over Time with Fixed Confidence Interval",
151
+ "Time",
152
+ "Metric",
153
+ width,
154
+ height,
155
+ )
156
+ )
157
+
158
+ return fig.show(fig_type)
159
+
160
+ def scatterplot_over_time(
161
+ self,
162
+ analysis: pd.DataFrame,
163
+ width: int = 800,
164
+ height: int = 400,
165
+ fig_type: str = None,
166
+ ):
167
+ """
168
+ Generate a time-series plot showing the metric performance with confidence interval and thresholds.
169
+ """
170
+ fig = go.Figure()
171
+
172
+ fig.add_trace(
173
+ go.Scatter(
174
+ x=analysis["datetime"],
175
+ y=analysis["metric"],
176
+ mode="lines+markers",
177
+ name="Metric",
178
+ )
179
+ )
180
+
181
+ # Confidence interval shading
182
+ fig.add_trace(
183
+ go.Scatter(
184
+ x=analysis["datetime"],
185
+ y=[self.statistics["ci_lower"], self.statistics["ci_upper"]],
186
+ fill="toself",
187
+ fillcolor="rgba(0, 100, 255, 0.2)",
188
+ line=dict(color="rgba(255,255,255,0)"),
189
+ name="Fixed Confidence Interval",
190
+ )
191
+ )
192
+
193
+ fig.add_hrect(
194
+ y0=self.statistics["ci_lower"],
195
+ y1=self.statistics["ci_upper"],
196
+ line_width=0,
197
+ fillcolor="lightblue",
198
+ opacity=0.5,
199
+ )
200
+
201
+ self._add_limits(fig)
202
+
203
+ fig.update_layout(
204
+ self._update_layout(
205
+ "Metric Over Time with Fixed Confidence Interval",
206
+ "Time",
207
+ "Metric",
208
+ width,
209
+ height,
210
+ )
211
+ )
212
+
213
+ return fig.show(fig_type)
@@ -0,0 +1 @@
1
+ from .performance import PerformanceTracker
@@ -0,0 +1,193 @@
1
+ import numpy as np
2
+ from sklearn.metrics import f1_score
3
+ import pandas as pd
4
+ from ..base.model import BaseModel
5
+ from typing import Callable, Tuple, Union
6
+
7
+
8
+ class PerformanceTracker(BaseModel):
9
+ def __init__(
10
+ self,
11
+ reference: pd.DataFrame,
12
+ target_col: str,
13
+ prediction_col: str,
14
+ datetime_col: str,
15
+ period: str,
16
+ metric_score: Callable = f1_score,
17
+ statistic: Callable = np.mean,
18
+ confidence_level: float = 0.997,
19
+ n_resamples: int = 1000,
20
+ random_state: int = 42,
21
+ drift_limit: Union[str, Tuple[float, float]] = "deviation",
22
+ ):
23
+ """
24
+ A tracker for monitoring model performance over time using a specified evaluation metric.
25
+ The tracker compares the performance metric across time periods to a reference distribution
26
+ and identifies potential performance degradation.
27
+
28
+ Parameters:
29
+ ----------
30
+ reference : DataFrame
31
+ The reference dataset used to compute the baseline metric distribution.
32
+ target_col : str
33
+ The name of the column containing the actual target values.
34
+ prediction_col : str
35
+ The name of the column containing the predicted values.
36
+ datetime_col : str
37
+ The name of the column containing datetime values for temporal grouping.
38
+ period : str
39
+ The frequency for grouping data (e.g., 'W' for weekly, 'M' for monthly).
40
+ metric_score : callable, optional
41
+ The function to compute the evaluation metric (e.g., `f1_score`).
42
+ Default is `f1_score`.
43
+ statistic : callable, optional
44
+ The statistic function used to summarize the reference metric distribution.
45
+ Default is `np.mean`.
46
+ confidence_level : float, optional
47
+ The confidence level for calculating statistical thresholds.
48
+ Default is 0.997.
49
+ n_resamples : int, optional
50
+ Number of resamples for bootstrapping when calculating statistics.
51
+ Default is 1000.
52
+ random_state : int, optional
53
+ Seed for reproducibility of random resampling.
54
+ Default is 42.
55
+ thresholds : tuple, optional
56
+ User-defined thresholds for drift detection.
57
+ Default is an empty tuple.
58
+
59
+ Attributes:
60
+ ----------
61
+ period : str
62
+ The grouping frequency used for analysis.
63
+ metric_score : callable
64
+ The evaluation metric function used for tracking performance.
65
+ reference_distribution : DataFrame
66
+ The performance metric distribution of the reference dataset.
67
+ statistics : dict
68
+ Statistical thresholds and summary statistics for performance monitoring.
69
+ plot : Plot
70
+ A plotting utility for visualizing performance over time.
71
+ """
72
+
73
+ self._validate_params(
74
+ confidence_level,
75
+ n_resamples,
76
+ period,
77
+ )
78
+ self._validate_columns(
79
+ reference,
80
+ target_col,
81
+ datetime_col,
82
+ )
83
+
84
+ if not callable(metric_score):
85
+ raise TypeError("metric_score must be a callable function.")
86
+
87
+ self.period = period
88
+ self.metric_score = metric_score
89
+
90
+ # Initialize distributions and statistics
91
+ self.reference_distribution = self._calculate_metric(
92
+ reference,
93
+ target_col,
94
+ prediction_col,
95
+ datetime_col,
96
+ )
97
+ super().__init__(
98
+ self.reference_distribution,
99
+ confidence_level,
100
+ statistic,
101
+ n_resamples,
102
+ random_state,
103
+ drift_limit,
104
+ )
105
+
106
+ def _calculate_metric(
107
+ self,
108
+ df: pd.DataFrame,
109
+ target_col: str,
110
+ prediction_col: str,
111
+ datetime_col: str,
112
+ ):
113
+ """
114
+ Calculate the performance metric for each time period in the dataset.
115
+
116
+ Parameters:
117
+ ----------
118
+ df : DataFrame
119
+ The dataset containing the data to analyze.
120
+ target_col : str
121
+ The name of the column containing the actual target values.
122
+ prediction_col : str
123
+ The name of the column containing the predicted values.
124
+ datetime_col : str
125
+ The name of the datetime column for temporal grouping.
126
+
127
+ Returns:
128
+ -------
129
+ DataFrame
130
+ A DataFrame with the calculated metric for each time period.
131
+ """
132
+ if target_col not in df.columns or prediction_col not in df.columns:
133
+ raise KeyError(
134
+ f"Columns {target_col} and/or {prediction_col} are not in the DataFrame."
135
+ )
136
+ if datetime_col not in df.columns:
137
+ raise KeyError(f"Datetime column {datetime_col} is not in the DataFrame.")
138
+ if not pd.api.types.is_datetime64_any_dtype(df[datetime_col]):
139
+ raise TypeError(f"Column {datetime_col} must be of datetime type.")
140
+
141
+ grouped = df.groupby(pd.Grouper(key=datetime_col, freq=self.period)).apply(
142
+ lambda x: self.metric_score(x[target_col], x[prediction_col])
143
+ )
144
+ return grouped.reset_index(name="metric")
145
+
146
+ def score(
147
+ self,
148
+ analysis: pd.DataFrame,
149
+ target_col: str,
150
+ prediction_col: str,
151
+ datetime_col: str,
152
+ ):
153
+ """
154
+ Assess model performance over time by calculating the evaluation metric
155
+ for each time period and comparing it to the reference distribution.
156
+
157
+ Parameters:
158
+ ----------
159
+ analysis : DataFrame
160
+ The dataset to analyze for performance drift.
161
+ target_col : str
162
+ The name of the column containing the actual target values.
163
+ prediction_col : str
164
+ The name of the column containing the predicted values.
165
+ datetime_col : str
166
+ The name of the datetime column for temporal grouping.
167
+
168
+ Returns:
169
+ -------
170
+ DataFrame
171
+ A DataFrame containing datetime values, calculated metrics, and a boolean
172
+ indicating whether performance drift was detected for each time period.
173
+ """
174
+
175
+ self._validate_columns(analysis, target_col, datetime_col)
176
+
177
+ if target_col not in analysis.columns or prediction_col not in analysis.columns:
178
+ raise KeyError(
179
+ f"Columns {target_col} and/or {prediction_col} are not in the DataFrame."
180
+ )
181
+ if datetime_col not in analysis.columns:
182
+ raise KeyError(f"Datetime column {datetime_col} is not in the DataFrame.")
183
+ if not pd.api.types.is_datetime64_any_dtype(analysis[datetime_col]):
184
+ raise TypeError(f"Column {datetime_col} must be of datetime type.")
185
+
186
+ if analysis.empty:
187
+ raise ValueError("Input DataFrame is empty.")
188
+
189
+ metrics = self._calculate_metric(
190
+ analysis, target_col, prediction_col, datetime_col
191
+ )
192
+ metrics["is_drifted"] = self._is_drifted(metrics)
193
+ return metrics
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Lucas Leão
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.1
2
+ Name: tinyshift
3
+ Version: 0.0.2
4
+ Summary: A small toolbox for MLOps
5
+ Author-email: Lucas Leão <heylucasleao@gmail.com>
6
+ License: MIT License
7
+ Keywords: mlops,toolbox,machine-learning
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: pandas
14
+ Requires-Dist: scipy
15
+ Requires-Dist: plotly
16
+ Requires-Dist: scikit-learn
17
+ Requires-Dist: numpy
18
+
19
+ # TinyShift
20
+
21
+ **TinyShift** is a small experimental Python library designed to detect **data drifts** and **performance drops** in machine learning models over time. The main goal of the project is to provide quick and tiny monitoring tools to help identify when data or model performance unexpectedly change.
22
+ For more robust solutions, I highly recommend [Nannyml.](https://github.com/NannyML/nannyml)
23
+
24
+ ## Technologies Used
25
+
26
+ - **Python 3.x**
27
+ - **Scikit-learn**
28
+ - **Pandas**
29
+ - **NumPy**
30
+ - **Plotly**
31
+ - **Scipy**
32
+
33
+ ## Installation
34
+
35
+ To install **TinyShift** in your development environment, use **pip**:
36
+
37
+
38
+ ```bash
39
+ pip install tinyshift
40
+ ```
41
+ If you prefer to clone the repository and install manually:
42
+ ```bash
43
+ git clone https://github.com/HeyLucasLeao/tinyshift.git
44
+ cd tinyshift
45
+ pip install .
46
+ ```
47
+
48
+ ## Usage
49
+ Below are basic examples of how to use TinyShift's features.
50
+ ### 1. Data Drift Detection
51
+ To detect data drift, simply score in a new dataset to compare with the reference data. The DataDriftDetector will calculate metrics to identify significant differences.
52
+
53
+ ```python
54
+ from tinyshift.detector import CategoricalDriftDetector
55
+
56
+ df = pd.DataFrame("examples.csv")
57
+ df_reference = df[(df["datetime"] < '2024-07-01')].copy()
58
+ df_analysis = df[(df["datetime"] >= '2024-07-01')].copy()
59
+
60
+ detector = CategoricalDriftDetector(df_reference, 'discrete_1', "datetime", "W", drift_limit='mad')
61
+
62
+ analysis_score = detector.score(df_analysis, "discrete_1", "datetime")
63
+
64
+ print(analysis_score)
65
+ ```
66
+
67
+ ### 2. Performance Tracker
68
+ To track model performance over time, use the PerformanceMonitor, which will compare model accuracy on both old and new data.
69
+ ```python
70
+ from tinyshift.tracker import PerformanceTracker
71
+
72
+ df_reference = pd.read_csv('refence.csv')
73
+ df_analysis = pd.read_csv('analysis.csv')
74
+ model = load_model('model.pkl')
75
+ df_analysis['prediction'] = model.predict(df_analysis["feature_0"])
76
+
77
+ tracker = PerformanceTracker(df_reference, 'target', 'prediction', 'datetime', "W")
78
+
79
+ analysis_score = tracker.score(df_analysis, 'target', 'prediction', 'datetime')
80
+
81
+ print(analysis_score)
82
+ ```
83
+
84
+ ### 3. Visualization
85
+ TinyShift also provides graphs to visualize the magnitude of drift and performance changes over time.
86
+ ```python
87
+ tracker.plot.scatterplot_over_time(analysis_score, fig_type="png")
88
+
89
+ tracker.plot.diverging_bar_over_time(analysis_score, fig_type="png")
90
+ ```
91
+
92
+ ## Project Structure
93
+ The basic structure of the project is as follows:
94
+ ```
95
+ tinyshift
96
+ ├── LICENSE
97
+ ├── README.md
98
+ ├── example.ipynb
99
+ ├── pyproject.toml
100
+ └── tinyshift
101
+ ├── base
102
+ │   ├── __init__.py
103
+ │   └── model.py
104
+ ├── detector
105
+ │   ├── __init__.py
106
+ │   ├── categorical.py
107
+ │   └── continuous.py
108
+ ├── plot
109
+ │   ├── __init__.py
110
+ │   └── plot.py
111
+ └── tracker
112
+ ├── __init__.py
113
+ └── performance.py
114
+ ```
115
+
116
+ ### License
117
+ This project is licensed under the MIT License - see the LICENSE file for more details.
@@ -0,0 +1,14 @@
1
+ tinyshift/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tinyshift/base/model.py,sha256=riQfiFrQieK6X0zsj1djoWrM3S_bTOuKdO6VlzpCo7o,9270
3
+ tinyshift/detector/__init__.py,sha256=hjP0xqHERdl1DHD3Fs6GrcbCou1EqZmIHwtbH-VhNG0,98
4
+ tinyshift/detector/categorical.py,sha256=oFj0dIuA34_pxqOVCxadSA4CqEgT5xpSBiFpHyOWFow,7491
5
+ tinyshift/detector/continuous.py,sha256=zRu5wYgQlYj9KpPnFaBg0JzFCxiq0Z9e--LyuCG7rl8,7394
6
+ tinyshift/plot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ tinyshift/plot/plot.py,sha256=ON9E653BXm1kTxRd2Vxx-32u6D9ubqgnu698e6LAiyg,5985
8
+ tinyshift/tracker/__init__.py,sha256=ElIARMomp32pFeJ3zwt4ISPv26BUtDc0Bx0JOORQICs,44
9
+ tinyshift/tracker/performance.py,sha256=5-eDcF1BEX79Z6oFHvCFneOqA7pEUlIApgnWlJY-trA,7049
10
+ tinyshift-0.0.2.dist-info/LICENSE,sha256=oGMyi5YKRfPvxPthLZi3eiJ9zVC3oHJ94YoesVfp1Hg,1068
11
+ tinyshift-0.0.2.dist-info/METADATA,sha256=d0YKrhDG9OkhvH8Hj9AYUSOAgaIFCUmAw3En3WzLBms,3563
12
+ tinyshift-0.0.2.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
13
+ tinyshift-0.0.2.dist-info/top_level.txt,sha256=yzWi5DWDzkaUzuJMahKQm_igjQxnWX04bPz09c9sQ4k,10
14
+ tinyshift-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.7.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tinyshift