|
10 | 10 | from numpy.testing import assert_allclose |
11 | 11 | from numpy.testing import assert_equal |
12 | 12 | from numpy.testing import assert_raises |
| 13 | +from sklearn.metrics import roc_auc_score |
13 | 14 |
|
14 | 15 | # temporary solution for relative imports in case pyod is not installed |
15 | 16 | # if pyod is installed, no need to use the following line |
@@ -84,6 +85,105 @@ def test_data_generate3(self): |
84 | 85 | assert_allclose(y_train, y_train2) |
85 | 86 | assert_allclose(y_test, y_test2) |
86 | 87 |
|
| 88 | + def test_data_generate_outliers_have_spread(self): |
| 89 | + # Regression test for GH #141: for certain seeds the internal offset |
| 90 | + # was drawn as 0, which collapsed every outlier onto the origin |
| 91 | + # (uniform(-0, 0) == 0) and produced zero-variance outliers. Sweep a |
| 92 | + # range of seeds (41, 48 and 50 previously triggered the collapse) |
| 93 | + # and confirm outliers always keep a non-zero spread. |
| 94 | + for seed in range(60): |
| 95 | + X, y = generate_data( |
| 96 | + n_features=2, |
| 97 | + contamination=0.05, |
| 98 | + train_only=True, |
| 99 | + random_state=seed, |
| 100 | + ) |
| 101 | + outliers = X[y == 1] |
| 102 | + assert outliers.var(axis=0).min() > 0, \ |
| 103 | + "outliers collapsed to zero variance for random_state=%d" % seed |
| 104 | + |
| 105 | + def test_data_generate_float_offset(self): |
| 106 | + # A float offset in (1, 2) used to raise ValueError, because randint |
| 107 | + # truncates its bounds and the redraw got low == high. It is now drawn |
| 108 | + # continuously with a floor of 1.0. Assert separation, not merely a |
| 109 | + # non-zero variance: a spread test alone would pass on data whose |
| 110 | + # labelled outliers sit inside the inlier cloud. |
| 111 | + for offset in (1.1, 1.5, np.nextafter(2.0, 1.0)): |
| 112 | + with self.subTest(offset=offset): |
| 113 | + for seed in range(5): |
| 114 | + X, y = generate_data( |
| 115 | + n_features=2, |
| 116 | + contamination=0.05, |
| 117 | + train_only=True, |
| 118 | + offset=offset, |
| 119 | + random_state=seed, |
| 120 | + ) |
| 121 | + outliers = X[y == 1] |
| 122 | + assert outliers.var(axis=0).min() > 0 |
| 123 | + centrality = np.linalg.norm(X - X.mean(axis=0), axis=1) |
| 124 | + assert roc_auc_score(y, centrality) > 0.7, \ |
| 125 | + "outliers are not separated for offset=%r seed=%d" % ( |
| 126 | + offset, seed) |
| 127 | + |
| 128 | + def test_data_generate_redraw_branches_stable(self): |
| 129 | + # The other golden test pins three default-offset seeds whose first |
| 130 | + # draw is non-zero, so it exercises neither branch the GH #141 fix |
| 131 | + # actually touches. Both seeds below take the zero-then-redraw path |
| 132 | + # (the first randint returns 0): offset=1 hits the fixed offset_=1 |
| 133 | + # branch, and offset=2 hits the integer redraw. |
| 134 | + # |
| 135 | + # Pin an array-wide sum alongside the first and last rows, with a |
| 136 | + # tolerance, rather than hashing the raw bytes. A byte-exact pin is |
| 137 | + # not portable: randn draws Gaussians by the polar method, whose log |
| 138 | + # is not required to be correctly rounded, so libm implementations |
| 139 | + # disagree in the last ulp and the same seed yields different low bits |
| 140 | + # on macOS than on Linux or Windows. The sum still covers every element, |
| 141 | + # so an inserted or reordered RNG draw, which moves values by O(1), |
| 142 | + # cannot hide inside this tolerance. |
| 143 | + golden = { |
| 144 | + (1, 0): (1888.646949950, |
| 145 | + [1.407737153, 1.853812935], [0.716955137, -0.505085628]), |
| 146 | + (2, 0): (1907.119693504, |
| 147 | + [1.070368485, 1.067679162], [-0.467865797, 0.251365755]), |
| 148 | + } |
| 149 | + for (offset, seed), (total, first, last) in golden.items(): |
| 150 | + with self.subTest(offset=offset, seed=seed): |
| 151 | + X, _ = generate_data(n_features=2, contamination=0.05, |
| 152 | + train_only=True, offset=offset, |
| 153 | + random_state=seed)[:2] |
| 154 | + assert_equal(X.shape, (1000, 2)) |
| 155 | + assert_allclose(X.sum(), total, rtol=0, atol=1e-6) |
| 156 | + assert_allclose(X[0], first, rtol=0, atol=1e-9) |
| 157 | + assert_allclose(X[-1], last, rtol=0, atol=1e-9) |
| 158 | + |
| 159 | + def test_data_generate_offset_below_one_rejected(self): |
| 160 | + # An offset below 1 must keep raising. coef_, the inlier spread, is |
| 161 | + # drawn from [0.001, 1.001) independently of offset, so an outlier box |
| 162 | + # of half-width < 1 lands inside an O(1) inlier cloud and the labelled |
| 163 | + # outliers become the densest points in the sample. |
| 164 | + for offset in (0.01, 0.5, np.nextafter(1.0, 0.0)): |
| 165 | + with self.subTest(offset=offset): |
| 166 | + with self.assertRaises(ValueError): |
| 167 | + generate_data(train_only=True, offset=offset, |
| 168 | + random_state=0) |
| 169 | + |
| 170 | + def test_data_generate_reproducibility(self): |
| 171 | + # Golden values pinned from the pre-fix implementation for seeds whose |
| 172 | + # offset was already non-zero. Redrawing only when the offset comes out |
| 173 | + # as 0 leaves these untouched, so this guards against a future change |
| 174 | + # silently altering long-standing fixed-seed output. |
| 175 | + golden = { |
| 176 | + 0: ([5.059894904, 5.061739412], [-0.263919547, 3.009107520]), |
| 177 | + 1: ([3.401186423, 3.524852969], [0.181525489, 3.650202520]), |
| 178 | + 42: ([6.433658544, 5.509168303], [-3.206743915, -4.912722786]), |
| 179 | + } |
| 180 | + for seed, (first, last) in golden.items(): |
| 181 | + X, _ = generate_data(n_train=10, n_test=5, n_features=2, |
| 182 | + contamination=0.2, train_only=True, |
| 183 | + random_state=seed) |
| 184 | + assert_allclose(X[0], first, rtol=0, atol=1e-9) |
| 185 | + assert_allclose(X[-1], last, rtol=0, atol=1e-9) |
| 186 | + |
87 | 187 | def test_data_generate_cluster(self): |
88 | 188 | X_train, X_test, y_train, y_test = \ |
89 | 189 | generate_data_clusters(n_train=self.n_train, |
|
0 commit comments