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.
@@ -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"]
@@ -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)