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.
robustmodels/scale.py ADDED
@@ -0,0 +1,758 @@
1
+ """Robust scale, skewness and their numerical calculations.
2
+
3
+ Every public function takes a one-dimensional sample of finite values and
4
+ returns a float. An empty sample gives ``nan`` and a single observation has
5
+ zero scale.
6
+ """
7
+
8
+ import numpy as np
9
+ from scipy import optimize, special
10
+ from scipy.stats import chi2, norm
11
+
12
+ from ._validation import as_1d, check_range
13
+
14
+ __all__ = ["lmc", "m_scale", "mad", "medcouple", "pn", "qn", "rmc", "sn", "tau_scale"]
15
+
16
+
17
+ MAD_CONSTANT = 1 / norm.ppf(0.75)
18
+
19
+
20
+ # MAD factors for n = 2, ..., 9; Croux & Rousseeuw (1992).
21
+ _MAD_N = (1.196, 1.495, 1.363, 1.206, 1.200, 1.140, 1.129, 1.107)
22
+
23
+
24
+ def mad(x, *, center=None, consistency=True, finite_correction=False):
25
+ """Median absolute deviation, ``1.4826 * med|x_i - center|``.
26
+
27
+ Breakdown point 50%, Gaussian efficiency 37%.
28
+
29
+ Parameters
30
+ ----------
31
+ x : array_like
32
+ One-dimensional sample of finite values.
33
+ center : float, optional
34
+ Center of the deviations. Defaults to the sample median.
35
+ consistency : bool, default True
36
+ Multiply by ``1 / Phi^-1(3/4) = 1.4826``, so that the MAD estimates the
37
+ standard deviation at the normal model.
38
+ finite_correction : bool, default False
39
+ Multiply by the small-sample factors of Croux & Rousseeuw (1992). Without
40
+ them the MAD underestimates the normal standard deviation by 17% at n = 5.
41
+
42
+ Returns
43
+ -------
44
+ float
45
+ ``nan`` for an empty sample, 0 for a single observation.
46
+
47
+ References
48
+ ----------
49
+ Hampel, F. R. (1974). The influence curve and its role in robust estimation.
50
+ *Journal of the American Statistical Association*, 69(346), 383-393.
51
+
52
+ Croux, C. and Rousseeuw, P. J. (1992). Time-efficient algorithms for two
53
+ highly robust estimators of scale. *Computational Statistics*, 1, 411-428.
54
+
55
+ Examples
56
+ --------
57
+ >>> from robustmodels.scale import mad
58
+ >>> round(mad([1, 2, 2, 3, 4, 100]), 4)
59
+ 1.4826
60
+ >>> mad([1, 2, 2, 3, 4, 100], consistency=False)
61
+ 1.0
62
+ """
63
+ x = as_1d(x)
64
+ if center is not None:
65
+ check_range("center", center, -np.inf, np.inf)
66
+ if x.size < 2:
67
+ return _trivial_scale(x)
68
+ if center is None:
69
+ center = np.median(x)
70
+ value = np.median(np.abs(x - center))
71
+ if consistency:
72
+ value *= MAD_CONSTANT
73
+ if finite_correction:
74
+ value *= _mad_factor(x.size)
75
+ return float(value)
76
+
77
+
78
+ def _mad_factor(n):
79
+ return _MAD_N[n - 2] if n <= 9 else n / (n - 0.8)
80
+
81
+
82
+ QN_CONSTANT = 1 / (np.sqrt(2) * norm.ppf(5 / 8))
83
+
84
+
85
+ _QN_N = (
86
+ 0.399356,
87
+ 0.99365,
88
+ 0.51321,
89
+ 0.84401,
90
+ 0.6122,
91
+ 0.85877,
92
+ 0.66993,
93
+ 0.87344,
94
+ 0.72014,
95
+ 0.88906,
96
+ 0.75743,
97
+ )
98
+
99
+
100
+ def qn(x, *, consistency=True, finite_correction=True):
101
+ """Qn scale estimator of Rousseeuw & Croux (1993).
102
+
103
+ The ``k``-th smallest of the distances ``|x_i - x_j|``, ``i < j``, with
104
+ ``k = h(h - 1) / 2`` and ``h = n // 2 + 1``. Breakdown point 50%, Gaussian
105
+ efficiency 82%. Computed exactly in O(n log n) time, without forming all pairs.
106
+
107
+ Parameters
108
+ ----------
109
+ x : array_like
110
+ One-dimensional sample of finite values.
111
+ consistency : bool, default True
112
+ Multiply by ``1 / (sqrt(2) * Phi^-1(5/8)) = 2.2191``, so that Qn estimates
113
+ the standard deviation at the normal model.
114
+ finite_correction : bool, default True
115
+ Multiply by the small-sample factors used by R's robustbase, which make Qn
116
+ unbiased at the normal model for small ``n``.
117
+
118
+ Returns
119
+ -------
120
+ float
121
+ ``nan`` for an empty sample, 0 for a single observation.
122
+
123
+ References
124
+ ----------
125
+ Rousseeuw, P. J. and Croux, C. (1993). Alternatives to the median absolute
126
+ deviation. *Journal of the American Statistical Association*, 88(424), 1273-1283.
127
+
128
+ Examples
129
+ --------
130
+ >>> from robustmodels.scale import qn
131
+ >>> round(qn([1, 2, 2, 3, 4, 100]), 4)
132
+ 1.3586
133
+ """
134
+ x = np.sort(as_1d(x))
135
+ if x.size < 2:
136
+ return _trivial_scale(x)
137
+ h = x.size // 2 + 1
138
+ rank = h * (h - 1) // 2
139
+ first, last = _pairwise_rows(x.size)
140
+ value = _kth_smallest(lambda i, j: x[j] - x[i], first, last, rank)
141
+ if consistency:
142
+ value *= QN_CONSTANT
143
+ if finite_correction:
144
+ value *= _qn_factor(x.size)
145
+ return float(value)
146
+
147
+
148
+ def _qn_factor(n):
149
+ if n <= 12:
150
+ return _QN_N[n - 2]
151
+ if n % 2:
152
+ correction = -2.1284 - 5.172 / n
153
+ correction = 1.60188 + correction / n
154
+ else:
155
+ correction = 6.987 - 77 / n
156
+ correction = 1.9654 + correction / n
157
+ correction = 3.67561 + correction / n
158
+ return 1 / (1 + correction / n)
159
+
160
+
161
+ SN_CONSTANT = 1.1925985531232086
162
+
163
+
164
+ # Sn factors for n = 2, ..., 9; Croux & Rousseeuw (1992).
165
+ _SN_N = (0.743, 1.851, 0.954, 1.351, 0.993, 1.198, 1.005, 1.131)
166
+
167
+
168
+ def sn(x, *, consistency=True, finite_correction=True):
169
+ """Sn scale estimator of Rousseeuw & Croux (1993).
170
+
171
+ ``lomed_i himed_j |x_i - x_j|``: for each observation, the high median of its
172
+ distances to all observations; then the low median of these values.
173
+ Breakdown point 50%, Gaussian efficiency 58%. Computed exactly in
174
+ O(n log n) time.
175
+
176
+ Parameters
177
+ ----------
178
+ x : array_like
179
+ One-dimensional sample of finite values.
180
+ consistency : bool, default True
181
+ Multiply by 1.1926, so that Sn estimates the standard deviation at the
182
+ normal model.
183
+ finite_correction : bool, default True
184
+ Multiply by the small-sample factors of Croux & Rousseeuw (1992).
185
+
186
+ Returns
187
+ -------
188
+ float
189
+ ``nan`` for an empty sample, 0 for a single observation.
190
+
191
+ References
192
+ ----------
193
+ Rousseeuw, P. J. and Croux, C. (1993). Alternatives to the median absolute
194
+ deviation. *Journal of the American Statistical Association*, 88(424), 1273-1283.
195
+
196
+ Croux, C. and Rousseeuw, P. J. (1992). Time-efficient algorithms for two
197
+ highly robust estimators of scale. *Computational Statistics*, 1, 411-428.
198
+
199
+ Examples
200
+ --------
201
+ >>> from robustmodels.scale import sn
202
+ >>> round(sn([1, 2, 2, 3, 4, 100]), 4)
203
+ 1.1843
204
+ """
205
+ x = np.sort(as_1d(x))
206
+ if x.size < 2:
207
+ return _trivial_scale(x)
208
+ distances = np.sort(_himed_distances(x))
209
+ lower_middle = (x.size + 1) // 2 - 1
210
+ value = distances[lower_middle]
211
+ if consistency:
212
+ value *= SN_CONSTANT
213
+ if finite_correction:
214
+ value *= _sn_factor(x.size)
215
+ return float(value)
216
+
217
+
218
+ def _sn_factor(n):
219
+ if n <= 9:
220
+ return _SN_N[n - 2]
221
+ return n / (n - 0.9) if n % 2 else 1.0
222
+
223
+
224
+ def _himed_distances(x):
225
+ """For sorted x and every i, the ``(n//2 + 1)``-th smallest ``|x_i - x_j|`` over j.
226
+
227
+ The zero self-distance comes first, so this is the ``n//2``-th smallest of the
228
+ merged sorted lists ``left[a] = x_i - x_(i-1-a)`` and ``right[b] = x_(i+1+b) - x_i``;
229
+ a vectorized binary search finds how many come from the left list.
230
+ """
231
+ n, k = x.size, x.size // 2
232
+ i = np.arange(n)
233
+
234
+ def left(a): # +inf past the end
235
+ indices = np.clip(i - 1 - a, 0, n - 1)
236
+ distances = x[i] - x[indices]
237
+ return np.where(a < i, distances, np.inf)
238
+
239
+ def right(b): # -inf before the start
240
+ indices = np.clip(i + 1 + b, 0, n - 1)
241
+ distances = x[indices] - x[i]
242
+ return np.where(b >= 0, distances, -np.inf)
243
+
244
+ lo, hi = np.maximum(0, k - (n - 1 - i)), np.minimum(k, i)
245
+ while np.any(lo < hi):
246
+ active = lo < hi
247
+ mid = (lo + hi) // 2
248
+ more = left(mid) < right(k - mid - 1)
249
+ lo[active & more] = mid[active & more] + 1
250
+ hi[active & ~more] = mid[active & ~more]
251
+ last_left = np.where(lo > 0, left(lo - 1), -np.inf)
252
+ last_right = right(k - lo - 1)
253
+ return np.maximum(last_left, last_right)
254
+
255
+
256
+ PN_CONSTANT = 1 / (np.sqrt(2) * norm.ppf(0.75))
257
+
258
+
259
+ # Tarr (2014), Appendix B.3, p. 148: factors for n = 3, ..., 40.
260
+ # https://www.researchgate.net/publication/267927753
261
+ _PN_N = (
262
+ 1.128,
263
+ 1.303,
264
+ 1.109,
265
+ 1.064,
266
+ 1.166,
267
+ 1.103,
268
+ 1.087,
269
+ 1.105,
270
+ 1.047,
271
+ 1.063,
272
+ 1.057,
273
+ 1.040,
274
+ 1.061,
275
+ 1.047,
276
+ 1.043,
277
+ 1.048,
278
+ 1.031,
279
+ 1.037,
280
+ 1.035,
281
+ 1.028,
282
+ 1.036,
283
+ 1.030,
284
+ 1.029,
285
+ 1.032,
286
+ 1.023,
287
+ 1.025,
288
+ 1.024,
289
+ 1.021,
290
+ 1.026,
291
+ 1.022,
292
+ 1.021,
293
+ 1.023,
294
+ 1.018,
295
+ 1.020,
296
+ 1.019,
297
+ 1.017,
298
+ 1.020,
299
+ 1.018,
300
+ )
301
+
302
+
303
+ def pn(x, *, consistency=True, finite_correction=True):
304
+ """Pn scale estimator of Tarr, Müller & Weber (2012).
305
+
306
+ Interquartile range of the pairwise means: ``H^-1(3/4) - H^-1(1/4)`` with
307
+ ``H`` the empirical distribution of ``(x_i + x_j) / 2``, ``i < j``.
308
+ Breakdown point 13.4%, Gaussian efficiency 86%. Computed exactly in
309
+ O(n log n) time.
310
+
311
+ Parameters
312
+ ----------
313
+ x : array_like
314
+ One-dimensional sample of finite values.
315
+ consistency : bool, default True
316
+ Multiply by ``1 / (sqrt(2) * Phi^-1(3/4)) = 1.0484``, so that Pn estimates
317
+ the standard deviation at the normal model.
318
+ finite_correction : bool, default True
319
+ Multiply by the factors of Tarr (2014), Appendix B.3, for ``n <= 40`` and
320
+ by ``n / (n - 0.7)`` for larger samples.
321
+
322
+ Returns
323
+ -------
324
+ float
325
+ ``nan`` for an empty sample, 0 for one or two observations.
326
+
327
+ References
328
+ ----------
329
+ Tarr, G., Müller, S. and Weber, N. C. (2012). A robust scale estimator based
330
+ on pairwise means. *Journal of Nonparametric Statistics*, 24(1), 187-199.
331
+
332
+ Tarr, G. (2014). *Quantile Based Estimation of Scale and Dependence*.
333
+ PhD thesis, University of Sydney.
334
+
335
+ Examples
336
+ --------
337
+ One outlier in six exceeds the breakdown point of Pn:
338
+
339
+ >>> from robustmodels.scale import pn
340
+ >>> round(pn([1, 2, 2, 3, 4, 100]), 4)
341
+ 54.6572
342
+ """
343
+ x = np.sort(as_1d(x))
344
+ if x.size < 3:
345
+ return _trivial_scale(x)
346
+ q1 = _pairwise_quantile(x, 0.25)
347
+ q3 = _pairwise_quantile(x, 0.75)
348
+ value = q3 - q1
349
+ if consistency:
350
+ value *= PN_CONSTANT
351
+ if finite_correction:
352
+ value *= _pn_factor(x.size)
353
+ return float(value)
354
+
355
+
356
+ def _pn_factor(n):
357
+ if n <= 40:
358
+ return _PN_N[n - 3]
359
+ return n / (n - 0.7)
360
+
361
+
362
+ def tau_scale(x, *, c1=4.5, c2=3.0, consistency=True):
363
+ """Tau scale of Yohai & Zamar (1988), in the form of Maronna & Zamar (2002).
364
+
365
+ ``s0 * sqrt(mean(min(((x - mu) / s0)^2, c2^2)) / E)``, with ``s0`` the raw
366
+ (unscaled) MAD and ``mu`` the tau location
367
+ (:func:`robustmodels.location.tau_location`).
368
+
369
+ Parameters
370
+ ----------
371
+ x : array_like
372
+ One-dimensional sample of finite values.
373
+ c1 : float, default 4.5
374
+ Bisquare tuning constant of the location weights.
375
+ c2 : float, default 3.0
376
+ Truncation point of the standardized residuals.
377
+ consistency : bool, default True
378
+ Divide by ``E = E[min(Z^2, (c2 * Phi^-1(3/4))^2)]``, so that the result
379
+ estimates the standard deviation at the normal model.
380
+
381
+ Returns
382
+ -------
383
+ float
384
+ ``nan`` for an empty sample, 0 for a single observation or a zero MAD.
385
+
386
+ References
387
+ ----------
388
+ Yohai, V. J. and Zamar, R. H. (1988). High breakdown-point estimates of
389
+ regression by means of the minimization of an efficient scale. *Journal of
390
+ the American Statistical Association*, 83(402), 406-413.
391
+
392
+ Maronna, R. A. and Zamar, R. H. (2002). Robust estimates of location and
393
+ dispersion for high-dimensional datasets. *Technometrics*, 44(4), 307-317.
394
+
395
+ Examples
396
+ --------
397
+ >>> from robustmodels.scale import tau_scale
398
+ >>> round(tau_scale([1, 2, 2, 3, 4, 100]), 4)
399
+ 1.5998
400
+ """
401
+ x = as_1d(x)
402
+ check_range("c1", c1, 0, np.inf, low_open=True)
403
+ check_range("c2", c2, 0, np.inf, low_open=True)
404
+ if x.size < 2:
405
+ return _trivial_scale(x)
406
+ location, s0 = _tau_estimates(x, c1)
407
+ if s0 == 0:
408
+ return 0.0
409
+ residuals = (x - location) / s0
410
+ clipped_squares = np.minimum(residuals**2, c2**2)
411
+ mean_square = clipped_squares.mean()
412
+ if consistency:
413
+ mean_square /= _e_trunc_square(c2 * norm.ppf(0.75))
414
+ return float(s0 * np.sqrt(mean_square))
415
+
416
+
417
+ def _tau_estimates(x, c):
418
+ """Return the tau location and raw MAD of a nonempty, finite sample."""
419
+ center = np.median(x)
420
+ raw_mad = np.median(np.abs(x - center))
421
+ if raw_mad == 0:
422
+ return float(center), 0.0
423
+ residuals = (x - center) / raw_mad
424
+ weights = _bisquare_weights(residuals, c)
425
+ total = weights.sum()
426
+ if total == 0:
427
+ return float(center), float(raw_mad)
428
+ location = np.dot(weights, x) / total
429
+ return float(location), float(raw_mad)
430
+
431
+
432
+ def _e_trunc_square(b):
433
+ """E[min(Z^2, b^2)] for Z ~ N(0, 1)."""
434
+ return 2 * norm.cdf(b) - 1 - 2 * b * norm.pdf(b) + 2 * b * b * norm.sf(b)
435
+
436
+
437
+ def m_scale(x, *, center=None, breakdown=0.5):
438
+ """Bisquare M-estimator of scale.
439
+
440
+ Solves ``mean(rho((x_i - center) / s)) = breakdown`` for ``s``, with Tukey's
441
+ bisquare ``rho`` scaled to a maximum of 1. Its tuning constant makes ``s``
442
+ estimate the standard deviation at the normal model. This is the scale that
443
+ S-estimators minimize.
444
+
445
+ Parameters
446
+ ----------
447
+ x : array_like
448
+ One-dimensional sample of finite values.
449
+ center : float, optional
450
+ Center of the residuals. Defaults to the sample median; pass 0 for
451
+ residuals that are already centered.
452
+ breakdown : float in (0, 0.5], default 0.5
453
+ Breakdown point, the right-hand side of the equation.
454
+
455
+ Returns
456
+ -------
457
+ float
458
+ ``nan`` for an empty sample. 0 for a single observation, or when at most a
459
+ fraction ``breakdown`` of the residuals is nonzero.
460
+
461
+ References
462
+ ----------
463
+ Maronna, R. A., Martin, R. D., Yohai, V. J. and Salibián-Barrera, M. (2019).
464
+ *Robust Statistics: Theory and Methods (with R)*, 2nd ed. Wiley.
465
+
466
+ Examples
467
+ --------
468
+ >>> from robustmodels.scale import m_scale
469
+ >>> round(m_scale([1, 2, 2, 3, 4, 100]), 4)
470
+ 1.5019
471
+ """
472
+ x = as_1d(x)
473
+ check_range("breakdown", breakdown, 0, 0.5, low_open=True)
474
+ if center is not None:
475
+ check_range("center", center, -np.inf, np.inf)
476
+ if x.size < 2:
477
+ return _trivial_scale(x)
478
+ center = np.median(x) if center is None else center
479
+ residuals = x - center
480
+ tuning = _tuning_for_breakdown(breakdown)
481
+ return _mscale_bisquare(residuals, tuning, breakdown)
482
+
483
+
484
+ def _mscale_bisquare(residuals, c, breakdown, s0=None, tol=1e-10, max_iter=200):
485
+ """Solve mean(rho(residuals / scale)) = breakdown by fixed-point iteration."""
486
+ residuals = np.abs(residuals)
487
+ positive = residuals[residuals > 0]
488
+ if positive.size <= breakdown * residuals.size:
489
+ return 0.0
490
+ scale = np.median(residuals) / 0.6745 if s0 is None else s0
491
+ if scale <= 0:
492
+ scale = np.median(positive) / 0.6745
493
+ for _ in range(max_iter):
494
+ if scale <= 0:
495
+ return 0.0
496
+ average_loss = np.mean(_rho(residuals / scale, c))
497
+ updated = scale * np.sqrt(average_loss / breakdown)
498
+ if abs(updated - scale) <= tol * scale:
499
+ return float(updated)
500
+ scale = updated
501
+ return float(scale)
502
+
503
+
504
+ def _rho(t, c):
505
+ """Tukey's bisquare loss, normalized to rho(infinity) = 1."""
506
+ squared = (t / c) ** 2
507
+ clipped = np.minimum(squared, 1)
508
+ return 1 - (1 - clipped) ** 3
509
+
510
+
511
+ def _bisquare_weights(t, c):
512
+ """``psi(t) / t`` up to a constant."""
513
+ u = (t / c) ** 2
514
+ return np.where(u < 1, (1 - u) ** 2, 0.0)
515
+
516
+
517
+ def _expected_rho(c, p=1):
518
+ """``E[rho_c(||Z||)]`` for ``Z ~ N_p(0, I)``."""
519
+ t = c * c
520
+ m1 = p * chi2.cdf(t, p + 2)
521
+ m2 = p * (p + 2) * chi2.cdf(t, p + 4)
522
+ m3 = p * (p + 2) * (p + 4) * chi2.cdf(t, p + 6)
523
+ return 3 * m1 / t - 3 * m2 / t**2 + m3 / t**3 + chi2.sf(t, p)
524
+
525
+
526
+ def _tuning_for_breakdown(breakdown=0.5, p=1):
527
+ """``c`` with ``E[rho_c] = breakdown``: consistent scale with that breakdown point."""
528
+ check_range("breakdown", breakdown, 0, 0.5, low_open=True)
529
+ upper = 10 * np.sqrt((p + 4) / breakdown) # E[rho_c] < breakdown there
530
+ return optimize.brentq(lambda c: _expected_rho(c, p) - breakdown, 1e-3, upper, xtol=1e-14)
531
+
532
+
533
+ def _shape_efficiency(c, p):
534
+ """Gaussian efficiency of the bisquare M-estimator of shape (Tyler 1982).
535
+
536
+ ``1 / sigma1``, ``sigma1 = p(p+2) E[psi^2 d^2] / E[psi' d^2 + (p+1) psi d]^2``
537
+ with ``psi(d) = d (1 - (d/c)^2)^2`` and ``d = ||Z||``.
538
+ """
539
+
540
+ def m(a): # E[d^a; d <= c]
541
+ g = np.exp(special.gammaln((p + a) / 2) - special.gammaln(p / 2))
542
+ return g * 2 ** (a / 2) * chi2.cdf(c * c, p + a)
543
+
544
+ psi2_d2 = m(4) - 4 * m(6) / c**2 + 6 * m(8) / c**4 - 4 * m(10) / c**6 + m(12) / c**8
545
+ dpsi_d2 = m(2) - 6 * m(4) / c**2 + 5 * m(6) / c**4
546
+ psi_d = m(2) - 2 * m(4) / c**2 + m(6) / c**4
547
+ return (dpsi_d2 + (p + 1) * psi_d) ** 2 / (p * (p + 2) * psi2_d2)
548
+
549
+
550
+ def _tuning_for_shape_efficiency(efficiency, p):
551
+ check_range("efficiency", efficiency, 0, 1, low_open=True, high_open=True)
552
+ return optimize.brentq(lambda c: _shape_efficiency(c, p) - efficiency, 0.5, 1e3, xtol=1e-12)
553
+
554
+
555
+ def medcouple(x):
556
+ """Medcouple, a robust measure of skewness (Brys, Hubert & Struyf 2004).
557
+
558
+ Median of ``((x_i - m) - (m - x_j)) / (x_i - x_j)`` over ``x_i >= m >= x_j``,
559
+ with ``m`` the sample median and the paper's kernel for ties at ``m``.
560
+ Lies in [-1, 1]: positive for right skew, 0 for symmetric or constant
561
+ samples, and ``medcouple(-x) == -medcouple(x)``. Breakdown point 25%.
562
+ Computed exactly in O(n log n) time.
563
+
564
+ Parameters
565
+ ----------
566
+ x : array_like
567
+ One-dimensional sample of finite values.
568
+
569
+ Returns
570
+ -------
571
+ float
572
+ ``nan`` for an empty sample.
573
+
574
+ References
575
+ ----------
576
+ Brys, G., Hubert, M. and Struyf, A. (2004). A robust measure of skewness.
577
+ *Journal of Computational and Graphical Statistics*, 13(4), 996-1017.
578
+
579
+ Examples
580
+ --------
581
+ >>> from robustmodels.scale import medcouple
582
+ >>> medcouple([1, 2, 2, 3, 4, 100])
583
+ 0.5
584
+ """
585
+ x = np.sort(as_1d(x))
586
+ if x.size == 0:
587
+ return np.nan
588
+ center = np.median(x)
589
+ upper = x[x >= center] - center
590
+ lower = x[x <= center] - center
591
+
592
+ def kernel(i, j):
593
+ tie = (upper[i] == 0) & (lower[j] == 0)
594
+ denominator = np.where(tie, 1, upper[i] - lower[j])
595
+ ratio = (upper[i] + lower[j]) / denominator
596
+ tie_value = np.sign(i + j - lower.size + 1)
597
+ return np.where(tie, tie_value, ratio)
598
+
599
+ first = np.zeros(upper.size, dtype=int)
600
+ last = np.full(upper.size, lower.size - 1)
601
+ return _matrix_median(kernel, first, last)
602
+
603
+
604
+ def lmc(x):
605
+ """Left medcouple, a robust measure of left tail weight (Brys, Hubert & Struyf 2006).
606
+
607
+ ``LMC = -medcouple(x[x <= median(x)])``; larger values mean a heavier left tail.
608
+
609
+ Parameters
610
+ ----------
611
+ x : array_like
612
+ One-dimensional sample of finite values.
613
+
614
+ Returns
615
+ -------
616
+ float
617
+ ``nan`` for an empty sample.
618
+
619
+ References
620
+ ----------
621
+ Brys, G., Hubert, M. and Struyf, A. (2006). Robust measures of tail weight.
622
+ *Computational Statistics & Data Analysis*, 50(3), 733-759.
623
+
624
+ Examples
625
+ --------
626
+ >>> from robustmodels.scale import lmc
627
+ >>> round(lmc([0.2, 0.5, 0.9, 1.1, 1.6, 2.4, 3.8, 7.5]), 4)
628
+ 0.0556
629
+ """
630
+ x = as_1d(x)
631
+ if x.size == 0:
632
+ return np.nan
633
+ return 0.0 - medcouple(x[x <= np.median(x)])
634
+
635
+
636
+ def rmc(x):
637
+ """Right medcouple, a robust measure of right tail weight (Brys, Hubert & Struyf 2006).
638
+
639
+ ``RMC = medcouple(x[x >= median(x)])``; larger values mean a heavier right tail.
640
+
641
+ Parameters
642
+ ----------
643
+ x : array_like
644
+ One-dimensional sample of finite values.
645
+
646
+ Returns
647
+ -------
648
+ float
649
+ ``nan`` for an empty sample.
650
+
651
+ References
652
+ ----------
653
+ Brys, G., Hubert, M. and Struyf, A. (2006). Robust measures of tail weight.
654
+ *Computational Statistics & Data Analysis*, 50(3), 733-759.
655
+
656
+ Examples
657
+ --------
658
+ >>> from robustmodels.scale import rmc
659
+ >>> round(rmc([0.2, 0.5, 0.9, 1.1, 1.6, 2.4, 3.8, 7.5]), 4)
660
+ 0.2458
661
+ """
662
+ x = as_1d(x)
663
+ if x.size == 0:
664
+ return np.nan
665
+ return medcouple(x[x >= np.median(x)])
666
+
667
+
668
+ def _trivial_scale(x):
669
+ """Scale of fewer than two values: NaN if empty, else 0."""
670
+ return np.nan if x.size == 0 else 0.0
671
+
672
+
673
+ # Select pairwise values without allocating the full n-by-n matrix.
674
+ _DIRECT_LIMIT = 50_000
675
+
676
+
677
+ def _pairwise_rows(n, diagonal=False):
678
+ """Inclusive column bounds for pairs i < j, or i <= j with the diagonal."""
679
+ row_count = n if diagonal else n - 1
680
+ first = np.arange(row_count)
681
+ if not diagonal:
682
+ first += 1
683
+ last = np.full(row_count, n - 1)
684
+ return first, last
685
+
686
+
687
+ def _pairwise_quantile(x, probability, diagonal=False):
688
+ """Inverse empirical CDF of (x[i] + x[j]) / 2 for sorted x."""
689
+ first, last = _pairwise_rows(x.size, diagonal)
690
+ count = int(np.sum(last - first + 1))
691
+ rank = int(np.ceil(probability * count))
692
+ return _kth_smallest(lambda i, j: (x[i] + x[j]) / 2, first, last, rank)
693
+
694
+
695
+ def _matrix_median(value, first, last):
696
+ """Median of an implicit sorted-row matrix, averaging the two middle values."""
697
+ count = int(np.sum(last - first + 1))
698
+ rank = (count + 1) // 2
699
+ lower = _kth_smallest(value, first, last, rank)
700
+ if count % 2:
701
+ return lower
702
+ upper = _kth_smallest(value, first, last, rank + 1)
703
+ return float((lower + upper) / 2)
704
+
705
+
706
+ def _kth_smallest(value, lo, hi, k):
707
+ """Select rank k (1-based) from value(i, j), with lo[i] <= j <= hi[i]."""
708
+ lo = np.array(lo, copy=True)
709
+ hi = np.array(hi, copy=True)
710
+ while True:
711
+ sizes = np.maximum(hi - lo + 1, 0)
712
+ if sizes.sum() <= max(_DIRECT_LIMIT, 4 * lo.size):
713
+ return _direct(value, lo, sizes, k)
714
+ rows = np.flatnonzero(sizes)
715
+ midpoints = (lo[rows] + hi[rows]) // 2
716
+ trial = _weighted_median(value(rows, midpoints), sizes[rows])
717
+ below = _count(value, rows, lo[rows], hi[rows], trial, strict=True)
718
+ through = _count(value, rows, lo[rows], hi[rows], trial, strict=False)
719
+ if k <= below.sum():
720
+ hi[rows] = lo[rows] + below - 1
721
+ elif k > through.sum():
722
+ k -= through.sum()
723
+ lo[rows] += through
724
+ else:
725
+ return float(trial)
726
+
727
+
728
+ def _direct(value, first, sizes, rank):
729
+ """Expand only the remaining candidates and select their order statistic."""
730
+ rows = np.repeat(np.arange(first.size), sizes)
731
+ row_offsets = np.cumsum(sizes) - sizes
732
+ offsets = np.arange(rows.size) - np.repeat(row_offsets, sizes)
733
+ columns = np.repeat(first, sizes) + offsets
734
+ candidates = value(rows, columns)
735
+ return float(np.partition(candidates, rank - 1)[rank - 1])
736
+
737
+
738
+ def _weighted_median(values, weights):
739
+ order = np.argsort(values)
740
+ cumulative = np.cumsum(weights[order])
741
+ middle = np.searchsorted(cumulative, cumulative[-1] / 2)
742
+ return values[order[middle]]
743
+
744
+
745
+ def _count(value, rows, lo, hi, trial, strict):
746
+ """Count entries below trial in each row, including ties if strict is False."""
747
+ left = lo.copy()
748
+ right = hi + 1
749
+ while np.any(left < right):
750
+ active = left < right
751
+ middle = np.minimum((left + right) // 2, hi)
752
+ values = value(rows, middle)
753
+ below = values < trial if strict else values <= trial
754
+ move_left = active & below
755
+ move_right = active & ~below
756
+ left[move_left] = middle[move_left] + 1
757
+ right[move_right] = middle[move_right]
758
+ return left - lo