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.
- tinyshift/base/__init__.py +0 -0
- tinyshift/base/model.py +249 -0
- tinyshift/detector/__init__.py +2 -0
- tinyshift/detector/categorical.py +226 -0
- tinyshift/detector/continuous.py +222 -0
- tinyshift/plot/__init__.py +0 -0
- tinyshift/plot/plot.py +213 -0
- tinyshift/tracker/__init__.py +1 -0
- tinyshift/tracker/performance.py +193 -0
- tinyshift-0.0.2.dist-info/LICENSE +21 -0
- tinyshift-0.0.2.dist-info/METADATA +117 -0
- tinyshift-0.0.2.dist-info/RECORD +14 -0
- tinyshift-0.0.2.dist-info/WHEEL +5 -0
- tinyshift-0.0.2.dist-info/top_level.txt +1 -0
|
File without changes
|
tinyshift/base/model.py
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
tinyshift
|