robustmodels 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,1191 @@
1
+ """Robust covariance estimators and their fitting steps.
2
+
3
+ The estimators follow the scikit-learn API and extend
4
+ :class:`sklearn.covariance.EmpiricalCovariance`. ``fit(X)`` takes observations
5
+ in rows and sets ``location_``, ``covariance_``, ``precision_`` and ``dist_``
6
+ (squared robust distances of the training data). The inherited
7
+ ``mahalanobis(X)`` returns squared robust distances of new observations, and
8
+ ``score``, ``error_norm`` and ``get_precision`` work as in scikit-learn.
9
+ """
10
+
11
+ import math
12
+ import warnings
13
+
14
+ import numpy as np
15
+ from scipy import linalg, stats
16
+ from scipy.stats import chi2
17
+ from sklearn.covariance import EmpiricalCovariance
18
+ from sklearn.utils.validation import validate_data
19
+
20
+ from ._validation import check_count, check_option, check_range
21
+ from .scale import (
22
+ _bisquare_weights,
23
+ _mscale_bisquare,
24
+ _rho,
25
+ _tau_estimates,
26
+ _tuning_for_breakdown,
27
+ _tuning_for_shape_efficiency,
28
+ mad,
29
+ pn,
30
+ qn,
31
+ tau_scale,
32
+ )
33
+
34
+ __all__ = [
35
+ "MCD",
36
+ "MRCD",
37
+ "MVE",
38
+ "OGK",
39
+ "MMEstimator",
40
+ "SEstimator",
41
+ "h_alpha_n",
42
+ "mcd_consistency",
43
+ "mcd_small_sample",
44
+ ]
45
+
46
+
47
+ class _RobustCovariance(EmpiricalCovariance):
48
+ """Base class; ``mahalanobis``, ``score`` and ``error_norm`` come from scikit-learn."""
49
+
50
+ store_precision = True # read by the inherited methods
51
+ assume_centered = False
52
+
53
+ def _validate(self, X):
54
+ return validate_data(self, X, dtype=np.float64, ensure_min_samples=2)
55
+
56
+ def _set_estimates(self, location, covariance, X):
57
+ self.location_ = np.asarray(location, dtype=float)
58
+ self.covariance_ = np.asarray(covariance, dtype=float)
59
+ self.precision_ = linalg.pinvh(self.covariance_)
60
+ diff = X - self.location_
61
+ self.dist_ = np.sum((diff @ self.precision_) * diff, axis=1)
62
+
63
+
64
+ class MCD(_RobustCovariance):
65
+ """Minimum Covariance Determinant estimator (Rousseeuw 1984).
66
+
67
+ Mean and covariance of the ``h`` observations whose covariance matrix has the
68
+ smallest determinant, scaled for consistency at the normal model. The
69
+ reweighting step then recomputes both from the observations whose squared
70
+ robust distance is below the 0.975 chi-square quantile. Breakdown point
71
+ ``(n - h + 1) / n``, about 50% for ``alpha = 0.5``. Needs ``n > p``; use
72
+ :class:`MRCD` otherwise. If ``h`` observations lie on a hyperplane, a
73
+ ``RuntimeWarning`` reports the exact fit and the raw estimates are kept.
74
+
75
+ Parameters
76
+ ----------
77
+ alpha : float in [0.5, 1], default 0.5
78
+ Coverage. The subset size ``h = h_alpha_n(alpha, n, p)`` goes from
79
+ ``(n + p + 1) // 2`` (maximal breakdown) at 0.5 to ``n`` at 1.
80
+ algorithm : {"fast", "deterministic"}, default "fast"
81
+ FAST-MCD with random starts (Rousseeuw & Van Driessen 1999), or DetMCD
82
+ with six deterministic starts (Hubert, Rousseeuw & Verdonck 2012).
83
+ With one variable both use the exact univariate MCD.
84
+ n_trials : int, default 500
85
+ Random starting subsets of FAST-MCD.
86
+ reweighting : bool, default True
87
+ Apply the reweighting step.
88
+ small_sample_correction : bool, default True
89
+ Multiply the covariance by the factors of Pison, Van Aelst & Willems (2002).
90
+ random_state : int, numpy.random.Generator, numpy.random.RandomState or None
91
+ Seed of FAST-MCD.
92
+
93
+ Attributes
94
+ ----------
95
+ location_ : ndarray of shape (n_features,)
96
+ Final location.
97
+ covariance_ : ndarray of shape (n_features, n_features)
98
+ Final covariance matrix.
99
+ precision_ : ndarray of shape (n_features, n_features)
100
+ Pseudo-inverse of ``covariance_``.
101
+ support_ : ndarray of bool, shape (n_samples,)
102
+ Observations used for the final estimates.
103
+ dist_ : ndarray of shape (n_samples,)
104
+ Squared robust distances of the training data.
105
+ raw_location_ : ndarray of shape (n_features,)
106
+ Mean of the ``h``-subset.
107
+ raw_covariance_ : ndarray of shape (n_features, n_features)
108
+ Corrected covariance of the ``h``-subset.
109
+ raw_support_ : ndarray of bool, shape (n_samples,)
110
+ The ``h``-subset.
111
+ h_ : int
112
+ Subset size.
113
+
114
+ References
115
+ ----------
116
+ Rousseeuw, P. J. (1984). Least median of squares regression. *Journal of the
117
+ American Statistical Association*, 79(388), 871-880.
118
+
119
+ Rousseeuw, P. J. and Van Driessen, K. (1999). A fast algorithm for the
120
+ minimum covariance determinant estimator. *Technometrics*, 41(3), 212-223.
121
+
122
+ Hubert, M., Rousseeuw, P. J. and Verdonck, T. (2012). A deterministic
123
+ algorithm for robust location and scatter. *Journal of Computational and
124
+ Graphical Statistics*, 21(3), 618-637.
125
+
126
+ Croux, C. and Haesbroeck, G. (1999). Influence function and efficiency of the
127
+ minimum covariance determinant scatter matrix estimator. *Journal of
128
+ Multivariate Analysis*, 71(2), 161-190.
129
+
130
+ Pison, G., Van Aelst, S. and Willems, G. (2002). Small sample corrections for
131
+ LTS and MCD. *Metrika*, 55, 111-123.
132
+
133
+ Examples
134
+ --------
135
+ >>> import numpy as np
136
+ >>> from robustmodels.covariance import MCD
137
+ >>> X = np.random.default_rng(0).normal(size=(100, 2))
138
+ >>> X[:10] += 10 # ten outliers
139
+ >>> mcd = MCD(random_state=0).fit(X)
140
+ >>> mcd.location_.round(2).tolist()
141
+ [0.01, 0.1]
142
+ >>> int(mcd.support_[:10].sum()) # no outlier is used
143
+ 0
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ *,
149
+ alpha=0.5,
150
+ algorithm="fast",
151
+ n_trials=500,
152
+ reweighting=True,
153
+ small_sample_correction=True,
154
+ random_state=None,
155
+ ):
156
+ self.alpha = alpha
157
+ self.algorithm = algorithm
158
+ self.n_trials = n_trials
159
+ self.reweighting = reweighting
160
+ self.small_sample_correction = small_sample_correction
161
+ self.random_state = random_state
162
+
163
+ def fit(self, X, y=None):
164
+ """Fit to ``X`` of shape (n_samples, n_features); ``y`` is ignored. Returns ``self``."""
165
+ X = self._validate(X)
166
+ n, p = X.shape
167
+ check_option("algorithm", self.algorithm, ("fast", "deterministic"))
168
+ check_count("n_trials", self.n_trials)
169
+ h = h_alpha_n(self.alpha, n, p)
170
+ if n <= p:
171
+ raise ValueError(f"MCD needs n > p (got n={n}, p={p}); use MRCD")
172
+ subset = self._best_subset(X, h)
173
+ self._set_raw_estimates(X, subset, h)
174
+ location, covariance = self._reweighted_estimates(X)
175
+ self._set_estimates(location, covariance, X)
176
+ return self
177
+
178
+ def _set_raw_estimates(self, X, subset, h):
179
+ n, p = X.shape
180
+ location, covariance = _mean_cov(X[subset])
181
+ if h < n:
182
+ covariance *= mcd_consistency(p, h / n) * self._small_sample(p, n, False)
183
+ self.h_ = h
184
+ self.raw_location_ = location
185
+ self.raw_covariance_ = covariance
186
+ self.raw_support_ = np.isin(np.arange(n), subset)
187
+
188
+ def _reweighted_estimates(self, X):
189
+ location, covariance = self.raw_location_, self.raw_covariance_
190
+ self.support_ = self.raw_support_.copy()
191
+ if _logdet(covariance) == -np.inf:
192
+ warnings.warn(
193
+ "exact fit: h observations lie on a hyperplane", RuntimeWarning, stacklevel=3
194
+ )
195
+ return location, covariance
196
+ if self.reweighting:
197
+ location, covariance, self.support_ = _reweight(X, location, covariance)
198
+ if not self.support_.all():
199
+ n, p = X.shape
200
+ covariance = covariance * self._small_sample(p, n, True)
201
+ return location, covariance
202
+
203
+ def _best_subset(self, X, h):
204
+ n, p = X.shape
205
+ if h == n:
206
+ return np.arange(n)
207
+ center, scale = _center_scale(X)
208
+ standardized = (X - center) / scale
209
+ if p == 1:
210
+ return _univariate_mcd(standardized[:, 0], h)
211
+ if self.algorithm == "fast":
212
+ rng = _as_generator(self.random_state)
213
+ return _fast_mcd(standardized, h, self.n_trials, rng)
214
+ return _best_mcd(standardized, _deterministic_starts(standardized, h), h)
215
+
216
+ def _small_sample(self, p, n, reweighted):
217
+ if not self.small_sample_correction:
218
+ return 1.0
219
+ return mcd_small_sample(p, n, self.alpha, reweighted)
220
+
221
+
222
+ def _mcd_steps(X, subset, h, max_steps=200):
223
+ """Concentrate a subset while its covariance determinant decreases."""
224
+ location, covariance = _mean_cov(X[subset])
225
+ determinant = _logdet(covariance)
226
+ for _ in range(max_steps):
227
+ if determinant == -np.inf:
228
+ break
229
+ distances = _mahalanobis_sq(X, location, covariance)
230
+ updated = np.sort(np.argpartition(distances, h - 1)[:h])
231
+ new_location, new_covariance = _mean_cov(X[updated])
232
+ new_determinant = _logdet(new_covariance)
233
+ if len(subset) == h and new_determinant >= determinant:
234
+ break
235
+ subset = updated
236
+ location, covariance = new_location, new_covariance
237
+ determinant = new_determinant
238
+ return determinant, subset
239
+
240
+
241
+ def _best_mcd(X, starts, h, max_steps=200):
242
+ """Return the best subset after concentrating each starting subset."""
243
+ candidates = [_mcd_steps(X, subset, h, max_steps) for subset in starts]
244
+ return min(candidates, key=lambda candidate: candidate[0])[1]
245
+
246
+
247
+ def _fast_mcd(X, h, n_trials, rng):
248
+ """Take two C-steps per random start, then converge the ten best subsets."""
249
+ starts = [_random_subset(X, rng) for _ in range(n_trials)]
250
+ if starts[0] is None:
251
+ return np.arange(h)
252
+ candidates = [_mcd_steps(X, subset, h, max_steps=2) for subset in starts]
253
+ candidates.sort(key=lambda candidate: candidate[0])
254
+ finalists = [subset for _, subset in candidates[:10]]
255
+ return _best_mcd(X, finalists, h)
256
+
257
+
258
+ def _deterministic_starts(Z, h):
259
+ """The six h-subsets of DetMCD, from robustly standardized data ``Z``."""
260
+ n, p = Z.shape
261
+ ranks = stats.rankdata(Z, axis=0)
262
+ norms = np.linalg.norm(Z, axis=1)
263
+ signs = Z / np.where(norms > 0, norms, 1)[:, None]
264
+ scatters = [
265
+ _corr(np.tanh(Z)),
266
+ _corr(ranks), # Spearman
267
+ _corr(stats.norm.ppf((ranks - 1 / 3) / (n + 1 / 3))), # normal scores
268
+ signs.T @ signs, # spatial sign covariance
269
+ _mean_cov(Z[np.argsort(norms)[: max(2, math.ceil(n / 2))]])[1], # first BACON step
270
+ _gk_covariance(Z, qn), # OGK-type
271
+ ]
272
+ return [_start_from_scatter(Z, S, h) for S in scatters]
273
+
274
+
275
+ def _corr(Y):
276
+ # A constant column contributes zero correlation to a starting scatter.
277
+ with np.errstate(divide="ignore", invalid="ignore"):
278
+ correlation = np.atleast_2d(np.corrcoef(Y, rowvar=False))
279
+ return np.nan_to_num(correlation)
280
+
281
+
282
+ def _start_from_scatter(Z, S, h):
283
+ """h points closest to a robust center, in the eigenbasis of ``S`` with Qn scales."""
284
+ eigenvectors = np.linalg.eigh(S)[1]
285
+ projected = Z @ eigenvectors
286
+ scales = np.array([qn(column) for column in projected.T])
287
+ scales = np.where(scales > 0, scales, 1e-300)
288
+ whitening = (eigenvectors / scales) @ eigenvectors.T
289
+ inverse_whitening = (eigenvectors * scales) @ eigenvectors.T
290
+ center = np.median(Z @ whitening, axis=0) @ inverse_whitening
291
+ residuals = ((Z - center) @ eigenvectors) / scales
292
+ distances = np.sum(residuals**2, axis=1)
293
+ return np.sort(np.argsort(distances, kind="stable")[:h])
294
+
295
+
296
+ def _univariate_mcd(z, h):
297
+ """Exact MCD for p = 1: the window of h sorted values with the least variance."""
298
+ order = np.argsort(z, kind="stable")
299
+ sorted_values = z[order]
300
+ sums = np.concatenate([[0], np.cumsum(sorted_values)])
301
+ square_sums = np.concatenate([[0], np.cumsum(sorted_values**2)])
302
+ window_sums = sums[h:] - sums[:-h]
303
+ window_square_sums = square_sums[h:] - square_sums[:-h]
304
+ centered_sums = window_square_sums - window_sums**2 / h
305
+ start = int(np.argmin(centered_sums))
306
+ return np.sort(order[start : start + h])
307
+
308
+
309
+ def h_alpha_n(alpha, n, p):
310
+ """Subset size of the MCD for coverage ``alpha``, as R's ``robustbase::h.alpha.n``.
311
+
312
+ ``(n + p + 1) // 2`` at ``alpha = 0.5``, ``n`` at ``alpha = 1`` and linear in
313
+ between.
314
+
315
+ Parameters
316
+ ----------
317
+ alpha : float in [0.5, 1]
318
+ Coverage.
319
+ n, p : int
320
+ Number of observations and of variables.
321
+
322
+ Returns
323
+ -------
324
+ int
325
+
326
+ Examples
327
+ --------
328
+ >>> from robustmodels.covariance import h_alpha_n
329
+ >>> h_alpha_n(0.5, 100, 3), h_alpha_n(0.75, 100, 3)
330
+ (52, 76)
331
+ """
332
+ check_range("alpha", alpha, 0.5, 1)
333
+ check_count("n", n)
334
+ check_count("p", p)
335
+ minimum = (n + p + 1) // 2
336
+ return int(2 * minimum - n + 2 * (n - minimum) * alpha)
337
+
338
+
339
+ def mcd_consistency(p, alpha):
340
+ """Consistency factor of the MCD at the normal model (Croux & Haesbroeck 1999).
341
+
342
+ ``alpha / F_{p+2}(chi2_p^-1(alpha))``, with ``F_k`` the chi-square distribution
343
+ function with ``k`` degrees of freedom. It makes the covariance of the
344
+ fraction ``alpha`` of observations closest to the center consistent.
345
+
346
+ Parameters
347
+ ----------
348
+ p : int
349
+ Number of variables.
350
+ alpha : float in (0, 1]
351
+ Fraction of observations kept; 1 gives 1.
352
+
353
+ Returns
354
+ -------
355
+ float
356
+
357
+ Examples
358
+ --------
359
+ >>> from robustmodels.covariance import mcd_consistency
360
+ >>> round(mcd_consistency(3, 0.5), 4)
361
+ 2.4574
362
+ """
363
+ check_count("p", p)
364
+ check_range("alpha", alpha, 0, 1, low_open=True)
365
+ if alpha == 1:
366
+ return 1.0
367
+ cutoff = chi2.ppf(alpha, p)
368
+ return float(alpha / chi2.cdf(cutoff, p + 2))
369
+
370
+
371
+ # Pison, Van Aelst & Willems (2002): curves f(n) = 1 - exp(a) / n**b.
372
+ # (reweighted, alpha) -> [(a, b) for p = 1, (a, b) for p = 2, two (a, b) points for p > 2]
373
+ _PISON = {
374
+ (False, 0.5): [
375
+ (0.262024211897096, 0.604756680630497),
376
+ (0.673292623522027, 0.691365864961895),
377
+ (-1.42764571687802, 1.26263336932151),
378
+ (-1.06141115981725, 1.28907991440387),
379
+ ],
380
+ (False, 0.875): [
381
+ (-0.351584646688712, 1.01646567502486),
382
+ (0.446537815635445, 1.06690782995919),
383
+ (-0.455179464070565, 1.11192541278794),
384
+ (-0.294241208320834, 1.09649329149811),
385
+ ],
386
+ (True, 0.5): [
387
+ (1.11098143415027, 1.5182890270453),
388
+ (3.11101712909049, 1.91401056721863),
389
+ (-1.02842572724793, 1.67659883081926),
390
+ (-0.26800273450853, 1.35968562893582),
391
+ ],
392
+ (True, 0.875): [
393
+ (-0.66046776772861, 0.88939595831888),
394
+ (0.79473550581058, 1.10081930350091),
395
+ (-0.544482443573914, 1.25994483222292),
396
+ (-0.343791072183285, 1.25159004257133),
397
+ ],
398
+ }
399
+
400
+
401
+ def _bias_curve(p, n, alpha, reweighted):
402
+ fit = _PISON[reweighted, alpha]
403
+ if p <= 2:
404
+ a, b = fit[p - 1]
405
+ else: # line through log(-a_j / p^b_j) at log(2 p^2) and log(3 p^2)
406
+ (a1, b1), (a2, b2) = fit[2], fit[3]
407
+ y1, y2 = np.log(-a1 / p**b1), np.log(-a2 / p**b2)
408
+ b = (y1 - y2) / np.log(1.5)
409
+ a = y1 + b * np.log(2 * p * p)
410
+ return 1 - np.exp(a) / n**b
411
+
412
+
413
+ def mcd_small_sample(p, n, alpha, reweighted=False):
414
+ """Small-sample factor of the MCD (Pison, Van Aelst & Willems 2002).
415
+
416
+ The paper fits the factors at ``alpha = 0.5`` and ``0.875``; other coverages
417
+ are interpolated linearly, reaching 1 at ``alpha = 1``.
418
+
419
+ Parameters
420
+ ----------
421
+ p, n : int
422
+ Number of variables and of observations.
423
+ alpha : float in [0.5, 1]
424
+ Coverage.
425
+ reweighted : bool, default False
426
+ Factor for the reweighted instead of the raw MCD.
427
+
428
+ Returns
429
+ -------
430
+ float
431
+
432
+ Examples
433
+ --------
434
+ >>> from robustmodels.covariance import mcd_small_sample
435
+ >>> round(mcd_small_sample(3, 100, 0.5), 4)
436
+ 1.0989
437
+ """
438
+ check_count("p", p)
439
+ check_count("n", n)
440
+ check_range("alpha", alpha, 0.5, 1)
441
+ f50 = _bias_curve(p, n, 0.5, reweighted)
442
+ f875 = _bias_curve(p, n, 0.875, reweighted)
443
+ if alpha <= 0.875:
444
+ f = f50 + (f875 - f50) * (alpha - 0.5) / 0.375
445
+ else:
446
+ f = f875 + (1 - f875) * (alpha - 0.875) / 0.125
447
+ return float(1 / f) if f > 0 else 1.0
448
+
449
+
450
+ class MRCD(_RobustCovariance):
451
+ """Minimum Regularized Covariance Determinant estimator (Boudt et al. 2020).
452
+
453
+ The data are standardized with the column medians and Qn scales. The
454
+ ``h``-subset ``H`` then minimizes the determinant of
455
+ ``K(H) = rho * I + (1 - rho) * c_alpha * cov(H)``, where ``c_alpha`` is the
456
+ MCD consistency factor and ``rho`` the smallest weight that keeps the
457
+ condition number of ``K`` below ``max_condition``. In the original units
458
+ the regularization target is the diagonal matrix of squared Qn scales.
459
+ Works with more variables than observations; when the subset covariance is
460
+ already well-conditioned, ``rho = 0`` and the result is the raw MCD. There is
461
+ no reweighting step.
462
+
463
+ With more variables than observations the starting subsets come from
464
+ eigenvectors that are not unique, so results can differ slightly between
465
+ platforms and linear algebra libraries.
466
+
467
+ Parameters
468
+ ----------
469
+ alpha : float in [0.5, 1], default 0.5
470
+ Coverage; the subset size is ``h = ceil(alpha * n)``.
471
+ max_condition : float, default 50
472
+ Largest condition number allowed for the regularized covariance.
473
+ rho : float in [0, 1), optional
474
+ Fixed regularization weight, which replaces ``max_condition``. Needed when a
475
+ starting subset has zero scatter.
476
+
477
+ Attributes
478
+ ----------
479
+ location_ : ndarray of shape (n_features,)
480
+ Mean of the ``h``-subset.
481
+ covariance_ : ndarray of shape (n_features, n_features)
482
+ Regularized covariance matrix.
483
+ precision_ : ndarray of shape (n_features, n_features)
484
+ Inverse of ``covariance_``.
485
+ support_ : ndarray of bool, shape (n_samples,)
486
+ The ``h``-subset.
487
+ dist_ : ndarray of shape (n_samples,)
488
+ Squared robust distances of the training data.
489
+ rho_ : float
490
+ Regularization weight used.
491
+ h_ : int
492
+ Subset size.
493
+
494
+ References
495
+ ----------
496
+ Boudt, K., Rousseeuw, P. J., Vanduffel, S. and Verdonck, T. (2020). The
497
+ minimum regularized covariance determinant estimator. *Statistics and
498
+ Computing*, 30(1), 113-128.
499
+
500
+ Examples
501
+ --------
502
+ >>> import numpy as np
503
+ >>> from robustmodels.covariance import MRCD
504
+ >>> X = np.random.default_rng(0).normal(size=(20, 50)) # p > n
505
+ >>> mrcd = MRCD().fit(X)
506
+ >>> mrcd.covariance_.shape
507
+ (50, 50)
508
+ >>> 0 < mrcd.rho_ < 1
509
+ True
510
+ """
511
+
512
+ def __init__(self, *, alpha=0.5, max_condition=50.0, rho=None):
513
+ self.alpha = alpha
514
+ self.max_condition = max_condition
515
+ self.rho = rho
516
+
517
+ def fit(self, X, y=None):
518
+ """Fit to ``X`` of shape (n_samples, n_features); ``y`` is ignored. Returns ``self``."""
519
+ X = self._validate(X)
520
+ check_range("alpha", self.alpha, 0.5, 1)
521
+ check_range("max_condition", self.max_condition, 1, np.inf, low_open=True)
522
+ if self.rho is not None:
523
+ check_range("rho", self.rho, 0, 1, high_open=True)
524
+ n, p = X.shape
525
+ h = math.ceil(self.alpha * n)
526
+ if h < 2:
527
+ raise ValueError("MRCD needs at least two observations in its subset")
528
+ center, scale = _center_scale(X)
529
+ standardized = (X - center) / scale
530
+ correction = mcd_consistency(p, h / n)
531
+ starts, rho = self._starting_subsets(standardized, h, correction)
532
+ try:
533
+ location, covariance, subset = _best_regularized_subset(
534
+ standardized, starts, h, rho, correction
535
+ )
536
+ except np.linalg.LinAlgError:
537
+ raise ValueError("MRCD covariance is singular; pass a larger rho") from None
538
+ self.h_, self.rho_ = h, float(rho)
539
+ self.support_ = np.isin(np.arange(n), subset)
540
+ location = center + scale * location
541
+ covariance = np.outer(scale, scale) * covariance
542
+ self._set_estimates(location, covariance, X)
543
+ return self
544
+
545
+ def _starting_subsets(self, X, h, correction):
546
+ if h == len(X):
547
+ starts = [np.arange(h)]
548
+ else:
549
+ starts = _deterministic_starts(X, h)
550
+ if self.rho is not None:
551
+ return starts, self.rho
552
+ weights = []
553
+ for subset in starts:
554
+ covariance = correction * _mean_cov(X[subset])[1]
555
+ weights.append(_min_rho(covariance, self.max_condition))
556
+ weights = np.array(weights)
557
+ cutoff = max(0.1, np.median(weights))
558
+ selected = [
559
+ subset for subset, weight in zip(starts, weights, strict=True) if weight <= cutoff
560
+ ]
561
+ return selected, weights[weights <= cutoff].max()
562
+
563
+
564
+ def _min_rho(covariance, max_condition):
565
+ """Smallest rho for cond(rho I + (1 - rho) covariance) <= max_condition."""
566
+ eigenvalues = np.linalg.eigvalsh(covariance)
567
+ smallest = max(eigenvalues[0], 0.0)
568
+ largest = eigenvalues[-1]
569
+ if largest <= 0:
570
+ raise ValueError("MRCD subset has zero scatter; pass a positive rho")
571
+ if largest <= max_condition * smallest:
572
+ return 0.0
573
+ numerator = largest - max_condition * smallest
574
+ denominator = max_condition * (1 - smallest) - (1 - largest)
575
+ rho = numerator / denominator
576
+ if rho >= 1:
577
+ raise ValueError("MRCD regularization is too close to 1; rescale the data")
578
+ return rho
579
+
580
+
581
+ def _regularized_estimates(X, subset, rho, correction):
582
+ location, covariance = _mean_cov(X[subset])
583
+ target = np.eye(X.shape[1])
584
+ covariance = rho * target + (1 - rho) * correction * covariance
585
+ return location, covariance
586
+
587
+
588
+ def _best_regularized_subset(X, starts, h, rho, correction):
589
+ candidates = [_mrcd_steps(X, subset, h, rho, correction) for subset in starts]
590
+ _, subset = min(candidates, key=lambda candidate: candidate[0])
591
+ location, covariance = _regularized_estimates(X, subset, rho, correction)
592
+ return location, covariance, subset
593
+
594
+
595
+ def _mrcd_steps(X, subset, h, rho, correction, max_steps=200):
596
+ """Concentration steps with the same regularization weight at every step."""
597
+ for _ in range(max_steps):
598
+ location, covariance = _regularized_estimates(X, subset, rho, correction)
599
+ distances = _mahalanobis_sq(X, location, covariance)
600
+ updated = np.sort(np.argpartition(distances, h - 1)[:h])
601
+ if np.array_equal(updated, subset):
602
+ break
603
+ subset = updated
604
+ _, covariance = _regularized_estimates(X, subset, rho, correction)
605
+ return np.linalg.slogdet(covariance)[1], subset
606
+
607
+
608
+ class MVE(_RobustCovariance):
609
+ """Minimum Volume Ellipsoid estimator (Rousseeuw 1985).
610
+
611
+ Center and scaled shape of the smallest ellipsoid that covers ``h``
612
+ observations. The search draws random ``(p + 1)``-subsets, then refits the
613
+ 10 best ellipsoids to the ``h`` points they cover while the volume
614
+ decreases. The shape is scaled for consistency at the normal model and
615
+ reweighted as in :class:`MCD`. Breakdown point about 50% for
616
+ ``alpha = 0.5``. Needs ``n > p + 1``.
617
+
618
+ Parameters
619
+ ----------
620
+ alpha : float in [0.5, 1), default 0.5
621
+ Coverage, as for :class:`MCD`. Full coverage has no finite scale correction.
622
+ n_trials : int, default 500
623
+ Random starting subsets.
624
+ reweighting : bool, default True
625
+ Recompute mean and covariance from the observations whose squared robust
626
+ distance is below the 0.975 chi-square quantile.
627
+ random_state : int, numpy.random.Generator, numpy.random.RandomState or None
628
+ Seed of the random subsets.
629
+
630
+ Attributes
631
+ ----------
632
+ location_ : ndarray of shape (n_features,)
633
+ Final location.
634
+ covariance_ : ndarray of shape (n_features, n_features)
635
+ Final covariance matrix.
636
+ precision_ : ndarray of shape (n_features, n_features)
637
+ Pseudo-inverse of ``covariance_``.
638
+ support_ : ndarray of bool, shape (n_samples,)
639
+ Observations used for the final estimates.
640
+ dist_ : ndarray of shape (n_samples,)
641
+ Squared robust distances of the training data.
642
+ raw_location_ : ndarray of shape (n_features,)
643
+ Center of the best ellipsoid.
644
+ raw_covariance_ : ndarray of shape (n_features, n_features)
645
+ Scaled shape of the best ellipsoid.
646
+ raw_support_ : ndarray of bool, shape (n_samples,)
647
+ Observations inside the best ellipsoid.
648
+ h_ : int
649
+ Number of observations covered.
650
+
651
+ References
652
+ ----------
653
+ Rousseeuw, P. J. (1985). Multivariate estimation with high breakdown point.
654
+ In W. Grossmann et al. (eds.), *Mathematical Statistics and Applications*,
655
+ Vol. B, 283-297. Reidel.
656
+
657
+ Examples
658
+ --------
659
+ >>> import numpy as np
660
+ >>> from robustmodels.covariance import MVE
661
+ >>> X = np.random.default_rng(0).normal(size=(100, 2))
662
+ >>> X[:10] += 10
663
+ >>> mve = MVE(random_state=0).fit(X)
664
+ >>> mve.location_.round(2).tolist()
665
+ [0.01, 0.1]
666
+ """
667
+
668
+ def __init__(self, *, alpha=0.5, n_trials=500, reweighting=True, random_state=None):
669
+ self.alpha = alpha
670
+ self.n_trials = n_trials
671
+ self.reweighting = reweighting
672
+ self.random_state = random_state
673
+
674
+ def fit(self, X, y=None):
675
+ """Fit to ``X`` of shape (n_samples, n_features); ``y`` is ignored. Returns ``self``."""
676
+ X = self._validate(X)
677
+ check_range("alpha", self.alpha, 0.5, 1, high_open=True)
678
+ n, p = X.shape
679
+ h = h_alpha_n(self.alpha, n, p)
680
+ if n <= p + 1 or h > n:
681
+ raise ValueError(f"MVE needs n > p + 1 (got n={n}, p={p})")
682
+ check_count("n_trials", self.n_trials)
683
+ _, location, shape, distances = self._best_ellipsoid(X, h)
684
+ radius_squared = np.partition(distances, h - 1)[h - 1]
685
+ self.h_ = h
686
+ self.raw_location_ = location
687
+ self.raw_covariance_ = shape * radius_squared / chi2.ppf(h / n, p)
688
+ self.raw_support_ = distances <= radius_squared
689
+ covariance = self.raw_covariance_
690
+ self.support_ = self.raw_support_
691
+ if self.reweighting:
692
+ location, covariance, self.support_ = _reweight(X, location, covariance)
693
+ self._set_estimates(location, covariance, X)
694
+ return self
695
+
696
+ def _best_ellipsoid(self, X, h):
697
+ rng = _as_generator(self.random_state)
698
+ candidates = []
699
+ for _ in range(self.n_trials):
700
+ subset = _random_subset(X, rng)
701
+ if subset is None:
702
+ raise ValueError("the data lie on a hyperplane")
703
+ location, covariance = _mean_cov(X[subset])
704
+ candidates.append(_ellipsoid(X, location, covariance, h))
705
+ candidates.sort(key=lambda candidate: candidate[0])
706
+ refined = [_refine_ellipsoid(X, candidate, h) for candidate in candidates[:10]]
707
+ return min(refined, key=lambda candidate: candidate[0])
708
+
709
+
710
+ def _ellipsoid(X, location, covariance, h):
711
+ """Return log-volume, location, shape and distances for an h-point ellipsoid."""
712
+ distances = _mahalanobis_sq(X, location, covariance)
713
+ radius_squared = np.partition(distances, h - 1)[h - 1]
714
+ volume = _logdet(covariance) + len(covariance) * np.log(radius_squared)
715
+ return volume, location, covariance, distances
716
+
717
+
718
+ def _refine_ellipsoid(X, candidate, h):
719
+ """Refit the covered h points while the ellipsoid volume decreases."""
720
+ for _ in range(100):
721
+ volume, _, _, distances = candidate
722
+ subset = np.argpartition(distances, h - 1)[:h]
723
+ location, covariance = _mean_cov(X[subset])
724
+ if _logdet(covariance) == -np.inf:
725
+ break
726
+ updated = _ellipsoid(X, location, covariance, h)
727
+ if updated[0] >= volume:
728
+ break
729
+ candidate = updated
730
+ return candidate
731
+
732
+
733
+ _SCALES = {"tau": tau_scale, "qn": qn, "mad": mad, "pn": pn}
734
+
735
+
736
+ class OGK(_RobustCovariance):
737
+ """Orthogonalized Gnanadesikan-Kettenring estimator (Maronna & Zamar 2002).
738
+
739
+ Robust pairwise covariances ``(s(X + Y)^2 - s(X - Y)^2) / 4``, with ``s`` a
740
+ robust univariate scale, made positive definite by orthogonalization.
741
+ Deterministic and fast for many variables. It is equivariant under shifts
742
+ and rescaling of the variables, but not under rotations.
743
+
744
+ Parameters
745
+ ----------
746
+ scale : {"tau", "qn", "mad", "pn"}, default "tau"
747
+ Univariate scale. "tau" is centered at the tau location, the others at
748
+ the median.
749
+ n_iter : int, default 2
750
+ Orthogonalization iterations.
751
+ reweighting : bool, default True
752
+ Recompute mean and covariance from the observations with
753
+ ``d^2 <= chi2_p^-1(beta) * median(d^2) / chi2_p^-1(0.5)``. The reweighted
754
+ covariance is corrected for consistency at the normal model, which R's
755
+ ``covOGK`` omits.
756
+ beta : float in (0, 1), default 0.9
757
+ Quantile of the reweighting cutoff.
758
+
759
+ Attributes
760
+ ----------
761
+ location_ : ndarray of shape (n_features,)
762
+ Final location.
763
+ covariance_ : ndarray of shape (n_features, n_features)
764
+ Final covariance matrix.
765
+ precision_ : ndarray of shape (n_features, n_features)
766
+ Pseudo-inverse of ``covariance_``.
767
+ support_ : ndarray of bool, shape (n_samples,)
768
+ Observations used for the final estimates (all if ``reweighting=False``).
769
+ dist_ : ndarray of shape (n_samples,)
770
+ Squared robust distances of the training data.
771
+ raw_location_ : ndarray of shape (n_features,)
772
+ Location before reweighting.
773
+ raw_covariance_ : ndarray of shape (n_features, n_features)
774
+ Covariance before reweighting.
775
+
776
+ References
777
+ ----------
778
+ Maronna, R. A. and Zamar, R. H. (2002). Robust estimates of location and
779
+ dispersion for high-dimensional datasets. *Technometrics*, 44(4), 307-317.
780
+
781
+ Gnanadesikan, R. and Kettenring, J. R. (1972). Robust estimates, residuals,
782
+ and outlier detection with multiresponse data. *Biometrics*, 28(1), 81-124.
783
+
784
+ Examples
785
+ --------
786
+ >>> import numpy as np
787
+ >>> from robustmodels.covariance import OGK
788
+ >>> X = np.random.default_rng(0).normal(size=(100, 2))
789
+ >>> X[:10] += 10
790
+ >>> OGK().fit(X).covariance_.round(1).tolist()
791
+ [[1.1, 0.2], [0.2, 1.1]]
792
+ """
793
+
794
+ def __init__(self, *, scale="tau", n_iter=2, reweighting=True, beta=0.9):
795
+ self.scale = scale
796
+ self.n_iter = n_iter
797
+ self.reweighting = reweighting
798
+ self.beta = beta
799
+
800
+ def fit(self, X, y=None):
801
+ """Fit to ``X`` of shape (n_samples, n_features); ``y`` is ignored. Returns ``self``."""
802
+ X = self._validate(X)
803
+ check_option("scale", self.scale, _SCALES)
804
+ check_count("n_iter", self.n_iter)
805
+ check_range("beta", self.beta, 0, 1, low_open=True, high_open=True)
806
+ scale_fn = _SCALES[self.scale]
807
+ transformed, basis = _orthogonalize(X, scale_fn, self.n_iter)
808
+ if self.scale == "tau":
809
+ center = np.array([_tau_estimates(column, 4.5)[0] for column in transformed.T])
810
+ else:
811
+ center = np.median(transformed, axis=0)
812
+ scales = _scales(transformed, scale_fn)
813
+ distances = np.sum(((transformed - center) / scales) ** 2, axis=1)
814
+ self.raw_location_ = basis @ center
815
+ self.raw_covariance_ = (basis * scales**2) @ basis.T
816
+ location, covariance = self._reweighted_estimates(X, distances)
817
+ self._set_estimates(location, covariance, X)
818
+ return self
819
+
820
+ def _reweighted_estimates(self, X, distances):
821
+ self.support_ = np.ones(len(X), dtype=bool)
822
+ if not self.reweighting:
823
+ return self.raw_location_, self.raw_covariance_
824
+ p = X.shape[1]
825
+ cutoff = np.median(distances) * chi2.ppf(self.beta, p) / chi2.ppf(0.5, p)
826
+ self.support_ = distances <= cutoff
827
+ if not self.support_.any():
828
+ raise ValueError("beta is too small: reweighting selects no observations")
829
+ selected = X[self.support_]
830
+ location = selected.mean(axis=0)
831
+ centered = selected - location
832
+ covariance = centered.T @ centered / len(selected)
833
+ covariance *= mcd_consistency(p, self.beta)
834
+ return location, covariance
835
+
836
+
837
+ def _orthogonalize(X, scale_fn, n_iter):
838
+ """Rotate standardized columns and record how to return to the original basis."""
839
+ transformed = X
840
+ basis = np.eye(X.shape[1])
841
+ for _ in range(n_iter):
842
+ scales = _scales(transformed, scale_fn)
843
+ standardized = transformed / scales
844
+ eigenvectors = np.linalg.eigh(_gk_covariance(standardized, scale_fn))[1]
845
+ transformed = standardized @ eigenvectors
846
+ basis = basis @ (scales[:, None] * eigenvectors)
847
+ return transformed, basis
848
+
849
+
850
+ def _scales(X, scale_fn):
851
+ scales = np.array([scale_fn(column) for column in X.T])
852
+ if np.any(scales <= 0):
853
+ raise ValueError("a variable has zero robust scale (more than half of its values tied)")
854
+ return scales
855
+
856
+
857
+ class SEstimator(_RobustCovariance):
858
+ """S-estimator of location and scatter (Davies 1987), with Tukey's bisquare.
859
+
860
+ Finds the location ``mu`` and the shape ``Gamma`` (determinant 1) that
861
+ minimize the M-scale ``s`` of the distances
862
+ ``d_i = sqrt((x_i - mu)' Gamma^-1 (x_i - mu))``, where ``s`` solves
863
+ ``mean(rho(d_i / s)) = breakdown``. The covariance is ``s^2 * Gamma``,
864
+ consistent at the normal model. Computed with the FAST-S algorithm
865
+ (Salibián-Barrera, Van Aelst & Willems 2006). Affine equivariant; needs
866
+ ``n > p``.
867
+
868
+ Parameters
869
+ ----------
870
+ breakdown : float in (0, 0.5], default 0.5
871
+ Breakdown point.
872
+ n_trials : int, default 500
873
+ Random ``(p + 1)``-subsets, each refined with ``k_steps`` I-steps.
874
+ n_best : int, default 5
875
+ Best candidates iterated to convergence.
876
+ k_steps : int, default 2
877
+ I-steps applied to each random subset.
878
+ max_iter : int, default 200
879
+ Maximum I-steps for each of the best candidates.
880
+ random_state : int, numpy.random.Generator, numpy.random.RandomState or None
881
+ Seed of the random subsets.
882
+
883
+ Attributes
884
+ ----------
885
+ location_ : ndarray of shape (n_features,)
886
+ Location ``mu``.
887
+ covariance_ : ndarray of shape (n_features, n_features)
888
+ Covariance matrix ``s^2 * Gamma``.
889
+ precision_ : ndarray of shape (n_features, n_features)
890
+ Pseudo-inverse of ``covariance_``.
891
+ dist_ : ndarray of shape (n_samples,)
892
+ Squared robust distances of the training data.
893
+ scale_ : float
894
+ The minimized M-scale ``s``.
895
+
896
+ References
897
+ ----------
898
+ Davies, P. L. (1987). Asymptotic behaviour of S-estimates of multivariate
899
+ location parameters and dispersion matrices. *Annals of Statistics*, 15(3),
900
+ 1269-1292.
901
+
902
+ Salibián-Barrera, M., Van Aelst, S. and Willems, G. (2006). Principal
903
+ components analysis based on multivariate MM estimators with fast and robust
904
+ bootstrap. *Journal of the American Statistical Association*, 101(475),
905
+ 1198-1211.
906
+
907
+ Examples
908
+ --------
909
+ >>> import numpy as np
910
+ >>> from robustmodels.covariance import SEstimator
911
+ >>> X = np.random.default_rng(0).normal(size=(100, 2))
912
+ >>> X[:10] += 10
913
+ >>> s = SEstimator(random_state=0).fit(X)
914
+ >>> s.covariance_.round(1).tolist()
915
+ [[1.1, 0.1], [0.1, 1.3]]
916
+ """
917
+
918
+ def __init__(
919
+ self, *, breakdown=0.5, n_trials=500, n_best=5, k_steps=2, max_iter=200, random_state=None
920
+ ):
921
+ self.breakdown = breakdown
922
+ self.n_trials = n_trials
923
+ self.n_best = n_best
924
+ self.k_steps = k_steps
925
+ self.max_iter = max_iter
926
+ self.random_state = random_state
927
+
928
+ def fit(self, X, y=None):
929
+ """Fit to ``X`` of shape (n_samples, n_features); ``y`` is ignored. Returns ``self``."""
930
+ X = self._validate(X)
931
+ location, shape, self.scale_ = self._fit_s(X)
932
+ self._set_estimates(location, self.scale_**2 * shape, X)
933
+ return self
934
+
935
+ def _fit_s(self, X):
936
+ """Returns (location, shape with det 1, scale)."""
937
+ n, p = X.shape
938
+ for name in ("n_trials", "n_best", "k_steps", "max_iter"):
939
+ check_count(name, getattr(self, name))
940
+ if n <= p:
941
+ raise ValueError(f"S-estimation needs n > p (got n={n}, p={p})")
942
+ c = _tuning_for_breakdown(self.breakdown, p)
943
+ rng = _as_generator(self.random_state)
944
+ candidates = self._initial_candidates(X, c, rng)
945
+ candidates.sort(key=lambda candidate: candidate[2])
946
+ refined = []
947
+ for location, shape, _ in candidates[: self.n_best]:
948
+ refined.append(_s_steps(X, location, shape, c, self.breakdown, self.max_iter))
949
+ return min(refined, key=lambda candidate: candidate[2])
950
+
951
+ def _initial_candidates(self, X, c, rng):
952
+ candidates = []
953
+ for _ in range(self.n_trials):
954
+ subset = _random_subset(X, rng)
955
+ if subset is None:
956
+ raise ValueError("the data lie on a hyperplane")
957
+ location, covariance = _mean_cov(X[subset])
958
+ shape = _unit_det(covariance)
959
+ candidates.append(_s_steps(X, location, shape, c, self.breakdown, self.k_steps))
960
+ return candidates
961
+
962
+
963
+ def _unit_det(covariance):
964
+ """Rescale a positive definite covariance matrix to determinant 1."""
965
+ geometric_mean = np.exp(_logdet(covariance) / len(covariance))
966
+ return covariance / geometric_mean
967
+
968
+
969
+ def _s_steps(X, location, shape, c, breakdown, n_steps, tol=1e-10):
970
+ """Reweight location and shape while the M-scale decreases."""
971
+ distances = np.sqrt(_mahalanobis_sq(X, location, shape))
972
+ scale = _mscale_bisquare(distances, c, breakdown)
973
+ for _ in range(n_steps):
974
+ if scale == 0:
975
+ break
976
+ weights = _bisquare_weights(distances / scale, c)
977
+ new_location, new_shape = _weighted_shape(X, weights)
978
+ new_distances = np.sqrt(_mahalanobis_sq(X, new_location, new_shape))
979
+ new_scale = _mscale_bisquare(new_distances, c, breakdown, s0=scale)
980
+ if new_scale > scale:
981
+ break
982
+ converged = scale - new_scale <= tol * scale
983
+ location, shape = new_location, new_shape
984
+ distances, scale = new_distances, new_scale
985
+ if converged:
986
+ break
987
+ return location, shape, scale
988
+
989
+
990
+ def _weighted_shape(X, weights):
991
+ """Weighted mean and weighted scatter rescaled to determinant 1."""
992
+ total = weights.sum()
993
+ if total == 0:
994
+ raise ValueError("all observations have zero bisquare weight")
995
+ location = weights @ X / total
996
+ centered = X - location
997
+ scatter = (weights[:, None] * centered).T @ centered
998
+ if _logdet(scatter) == -np.inf:
999
+ raise ValueError("bisquare weights produce a singular scatter matrix")
1000
+ return location, _unit_det(scatter)
1001
+
1002
+
1003
+ class MMEstimator(_RobustCovariance):
1004
+ """MM-estimator of location and scatter (Tatsuoka & Tyler 2000).
1005
+
1006
+ Starts from a bisquare :class:`SEstimator` and keeps its scale ``s``. A
1007
+ bisquare M-estimator of location and shape, tuned for the requested Gaussian
1008
+ efficiency of the shape, then refines the estimates. It keeps the breakdown
1009
+ point of the S-estimator and gains efficiency. Affine equivariant; needs
1010
+ ``n > p``.
1011
+
1012
+ Parameters
1013
+ ----------
1014
+ breakdown : float in (0, 0.5], default 0.5
1015
+ Breakdown point of the initial S-estimator.
1016
+ efficiency : float in (0, 1), default 0.95
1017
+ Gaussian efficiency of the shape.
1018
+ n_trials : int, default 500
1019
+ Random subsets of the initial S-estimator.
1020
+ max_iter : int, default 200
1021
+ Maximum iterations of the M-step.
1022
+ random_state : int, numpy.random.Generator, numpy.random.RandomState or None
1023
+ Seed of the initial S-estimator.
1024
+
1025
+ Attributes
1026
+ ----------
1027
+ location_ : ndarray of shape (n_features,)
1028
+ MM location.
1029
+ covariance_ : ndarray of shape (n_features, n_features)
1030
+ MM covariance matrix.
1031
+ precision_ : ndarray of shape (n_features, n_features)
1032
+ Pseudo-inverse of ``covariance_``.
1033
+ dist_ : ndarray of shape (n_samples,)
1034
+ Squared robust distances of the training data.
1035
+ scale_ : float
1036
+ Scale of the S-estimator.
1037
+ s_location_ : ndarray of shape (n_features,)
1038
+ Location of the S-estimator.
1039
+ s_covariance_ : ndarray of shape (n_features, n_features)
1040
+ Covariance of the S-estimator.
1041
+
1042
+ References
1043
+ ----------
1044
+ Tatsuoka, K. S. and Tyler, D. E. (2000). On the uniqueness of S-functionals
1045
+ and M-functionals under nonelliptical distributions. *Annals of Statistics*,
1046
+ 28(4), 1219-1243.
1047
+
1048
+ Salibián-Barrera, M., Van Aelst, S. and Willems, G. (2006). Principal
1049
+ components analysis based on multivariate MM estimators with fast and robust
1050
+ bootstrap. *Journal of the American Statistical Association*, 101(475),
1051
+ 1198-1211.
1052
+
1053
+ Examples
1054
+ --------
1055
+ >>> import numpy as np
1056
+ >>> from robustmodels.covariance import MMEstimator
1057
+ >>> X = np.random.default_rng(0).normal(size=(100, 2))
1058
+ >>> X[:10] += 10
1059
+ >>> mm = MMEstimator(random_state=0).fit(X)
1060
+ >>> mm.covariance_.round(1).tolist()
1061
+ [[1.2, 0.1], [0.1, 1.2]]
1062
+ """
1063
+
1064
+ def __init__(
1065
+ self, *, breakdown=0.5, efficiency=0.95, n_trials=500, max_iter=200, random_state=None
1066
+ ):
1067
+ self.breakdown = breakdown
1068
+ self.efficiency = efficiency
1069
+ self.n_trials = n_trials
1070
+ self.max_iter = max_iter
1071
+ self.random_state = random_state
1072
+
1073
+ def fit(self, X, y=None):
1074
+ """Fit to ``X`` of shape (n_samples, n_features); ``y`` is ignored. Returns ``self``."""
1075
+ X = self._validate(X)
1076
+ check_count("max_iter", self.max_iter)
1077
+ c = _tuning_for_shape_efficiency(self.efficiency, X.shape[1])
1078
+ initial = SEstimator(
1079
+ breakdown=self.breakdown, n_trials=self.n_trials, random_state=self.random_state
1080
+ )
1081
+ location, shape, scale = initial._fit_s(X)
1082
+ self.scale_ = scale
1083
+ self.s_location_ = location
1084
+ self.s_covariance_ = scale**2 * shape
1085
+ location, shape = _refine_shape(X, location, shape, scale, c, self.max_iter)
1086
+ self._set_estimates(location, scale**2 * shape, X)
1087
+ return self
1088
+
1089
+
1090
+ def _refine_shape(X, location, shape, scale, c, max_iter):
1091
+ """Decrease bisquare loss while keeping the initial S-scale fixed."""
1092
+ if scale == 0:
1093
+ return location, shape
1094
+ distances = np.sqrt(_mahalanobis_sq(X, location, shape))
1095
+ loss = np.mean(_rho(distances / scale, c))
1096
+ for _ in range(max_iter):
1097
+ weights = _bisquare_weights(distances / scale, c)
1098
+ new_location, new_shape = _weighted_shape(X, weights)
1099
+ new_distances = np.sqrt(_mahalanobis_sq(X, new_location, new_shape))
1100
+ new_loss = np.mean(_rho(new_distances / scale, c))
1101
+ if new_loss > loss:
1102
+ break
1103
+ converged = loss - new_loss <= 1e-12
1104
+ location, shape, distances, loss = new_location, new_shape, new_distances, new_loss
1105
+ if converged:
1106
+ break
1107
+ return location, shape
1108
+
1109
+
1110
+ def _mahalanobis_sq(X, location, cov):
1111
+ """Squared Mahalanobis distances (``cov`` must be positive definite)."""
1112
+ centered = X - location
1113
+ cholesky = np.linalg.cholesky(cov)
1114
+ standardized = np.linalg.solve(cholesky, centered.T)
1115
+ return np.sum(standardized**2, axis=0)
1116
+
1117
+
1118
+ def _logdet(cov):
1119
+ """``log det(cov)``, or ``-inf`` if ``cov`` is numerically singular."""
1120
+ eigenvalues = np.linalg.eigvalsh(cov)
1121
+ if eigenvalues[0] <= 1e-12 * eigenvalues[-1]:
1122
+ return -np.inf
1123
+ return float(np.log(eigenvalues).sum())
1124
+
1125
+
1126
+ def _mean_cov(X):
1127
+ return X.mean(axis=0), np.atleast_2d(np.cov(X, rowvar=False))
1128
+
1129
+
1130
+ def _center_scale(X):
1131
+ """Column medians and Qn scales (MAD, then 1, when a scale is zero)."""
1132
+ scale = np.array([_column_scale(column) for column in X.T])
1133
+ return np.median(X, axis=0), scale
1134
+
1135
+
1136
+ def _column_scale(column):
1137
+ scale = qn(column)
1138
+ if scale == 0:
1139
+ scale = mad(column)
1140
+ if scale == 0:
1141
+ scale = 1.0
1142
+ return scale
1143
+
1144
+
1145
+ def _random_subset(X, rng):
1146
+ """Random (p+1)-subset, enlarged until its covariance is non-singular (None if impossible)."""
1147
+ n, p = X.shape
1148
+ order = rng.permutation(n)
1149
+ for k in range(p + 1, n + 1):
1150
+ subset = order[:k]
1151
+ _, covariance = _mean_cov(X[subset])
1152
+ if _logdet(covariance) > -np.inf:
1153
+ return subset
1154
+ return None
1155
+
1156
+
1157
+ def _reweight(X, location, cov, quantile=0.975):
1158
+ """Mean and covariance of the points with ``d^2 < chi2_p(quantile)``.
1159
+
1160
+ The covariance is made consistent for that cutoff (not for the fraction
1161
+ actually kept, which outliers would distort). If every point is kept, the
1162
+ sample covariance is returned unchanged, as in robustbase.
1163
+ """
1164
+ p = X.shape[1]
1165
+ keep = _mahalanobis_sq(X, location, cov) < chi2.ppf(quantile, p)
1166
+ if keep.sum() <= p:
1167
+ return location, cov, keep
1168
+ loc, cov = _mean_cov(X[keep])
1169
+ if not keep.all():
1170
+ cov = cov * mcd_consistency(p, quantile)
1171
+ return loc, cov, keep
1172
+
1173
+
1174
+ def _gk_covariance(X, scale_fn):
1175
+ """Pairwise Gnanadesikan-Kettenring scatter with a unit diagonal."""
1176
+ matrix = np.eye(X.shape[1])
1177
+ for i in range(X.shape[1]):
1178
+ for j in range(i):
1179
+ a, b = X[:, i], X[:, j]
1180
+ sum_variance = scale_fn(a + b) ** 2
1181
+ difference_variance = scale_fn(a - b) ** 2
1182
+ covariance = (sum_variance - difference_variance) / 4
1183
+ matrix[i, j] = matrix[j, i] = covariance
1184
+ return matrix
1185
+
1186
+
1187
+ def _as_generator(random_state):
1188
+ """NumPy ``Generator`` from None, an int, a Generator or a legacy RandomState."""
1189
+ if isinstance(random_state, np.random.RandomState):
1190
+ random_state = random_state.randint(2**31)
1191
+ return np.random.default_rng(random_state)