SKSurrogate 0.1.0__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.
- SKSurrogate/DataProcess.py +409 -0
- SKSurrogate/NpyProximation.py +457 -0
- SKSurrogate/__init__.py +110 -0
- SKSurrogate/aml.py +1156 -0
- SKSurrogate/ci.py +93 -0
- SKSurrogate/deployment.py +31 -0
- SKSurrogate/eoa.py +381 -0
- SKSurrogate/execution.py +416 -0
- SKSurrogate/inference.py +224 -0
- SKSurrogate/mltrace.py +2138 -0
- SKSurrogate/modelbundle.py +600 -0
- SKSurrogate/monitoring.py +360 -0
- SKSurrogate/retraining.py +49 -0
- SKSurrogate/sensapprx.py +250 -0
- SKSurrogate/structsearch.py +1411 -0
- SKSurrogate/synthdat.py +503 -0
- sksurrogate-0.1.0.dist-info/METADATA +139 -0
- sksurrogate-0.1.0.dist-info/RECORD +21 -0
- sksurrogate-0.1.0.dist-info/WHEEL +5 -0
- sksurrogate-0.1.0.dist-info/entry_points.txt +2 -0
- sksurrogate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Data-quality and drift monitoring for tabular model inputs."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from sklearn.metrics import accuracy_score, mean_absolute_error, mean_squared_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _schema(frame):
|
|
12
|
+
return {
|
|
13
|
+
column: {
|
|
14
|
+
"dtype": str(frame[column].dtype),
|
|
15
|
+
"nullable": bool(frame[column].isna().any()),
|
|
16
|
+
}
|
|
17
|
+
for column in frame.columns
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _distribution(values, categories):
|
|
22
|
+
counts = Counter(str(value) for value in values.dropna())
|
|
23
|
+
total = float(sum(counts.values()))
|
|
24
|
+
return {
|
|
25
|
+
category: counts.get(category, 0) / total if total else 0.0
|
|
26
|
+
for category in categories
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _numeric_drift(reference, current, bins):
|
|
31
|
+
reference = reference.dropna().to_numpy(dtype=float)
|
|
32
|
+
current = current.dropna().to_numpy(dtype=float)
|
|
33
|
+
if not len(reference) or not len(current):
|
|
34
|
+
return {"method": "psi", "value": None, "bins": []}
|
|
35
|
+
quantiles = np.linspace(0.0, 1.0, bins + 1)
|
|
36
|
+
edges = np.unique(np.quantile(reference, quantiles))
|
|
37
|
+
if len(edges) < 2:
|
|
38
|
+
value = 0.0 if np.all(current == reference[0]) else 1.0
|
|
39
|
+
return {"method": "psi", "value": value, "bins": [float(edges[0])]}
|
|
40
|
+
reference_counts, _ = np.histogram(reference, bins=edges)
|
|
41
|
+
current_counts, _ = np.histogram(current, bins=edges)
|
|
42
|
+
reference_share = np.clip(reference_counts / len(reference), 1.0e-12, None)
|
|
43
|
+
current_share = np.clip(current_counts / len(current), 1.0e-12, None)
|
|
44
|
+
psi = float(np.sum((current_share - reference_share) * np.log(current_share / reference_share)))
|
|
45
|
+
return {"method": "psi", "value": psi, "bins": edges.tolist()}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def drift_report(
|
|
49
|
+
reference,
|
|
50
|
+
current,
|
|
51
|
+
*,
|
|
52
|
+
model_version=None,
|
|
53
|
+
reference_dataset_fingerprint=None,
|
|
54
|
+
current_dataset_fingerprint=None,
|
|
55
|
+
psi_threshold=0.2,
|
|
56
|
+
categorical_threshold=0.2,
|
|
57
|
+
missingness_threshold=0.1,
|
|
58
|
+
range_threshold=0.1,
|
|
59
|
+
bins=10,
|
|
60
|
+
):
|
|
61
|
+
"""Compare reference and current tabular data and return a JSON-safe report.
|
|
62
|
+
|
|
63
|
+
Schema and data-quality changes are reported independently from statistical drift.
|
|
64
|
+
Numeric drift uses population stability index (PSI); categorical drift uses total
|
|
65
|
+
variation distance. Threshold crossings are listed in ``alerts``.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(reference, pd.DataFrame) or not isinstance(current, pd.DataFrame):
|
|
68
|
+
raise TypeError("reference and current must be pandas DataFrames")
|
|
69
|
+
if bins < 2:
|
|
70
|
+
raise ValueError("bins must be at least 2")
|
|
71
|
+
reference_schema = _schema(reference)
|
|
72
|
+
current_schema = _schema(current)
|
|
73
|
+
reference_columns = list(reference.columns)
|
|
74
|
+
current_columns = list(current.columns)
|
|
75
|
+
missing = [column for column in reference_columns if column not in current_columns]
|
|
76
|
+
extra = [column for column in current_columns if column not in reference_columns]
|
|
77
|
+
reordered = not missing and not extra and reference_columns != current_columns
|
|
78
|
+
schema_changes = {
|
|
79
|
+
"missing_columns": missing,
|
|
80
|
+
"extra_columns": extra,
|
|
81
|
+
"reordered": reordered,
|
|
82
|
+
"dtype_changes": {
|
|
83
|
+
column: {"reference": reference_schema[column]["dtype"], "current": current_schema[column]["dtype"]}
|
|
84
|
+
for column in reference_columns
|
|
85
|
+
if column in current_schema
|
|
86
|
+
and reference_schema[column]["dtype"] != current_schema[column]["dtype"]
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
quality = {
|
|
90
|
+
"reference_rows": int(len(reference)),
|
|
91
|
+
"current_rows": int(len(current)),
|
|
92
|
+
"missingness": {},
|
|
93
|
+
}
|
|
94
|
+
drift = {"numeric": {}, "categorical": {}, "range": {}}
|
|
95
|
+
alerts = []
|
|
96
|
+
for column in reference_columns:
|
|
97
|
+
if column not in current:
|
|
98
|
+
continue
|
|
99
|
+
reference_missingness = float(reference[column].isna().mean())
|
|
100
|
+
current_missingness = float(current[column].isna().mean())
|
|
101
|
+
missingness_delta = current_missingness - reference_missingness
|
|
102
|
+
quality["missingness"][column] = {
|
|
103
|
+
"reference": reference_missingness,
|
|
104
|
+
"current": current_missingness,
|
|
105
|
+
"delta": missingness_delta,
|
|
106
|
+
}
|
|
107
|
+
if abs(missingness_delta) >= missingness_threshold:
|
|
108
|
+
alerts.append({"type": "missingness", "column": column, "value": missingness_delta})
|
|
109
|
+
if not pd.api.types.is_numeric_dtype(reference[column]):
|
|
110
|
+
categories = sorted(
|
|
111
|
+
set(str(value) for value in reference[column].dropna())
|
|
112
|
+
| set(str(value) for value in current[column].dropna())
|
|
113
|
+
)
|
|
114
|
+
reference_distribution = _distribution(reference[column], categories)
|
|
115
|
+
current_distribution = _distribution(current[column], categories)
|
|
116
|
+
distance = 0.5 * sum(
|
|
117
|
+
abs(reference_distribution[category] - current_distribution[category])
|
|
118
|
+
for category in categories
|
|
119
|
+
)
|
|
120
|
+
drift["categorical"][column] = {
|
|
121
|
+
"metric": "total_variation_distance",
|
|
122
|
+
"value": float(distance),
|
|
123
|
+
"reference_distribution": reference_distribution,
|
|
124
|
+
"current_distribution": current_distribution,
|
|
125
|
+
}
|
|
126
|
+
if distance >= categorical_threshold:
|
|
127
|
+
alerts.append({"type": "categorical_drift", "column": column, "value": float(distance)})
|
|
128
|
+
else:
|
|
129
|
+
numeric = _numeric_drift(reference[column], current[column], bins)
|
|
130
|
+
drift["numeric"][column] = numeric
|
|
131
|
+
if numeric["value"] is not None and numeric["value"] >= psi_threshold:
|
|
132
|
+
alerts.append({"type": "numeric_drift", "column": column, "value": numeric["value"]})
|
|
133
|
+
reference_min = float(reference[column].min()) if not reference[column].dropna().empty else None
|
|
134
|
+
reference_max = float(reference[column].max()) if not reference[column].dropna().empty else None
|
|
135
|
+
if reference_min is not None and reference_max is not None:
|
|
136
|
+
values = current[column].dropna()
|
|
137
|
+
out_of_range = (
|
|
138
|
+
float(((values < reference_min) | (values > reference_max)).mean())
|
|
139
|
+
if not values.empty
|
|
140
|
+
else 0.0
|
|
141
|
+
)
|
|
142
|
+
drift["range"][column] = {
|
|
143
|
+
"reference_min": reference_min,
|
|
144
|
+
"reference_max": reference_max,
|
|
145
|
+
"current_min": float(values.min()) if not values.empty else None,
|
|
146
|
+
"current_max": float(values.max()) if not values.empty else None,
|
|
147
|
+
"out_of_range_rate": out_of_range,
|
|
148
|
+
}
|
|
149
|
+
if out_of_range >= range_threshold:
|
|
150
|
+
alerts.append({"type": "range_drift", "column": column, "value": out_of_range})
|
|
151
|
+
return {
|
|
152
|
+
"model_version": model_version,
|
|
153
|
+
"reference_dataset_fingerprint": reference_dataset_fingerprint,
|
|
154
|
+
"current_dataset_fingerprint": current_dataset_fingerprint,
|
|
155
|
+
"schema_changes": schema_changes,
|
|
156
|
+
"data_quality": quality,
|
|
157
|
+
"drift": drift,
|
|
158
|
+
"thresholds": {
|
|
159
|
+
"psi": psi_threshold,
|
|
160
|
+
"categorical": categorical_threshold,
|
|
161
|
+
"missingness": missingness_threshold,
|
|
162
|
+
"range": range_threshold,
|
|
163
|
+
},
|
|
164
|
+
"alerts": alerts,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def prediction_distribution_report(
|
|
169
|
+
reference_predictions,
|
|
170
|
+
current_predictions,
|
|
171
|
+
*,
|
|
172
|
+
model_version=None,
|
|
173
|
+
threshold=0.2,
|
|
174
|
+
bins=10,
|
|
175
|
+
):
|
|
176
|
+
"""Report distribution drift in model predictions."""
|
|
177
|
+
reference = pd.DataFrame({"prediction": reference_predictions})
|
|
178
|
+
current = pd.DataFrame({"prediction": current_predictions})
|
|
179
|
+
report = drift_report(
|
|
180
|
+
reference,
|
|
181
|
+
current,
|
|
182
|
+
model_version=model_version,
|
|
183
|
+
psi_threshold=threshold,
|
|
184
|
+
categorical_threshold=threshold,
|
|
185
|
+
missingness_threshold=1.0,
|
|
186
|
+
range_threshold=1.0,
|
|
187
|
+
bins=bins,
|
|
188
|
+
)
|
|
189
|
+
report["prediction_column"] = "prediction"
|
|
190
|
+
report["alerts"] = [
|
|
191
|
+
alert for alert in report["alerts"]
|
|
192
|
+
if alert["type"] in {"numeric_drift", "categorical_drift"}
|
|
193
|
+
]
|
|
194
|
+
return report
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def delayed_label_performance(predictions, labels, *, model_version=None, task_type="classification"):
|
|
198
|
+
"""Compute performance after delayed labels arrive for recorded predictions."""
|
|
199
|
+
predictions = np.asarray(predictions)
|
|
200
|
+
labels = np.asarray(labels)
|
|
201
|
+
if len(predictions) != len(labels):
|
|
202
|
+
raise ValueError("predictions and labels must contain the same number of rows")
|
|
203
|
+
if not len(labels):
|
|
204
|
+
raise ValueError("predictions and labels cannot be empty")
|
|
205
|
+
if task_type == "classification":
|
|
206
|
+
metrics = {"accuracy": float(accuracy_score(labels, predictions))}
|
|
207
|
+
elif task_type == "regression":
|
|
208
|
+
metrics = {
|
|
209
|
+
"mae": float(mean_absolute_error(labels, predictions)),
|
|
210
|
+
"mse": float(mean_squared_error(labels, predictions)),
|
|
211
|
+
}
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError("task_type must be 'classification' or 'regression'")
|
|
214
|
+
return {"model_version": model_version, "rows": int(len(labels)), "metrics": metrics}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def fairness_report(y_true, y_pred, groups, *, positive_label=1):
|
|
218
|
+
"""Summarize demographic parity and equal-opportunity gaps across groups."""
|
|
219
|
+
y_true = np.asarray(y_true)
|
|
220
|
+
y_pred = np.asarray(y_pred)
|
|
221
|
+
groups = np.asarray(groups)
|
|
222
|
+
if len(y_true) != len(y_pred) or len(y_true) != len(groups):
|
|
223
|
+
raise ValueError("y_true, y_pred, and groups must have the same length")
|
|
224
|
+
if not len(y_true):
|
|
225
|
+
raise ValueError("inputs cannot be empty")
|
|
226
|
+
by_group = {}
|
|
227
|
+
selection_rates = []
|
|
228
|
+
opportunity_rates = []
|
|
229
|
+
for group in sorted(np.unique(groups)):
|
|
230
|
+
mask = groups == group
|
|
231
|
+
predicted_positive = y_pred[mask] == positive_label
|
|
232
|
+
actual_positive = y_true[mask] == positive_label
|
|
233
|
+
selection_rate = float(np.mean(predicted_positive)) if np.any(mask) else 0.0
|
|
234
|
+
true_positive_rate = float(np.mean(predicted_positive[actual_positive])) if np.any(actual_positive) else 0.0
|
|
235
|
+
by_group[str(group)] = {
|
|
236
|
+
"count": int(np.sum(mask)),
|
|
237
|
+
"selection_rate": selection_rate,
|
|
238
|
+
"true_positive_rate": true_positive_rate,
|
|
239
|
+
"accuracy": float(np.mean(y_true[mask] == y_pred[mask])) if np.any(mask) else 0.0,
|
|
240
|
+
}
|
|
241
|
+
selection_rates.append(selection_rate)
|
|
242
|
+
opportunity_rates.append(true_positive_rate)
|
|
243
|
+
fairness = {
|
|
244
|
+
"demographic_parity_gap": float(max(selection_rates) - min(selection_rates)) if selection_rates else 0.0,
|
|
245
|
+
"equal_opportunity_gap": float(max(opportunity_rates) - min(opportunity_rates)) if opportunity_rates else 0.0,
|
|
246
|
+
}
|
|
247
|
+
return {"groups": by_group, "fairness": fairness}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def subgroup_performance_report(y_true, y_pred, groups, *, metric="accuracy", positive_label=1):
|
|
251
|
+
"""Report group-level metrics and the maximum performance gap between subgroups."""
|
|
252
|
+
y_true = np.asarray(y_true)
|
|
253
|
+
y_pred = np.asarray(y_pred)
|
|
254
|
+
groups = np.asarray(groups)
|
|
255
|
+
if len(y_true) != len(y_pred) or len(y_true) != len(groups):
|
|
256
|
+
raise ValueError("y_true, y_pred, and groups must have the same length")
|
|
257
|
+
if metric not in {"accuracy", "precision", "recall", "f1", "loss"}:
|
|
258
|
+
raise ValueError("metric must be one of accuracy, precision, recall, f1, or loss")
|
|
259
|
+
results = {}
|
|
260
|
+
values = []
|
|
261
|
+
for group in sorted(np.unique(groups)):
|
|
262
|
+
mask = groups == group
|
|
263
|
+
group_true = y_true[mask]
|
|
264
|
+
group_pred = y_pred[mask]
|
|
265
|
+
if metric == "accuracy":
|
|
266
|
+
value = float(np.mean(group_true == group_pred)) if len(group_true) else 0.0
|
|
267
|
+
elif metric == "precision":
|
|
268
|
+
positive_pred = group_pred == positive_label
|
|
269
|
+
actual_positive = group_true == positive_label
|
|
270
|
+
value = float(np.mean(positive_pred[actual_positive])) if np.any(actual_positive) else 0.0
|
|
271
|
+
elif metric == "recall":
|
|
272
|
+
actual_positive = group_true == positive_label
|
|
273
|
+
value = float(np.mean(group_pred[actual_positive] == positive_label)) if np.any(actual_positive) else 0.0
|
|
274
|
+
elif metric == "f1":
|
|
275
|
+
actual_positive = group_true == positive_label
|
|
276
|
+
actual_negative = group_true != positive_label
|
|
277
|
+
tp = np.sum((group_pred == positive_label) & actual_positive)
|
|
278
|
+
fp = np.sum((group_pred == positive_label) & actual_negative)
|
|
279
|
+
precision = tp / (tp + fp) if (tp + fp) else 0.0
|
|
280
|
+
recall = tp / np.sum(actual_positive) if np.any(actual_positive) else 0.0
|
|
281
|
+
value = 0.0 if (precision + recall) == 0.0 else (2.0 * precision * recall) / (precision + recall)
|
|
282
|
+
else:
|
|
283
|
+
value = 1.0 - float(np.mean(group_true == group_pred)) if len(group_true) else 0.0
|
|
284
|
+
results[str(group)] = {metric: value, "count": int(len(group_true))}
|
|
285
|
+
values.append(value)
|
|
286
|
+
return {"groups": results, "metric": metric, "fairness_gap": float(max(values) - min(values)) if values else 0.0}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def loss_report(y_true, y_pred, groups=None, *, positive_label=1):
|
|
290
|
+
"""Convenience wrapper for group-level loss reporting."""
|
|
291
|
+
if groups is None:
|
|
292
|
+
groups = np.zeros_like(np.asarray(y_true), dtype=object)
|
|
293
|
+
return subgroup_performance_report(y_true, y_pred, groups, metric="loss", positive_label=positive_label)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def sensitive_feature_report(frame, *, sensitive_features=None):
|
|
297
|
+
"""Flag sensitive and PII-like columns before model training or serving."""
|
|
298
|
+
if not isinstance(frame, pd.DataFrame):
|
|
299
|
+
raise TypeError("frame must be a pandas DataFrame")
|
|
300
|
+
explicit_sensitive = set(sensitive_features or [])
|
|
301
|
+
pii_tokens = (
|
|
302
|
+
"email",
|
|
303
|
+
"phone",
|
|
304
|
+
"ssn",
|
|
305
|
+
"social_security",
|
|
306
|
+
"address",
|
|
307
|
+
"name",
|
|
308
|
+
"passport",
|
|
309
|
+
"tax_id",
|
|
310
|
+
"dob",
|
|
311
|
+
"date_of_birth",
|
|
312
|
+
"ip_address",
|
|
313
|
+
)
|
|
314
|
+
pii_columns = []
|
|
315
|
+
sensitive_columns = []
|
|
316
|
+
for column in frame.columns:
|
|
317
|
+
lowered = str(column).lower()
|
|
318
|
+
if any(token in lowered for token in pii_tokens):
|
|
319
|
+
pii_columns.append(column)
|
|
320
|
+
if column in explicit_sensitive or any(token in lowered for token in ("ssn", "secret", "password", "token", "api_key")):
|
|
321
|
+
sensitive_columns.append(column)
|
|
322
|
+
pii_columns = sorted(set(pii_columns))
|
|
323
|
+
sensitive_columns = sorted(set(sensitive_columns))
|
|
324
|
+
warnings = []
|
|
325
|
+
if pii_columns:
|
|
326
|
+
warnings.append("PII-like columns detected: %s" % ", ".join(pii_columns))
|
|
327
|
+
if sensitive_columns:
|
|
328
|
+
warnings.append("Sensitive columns detected: %s" % ", ".join(sensitive_columns))
|
|
329
|
+
return {
|
|
330
|
+
"pii_columns": pii_columns,
|
|
331
|
+
"sensitive_columns": sensitive_columns,
|
|
332
|
+
"warnings": warnings,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class InferenceMonitor:
|
|
337
|
+
"""Accumulate latency, error-rate, and throughput metrics for requests."""
|
|
338
|
+
|
|
339
|
+
def __init__(self, model_version=None):
|
|
340
|
+
self.model_version = model_version
|
|
341
|
+
self._requests = []
|
|
342
|
+
|
|
343
|
+
def record(self, latency_ms, rows, *, success=True):
|
|
344
|
+
if latency_ms < 0 or rows < 0:
|
|
345
|
+
raise ValueError("latency_ms and rows must be non-negative")
|
|
346
|
+
self._requests.append({"latency_ms": float(latency_ms), "rows": int(rows), "success": bool(success)})
|
|
347
|
+
|
|
348
|
+
def summary(self):
|
|
349
|
+
total = len(self._requests)
|
|
350
|
+
rows = sum(item["rows"] for item in self._requests)
|
|
351
|
+
latency = sum(item["latency_ms"] for item in self._requests)
|
|
352
|
+
successes = sum(item["success"] for item in self._requests)
|
|
353
|
+
return {
|
|
354
|
+
"model_version": self.model_version,
|
|
355
|
+
"requests": total,
|
|
356
|
+
"rows": rows,
|
|
357
|
+
"error_rate": (total - successes) / total if total else 0.0,
|
|
358
|
+
"mean_latency_ms": latency / total if total else 0.0,
|
|
359
|
+
"throughput_rows_per_second": rows / (latency / 1000.0) if latency else 0.0,
|
|
360
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Scheduler-agnostic retraining entry points."""
|
|
2
|
+
|
|
3
|
+
from .modelbundle import ModelBundle
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RetrainingJob:
|
|
7
|
+
"""Run a trainer when a scheduled or data-driven trigger is satisfied."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, trainer, registry, task_name, promotion_state=None):
|
|
10
|
+
if not callable(trainer):
|
|
11
|
+
raise TypeError("trainer must be callable")
|
|
12
|
+
self.trainer = trainer
|
|
13
|
+
self.registry = registry
|
|
14
|
+
self.task_name = task_name
|
|
15
|
+
self.promotion_state = promotion_state
|
|
16
|
+
|
|
17
|
+
def run(self, *, trigger=None, context=None):
|
|
18
|
+
context = {} if context is None else dict(context)
|
|
19
|
+
if trigger is not None and not trigger(context):
|
|
20
|
+
return {"status": "skipped", "reason": "trigger_not_satisfied", "context": context}
|
|
21
|
+
|
|
22
|
+
bundle = self.trainer(context)
|
|
23
|
+
if not isinstance(bundle, ModelBundle):
|
|
24
|
+
raise TypeError("trainer must return a ModelBundle")
|
|
25
|
+
if bundle.task_name is None:
|
|
26
|
+
bundle.task_name = self.task_name
|
|
27
|
+
elif bundle.task_name != self.task_name:
|
|
28
|
+
raise ValueError("trainer returned a bundle for a different task")
|
|
29
|
+
model_version = self.registry.register(bundle)
|
|
30
|
+
if self.promotion_state is not None:
|
|
31
|
+
self.registry.promote(self.task_name, model_version, self.promotion_state)
|
|
32
|
+
return {
|
|
33
|
+
"status": "completed",
|
|
34
|
+
"model_version": model_version,
|
|
35
|
+
"task_name": self.task_name,
|
|
36
|
+
"promotion_state": self.promotion_state,
|
|
37
|
+
"context": context,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def run_scheduled(self, is_due, *, context=None):
|
|
41
|
+
"""Run when a scheduler-specific due predicate returns true."""
|
|
42
|
+
return self.run(trigger=is_due, context=context)
|
|
43
|
+
|
|
44
|
+
def run_on_data(self, has_new_data, *, context=None):
|
|
45
|
+
"""Run when a data watcher-specific predicate returns true."""
|
|
46
|
+
return self.run(trigger=has_new_data, context=context)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["RetrainingJob"]
|
SKSurrogate/sensapprx.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sensitivity Analysis
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
Sensitivity analysis of a dataset based on a fit, sklearn style.
|
|
6
|
+
The core functionality is provided by `SALib <https://github.com/SALib/SALib>`_ .
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
11
|
+
except ModuleNotFoundError:
|
|
12
|
+
BaseEstimator = type("BaseEstimator", (object,), dict())
|
|
13
|
+
TransformerMixin = type("TransformerMixin", (object,), dict())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SensAprx(BaseEstimator, TransformerMixin):
|
|
17
|
+
"""
|
|
18
|
+
Transform data to select the most secretive factors according to a regressor that fits the data.
|
|
19
|
+
|
|
20
|
+
:param n_features_to_select: `int` number of top features to be selected
|
|
21
|
+
:param regressor: a sklearn style regressor to fit the data for sensitivity analysis
|
|
22
|
+
:param method: `str` the sensitivity analysis method; defalt 'sobol', other options are 'morris' and 'delta-mmnt'
|
|
23
|
+
:param margin: domain margine, default: .2
|
|
24
|
+
:param num_smpl: number of samples to perform the analysis, default: 512
|
|
25
|
+
:param num_levels: number of levels for morris analysis, default: 6
|
|
26
|
+
:param grid_jump: grid jump for morris analysis, default: 1
|
|
27
|
+
:param num_resmpl: number of resamples for moment independent analysis, default: 10
|
|
28
|
+
:param reduce: whether to reduce the data points to uniques and calculate the averages of the target or not,
|
|
29
|
+
default: False
|
|
30
|
+
:param domain: pre-calculated unique points, if none, and reduce is `True` then unique points will be found
|
|
31
|
+
:param probs: pre-calculated values associated to `domain` points
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
n_features_to_select=10,
|
|
37
|
+
regressor=None,
|
|
38
|
+
method="sobol",
|
|
39
|
+
margin=0.2,
|
|
40
|
+
num_smpl=512,
|
|
41
|
+
num_levels=6,
|
|
42
|
+
grid_jump=1,
|
|
43
|
+
num_resmpl=8,
|
|
44
|
+
reduce=False,
|
|
45
|
+
domain=None,
|
|
46
|
+
probs=None,
|
|
47
|
+
):
|
|
48
|
+
self.n_features_to_select = n_features_to_select
|
|
49
|
+
self.regressor = regressor
|
|
50
|
+
self.method = method
|
|
51
|
+
self.margin = margin
|
|
52
|
+
self.num_smpl = num_smpl
|
|
53
|
+
self.num_levels = num_levels
|
|
54
|
+
self.grid_jump = grid_jump
|
|
55
|
+
self.num_resmpl = num_resmpl
|
|
56
|
+
self.reduce = reduce
|
|
57
|
+
self.domain = domain
|
|
58
|
+
self.probs = probs
|
|
59
|
+
self.weights_ = None
|
|
60
|
+
self.top_features_ = []
|
|
61
|
+
|
|
62
|
+
def _avg_fucn(self, X, y):
|
|
63
|
+
from numpy import bincount, unique
|
|
64
|
+
|
|
65
|
+
if self.reduce:
|
|
66
|
+
self.domain, inverse = unique(X, axis=0, return_inverse=True)
|
|
67
|
+
sums = bincount(inverse, weights=y)
|
|
68
|
+
counts = bincount(inverse)
|
|
69
|
+
self.probs = sums / counts
|
|
70
|
+
else:
|
|
71
|
+
self.domain = X
|
|
72
|
+
self.probs = y
|
|
73
|
+
|
|
74
|
+
def fit(self, X, y):
|
|
75
|
+
"""
|
|
76
|
+
Fits the regressor to the data `(X, y)` and performs a sensitivity analysis on the result of the regression.
|
|
77
|
+
|
|
78
|
+
:param X: Training data
|
|
79
|
+
:param y: Target values
|
|
80
|
+
:return: `self`
|
|
81
|
+
"""
|
|
82
|
+
from numpy import argpartition
|
|
83
|
+
|
|
84
|
+
self.n_features_to_select = min(self.n_features_to_select, X.shape[1])
|
|
85
|
+
|
|
86
|
+
N = len(X[0])
|
|
87
|
+
if (self.domain is None) or (self.probs is None):
|
|
88
|
+
self._avg_fucn(X, y)
|
|
89
|
+
if self.regressor is None:
|
|
90
|
+
from sklearn.svm import SVR
|
|
91
|
+
self.regressor = SVR()
|
|
92
|
+
self.regressor.fit(self.domain, self.probs)
|
|
93
|
+
bounds = [
|
|
94
|
+
[
|
|
95
|
+
min(self.domain[:, idx]) - self.margin,
|
|
96
|
+
max(self.domain[:, idx]) + self.margin,
|
|
97
|
+
]
|
|
98
|
+
for idx in range(N)
|
|
99
|
+
]
|
|
100
|
+
problem = dict(
|
|
101
|
+
num_vars=N, names=["x%d" % idx for idx in range(N)], bounds=bounds
|
|
102
|
+
)
|
|
103
|
+
res = []
|
|
104
|
+
if self.method == "sobol":
|
|
105
|
+
from SALib.sample import saltelli
|
|
106
|
+
from SALib.analyze import sobol
|
|
107
|
+
|
|
108
|
+
param_values = saltelli.sample(problem, self.num_smpl)
|
|
109
|
+
y_ = self.regressor.predict(param_values)
|
|
110
|
+
res = sobol.analyze(problem, y_)["ST"]
|
|
111
|
+
self.weights_ = res
|
|
112
|
+
elif self.method == "morris":
|
|
113
|
+
from SALib.sample import morris as mrs
|
|
114
|
+
from SALib.analyze import morris
|
|
115
|
+
|
|
116
|
+
param_values = mrs.sample(
|
|
117
|
+
problem, self.num_smpl, num_levels=self.num_levels
|
|
118
|
+
)
|
|
119
|
+
y_ = self.regressor.predict(param_values)
|
|
120
|
+
res = morris.analyze(problem, param_values, y_, num_levels=self.num_levels)[
|
|
121
|
+
"mu_star"
|
|
122
|
+
]
|
|
123
|
+
self.weights_ = res
|
|
124
|
+
elif self.method == "delta-mmnt":
|
|
125
|
+
from SALib.sample import latin
|
|
126
|
+
from SALib.analyze import delta
|
|
127
|
+
|
|
128
|
+
param_values = latin.sample(problem, self.num_smpl)
|
|
129
|
+
y_ = self.regressor.predict(param_values)
|
|
130
|
+
res = delta.analyze(
|
|
131
|
+
problem, param_values, y_, num_resamples=self.num_resmpl
|
|
132
|
+
)["delta"]
|
|
133
|
+
self.weights_ = res
|
|
134
|
+
self.top_features_ = argpartition(res, -self.n_features_to_select)[
|
|
135
|
+
-self.n_features_to_select:
|
|
136
|
+
]
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def transform(self, X):
|
|
140
|
+
return X[:, self.top_features_[: self.n_features_to_select]]
|
|
141
|
+
|
|
142
|
+
def fit_transform(self, X, y=None, **fit_params):
|
|
143
|
+
"""
|
|
144
|
+
Fit to data, then transform it.
|
|
145
|
+
|
|
146
|
+
Fits transformer to X and y with optional parameters fit_params and returns a transformed version of X.
|
|
147
|
+
|
|
148
|
+
:param X: numpy array of shape [n_samples, n_features]; Training set.
|
|
149
|
+
:param y: numpy array of shape [n_samples]; Target values.
|
|
150
|
+
:return: Transformed array
|
|
151
|
+
"""
|
|
152
|
+
self.fit(X, y)
|
|
153
|
+
return self.transform(X)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CorrelationThreshold(BaseEstimator, TransformerMixin):
|
|
157
|
+
"""
|
|
158
|
+
Selects a minimal set of features based on a given (Pearson) correlation threshold.
|
|
159
|
+
The transformer omits the maximum number features with a high correlation and makes sure that the remaining
|
|
160
|
+
features are not correlated behind the given threshold.
|
|
161
|
+
|
|
162
|
+
:param threshold: the threshold for selecting correlated pairs.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, threshold=0.7):
|
|
166
|
+
self.threshold = threshold
|
|
167
|
+
self.indices = []
|
|
168
|
+
|
|
169
|
+
def _high_corr(self, crln):
|
|
170
|
+
pairs = []
|
|
171
|
+
nodes = set()
|
|
172
|
+
n = crln.shape[0]
|
|
173
|
+
for i in range(1, n):
|
|
174
|
+
for j in range(i):
|
|
175
|
+
if abs(crln[i, j]) >= self.threshold:
|
|
176
|
+
pairs.append((i, j))
|
|
177
|
+
nodes.add(i)
|
|
178
|
+
nodes.add(j)
|
|
179
|
+
return pairs, nodes
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _node_degrees(pairs):
|
|
183
|
+
nodes = set()
|
|
184
|
+
degs = {}
|
|
185
|
+
for p in pairs:
|
|
186
|
+
nodes.add(p[0])
|
|
187
|
+
nodes.add(p[1])
|
|
188
|
+
if p[0] not in degs:
|
|
189
|
+
degs[p[0]] = 1
|
|
190
|
+
else:
|
|
191
|
+
degs[p[0]] += 1
|
|
192
|
+
if p[1] not in degs:
|
|
193
|
+
degs[p[1]] = 1
|
|
194
|
+
else:
|
|
195
|
+
degs[p[1]] += 1
|
|
196
|
+
node_deg = [(k, degs[k]) for k in degs]
|
|
197
|
+
node_deg.sort(key=lambda x: -x[1])
|
|
198
|
+
return node_deg
|
|
199
|
+
|
|
200
|
+
def _minimal_selection(self, crln):
|
|
201
|
+
from copy import copy
|
|
202
|
+
|
|
203
|
+
pairs = copy(crln)
|
|
204
|
+
selection = []
|
|
205
|
+
while pairs != []:
|
|
206
|
+
temp_pairs = []
|
|
207
|
+
degs = self._node_degrees(pairs)
|
|
208
|
+
candid = degs[0][0]
|
|
209
|
+
selection.append(candid)
|
|
210
|
+
for p in pairs:
|
|
211
|
+
if candid not in p:
|
|
212
|
+
temp_pairs.append(p)
|
|
213
|
+
pairs = temp_pairs
|
|
214
|
+
return set(selection)
|
|
215
|
+
|
|
216
|
+
def fit(self, X, y=None):
|
|
217
|
+
"""
|
|
218
|
+
Finds the Pearson correlation among all features, selects the pairs with absolute value of correlation above
|
|
219
|
+
the given threshold and selects a minimal set of features with low correlation
|
|
220
|
+
|
|
221
|
+
:param X: Training data
|
|
222
|
+
:param y: Target values (default: `None`)
|
|
223
|
+
:return: `self`
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
from numpy import corrcoef
|
|
227
|
+
|
|
228
|
+
n = X.shape[1]
|
|
229
|
+
crln = corrcoef(X.transpose())
|
|
230
|
+
pairs, nodes = self._high_corr(crln)
|
|
231
|
+
sel_features = self._minimal_selection(pairs)
|
|
232
|
+
self.indices = list(set(range(n)).difference(nodes).union(sel_features))
|
|
233
|
+
self.indices.sort()
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def transform(self, X):
|
|
237
|
+
return X[:, self.indices]
|
|
238
|
+
|
|
239
|
+
def fit_transform(self, X, y=None, **fit_params):
|
|
240
|
+
"""
|
|
241
|
+
Fit to data, then transform it.
|
|
242
|
+
|
|
243
|
+
Fits transformer to X and y with optional parameters fit_params and returns a transformed version of X.
|
|
244
|
+
|
|
245
|
+
:param X: numpy array of shape [n_samples, n_features]; Training set.
|
|
246
|
+
:param y: numpy array of shape [n_samples]; Target values.
|
|
247
|
+
:return: Transformed array
|
|
248
|
+
"""
|
|
249
|
+
self.fit(X, y)
|
|
250
|
+
return self.transform(X)
|