Repository navigation
Expand file tree
/
Copy pathP2QuantileEstimator.java
More file actions
310 lines (281 loc) · 11.2 KB
/
Copy pathP2QuantileEstimator.java
File metadata and controls
310 lines (281 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
package com.thealgorithms.streaming;
import java.util.Arrays;
/**
* The <b>P-square (P²) algorithm</b> of Jain and Chlamtac: it estimates one quantile of a stream
* using five numbers and no memory of the samples themselves.
*
* <p>Computing an exact quantile requires storing everything seen so far, which is impossible for an
* unbounded stream. P² instead keeps five <i>markers</i> that track the minimum, the quantile
* {@code p/2}, the quantile {@code p} itself, the quantile {@code (1 + p)/2} and the maximum. Each
* marker has a height (its current value) and a position (how many samples are known to be below
* it). Every new sample shifts the positions of the markers it falls below, and each marker is then
* nudged back towards the position it is supposed to occupy. The nudge follows a piecewise
* parabolic prediction fitted through the three neighbouring markers, falling back to a linear one
* whenever the parabola would break the ordering of the heights.
*
* <p>Accuracy is typically a fraction of a percent in rank for stationary streams and improves as
* more samples arrive. The markers adapt continuously, so a drifting distribution is tracked rather
* than averaged away, but no accuracy bound is guaranteed for adversarial inputs.
*
* <table border="1">
* <caption>Cost</caption>
* <tr><th>Operation</th><th>Complexity</th></tr>
* <tr><td>{@link #add(double)}</td><td>O(1)</td></tr>
* <tr><td>{@link #quantile()}</td><td>O(1)</td></tr>
* <tr><td>memory</td><td>O(1) - five markers, independent of the stream length</td></tr>
* </table>
*
* <h2>Usage</h2>
*
* <pre>{@code
* P2QuantileEstimator p99 = new P2QuantileEstimator(0.99);
* for (double latency : latencies) {
* p99.add(latency);
* }
* double tail = p99.quantile();
* }</pre>
*
* <p>The first five samples are stored verbatim, so the estimate is exact until the sixth one
* arrives. This class is not thread-safe.
*
* @see <a href="https://www.cse.wustl.edu/~jain/papers/ftp/psqr.pdf">R. Jain, I. Chlamtac, The P-square algorithm (1985)</a>
*/
public final class P2QuantileEstimator {
private static final int MARKERS = 5;
private final double probability;
/** Marker heights, kept in non-decreasing order; also the raw buffer for the first five samples. */
private final double[] heights = new double[MARKERS];
/** One-based rank of each marker, i.e. how many samples are known to sit at or below it. */
private final int[] positions = new int[MARKERS];
/** Position each marker should ideally occupy. */
private final double[] desiredPositions = new double[MARKERS];
/** How much {@link #desiredPositions} grows per sample. */
private final double[] positionIncrements = new double[MARKERS];
private long count;
/**
* Creates an estimator for a single quantile.
*
* @param probability the quantile to track, strictly between 0 and 1, e.g. {@code 0.5} for the median
* @throws IllegalArgumentException if {@code probability} is outside {@code (0, 1)}
*/
public P2QuantileEstimator(double probability) {
if (!(probability > 0.0) || !(probability < 1.0)) {
throw new IllegalArgumentException("The quantile probability must lie strictly between 0 and 1, but was " + probability);
}
this.probability = probability;
}
/**
* Incorporates one sample.
*
* @param value the sample to add
* @throws IllegalArgumentException if {@code value} is NaN or infinite
*/
public void add(double value) {
if (!Double.isFinite(value)) {
throw new IllegalArgumentException("Samples must be finite, but was " + value);
}
if (count < MARKERS) {
collectInitialSample(value);
return;
}
int cell = locate(value);
for (int i = cell + 1; i < MARKERS; i++) {
positions[i]++;
}
for (int i = 0; i < MARKERS; i++) {
desiredPositions[i] += positionIncrements[i];
}
for (int i = 1; i < MARKERS - 1; i++) {
adjustMarker(i);
}
count++;
}
/**
* Incorporates every given sample, in order.
*
* @param values the samples to add
* @throws IllegalArgumentException if any value is NaN or infinite
* @throws NullPointerException if {@code values} is {@code null}
*/
public void addAll(double... values) {
for (double value : values) {
add(value);
}
}
/**
* Returns the current estimate of the tracked quantile.
*
* @return the estimated quantile, exact while fewer than five samples have been seen
* @throws IllegalStateException if no sample has been added yet
*/
public double quantile() {
requireNonEmpty();
if (count < MARKERS) {
return exactQuantileOfInitialSamples();
}
return heights[2];
}
/**
* Returns the smallest sample seen so far, which P² tracks exactly.
*
* @return the running minimum
* @throws IllegalStateException if no sample has been added yet
*/
public double min() {
requireNonEmpty();
return count < MARKERS ? Arrays.stream(heights, 0, (int) count).min().orElse(Double.NaN) : heights[0];
}
/**
* Returns the largest sample seen so far, which P² tracks exactly.
*
* @return the running maximum
* @throws IllegalStateException if no sample has been added yet
*/
public double max() {
requireNonEmpty();
return count < MARKERS ? Arrays.stream(heights, 0, (int) count).max().orElse(Double.NaN) : heights[MARKERS - 1];
}
/**
* Returns the quantile this estimator was configured for.
*
* @return the probability given at construction time
*/
public double probability() {
return probability;
}
/**
* Returns the number of samples seen so far.
*
* @return the sample count
*/
public long count() {
return count;
}
/**
* Tells whether any sample has been added.
*
* @return {@code true} if the estimator holds no samples
*/
public boolean isEmpty() {
return count == 0;
}
/**
* Forgets every sample.
*/
public void reset() {
count = 0;
Arrays.fill(heights, 0.0);
Arrays.fill(positions, 0);
Arrays.fill(desiredPositions, 0.0);
Arrays.fill(positionIncrements, 0.0);
}
@Override
public String toString() {
return "P2QuantileEstimator{p=" + probability + ", count=" + count + ", quantile=" + (count == 0 ? Double.NaN : quantile()) + '}';
}
/**
* Stores one of the first five samples and, on the fifth, lays out the markers.
*/
private void collectInitialSample(double value) {
heights[(int) count] = value;
count++;
if (count < MARKERS) {
return;
}
Arrays.sort(heights);
// Marker positions are one-based, exactly as in the paper. Only differences of positions are
// ever used, so the origin is arithmetically irrelevant - but the desired positions are grown
// by repeated addition, and starting them one lower would round differently and flip the
// strict comparisons below on the samples where a desired position lands on a whole number.
for (int i = 0; i < MARKERS; i++) {
positions[i] = i + 1;
}
desiredPositions[0] = 1.0;
desiredPositions[1] = 1.0 + 2.0 * probability;
desiredPositions[2] = 1.0 + 4.0 * probability;
desiredPositions[3] = 3.0 + 2.0 * probability;
desiredPositions[4] = 5.0;
positionIncrements[0] = 0.0;
positionIncrements[1] = probability / 2.0;
positionIncrements[2] = probability;
positionIncrements[3] = (1.0 + probability) / 2.0;
positionIncrements[4] = 1.0;
}
/**
* Finds the cell the sample falls into, stretching the outer markers when it falls outside the
* range seen so far.
*
* @param value the incoming sample
* @return the index of the marker immediately below the sample
*/
private int locate(double value) {
if (value < heights[0]) {
heights[0] = value;
return 0;
}
for (int i = 1; i < MARKERS - 1; i++) {
if (value < heights[i]) {
return i - 1;
}
}
if (value > heights[MARKERS - 1]) {
heights[MARKERS - 1] = value;
}
return MARKERS - 2;
}
/**
* Moves one inner marker by a single position if it has drifted too far from where it should be.
*/
private void adjustMarker(int i) {
double drift = desiredPositions[i] - positions[i];
boolean shiftRight = drift >= 1.0 && positions[i + 1] - positions[i] > 1;
boolean shiftLeft = drift <= -1.0 && positions[i - 1] - positions[i] < -1;
if (!shiftRight && !shiftLeft) {
return;
}
int direction = shiftRight ? 1 : -1;
double candidate = parabolicPrediction(i, direction);
heights[i] = heights[i - 1] < candidate && candidate < heights[i + 1] ? candidate : linearPrediction(i, direction);
positions[i] += direction;
}
/**
* Piecewise parabolic prediction: fits a parabola through markers {@code i - 1}, {@code i} and
* {@code i + 1} and evaluates it one position away from the current one.
*/
private double parabolicPrediction(int i, int direction) {
double left = positions[i] - positions[i - 1];
double right = positions[i + 1] - positions[i];
// Grouped exactly as in the paper. The result then decides, by a strict comparison against the
// neighbouring heights, whether the parabola is used at all, so regrouping the arithmetic would
// flip that decision on knife-edge inputs and make this estimator drift away from the reference.
return heights[i] + direction / (left + right) * ((left + direction) * (heights[i + 1] - heights[i]) / right + (right - direction) * (heights[i] - heights[i - 1]) / left);
}
/**
* Linear fallback used whenever the parabolic prediction would violate the ordering of the
* marker heights.
*/
private double linearPrediction(int i, int direction) {
int neighbour = i + direction;
return heights[i] + direction * (heights[neighbour] - heights[i]) / (positions[neighbour] - positions[i]);
}
/**
* Exact quantile of the fewer than five samples buffered so far, by linear interpolation between
* the two order statistics surrounding the requested rank.
*/
private double exactQuantileOfInitialSamples() {
double[] sorted = Arrays.copyOf(heights, (int) count);
Arrays.sort(sorted);
if (sorted.length == 1) {
return sorted[0];
}
double rank = probability * (sorted.length - 1);
int lower = (int) Math.floor(rank);
int upper = Math.min(lower + 1, sorted.length - 1);
return sorted[lower] + (rank - lower) * (sorted[upper] - sorted[lower]);
}
private void requireNonEmpty() {
if (count == 0) {
throw new IllegalStateException("The estimator has not seen any sample yet");
}
}
}