Streaming Estimation¶
The Hill estimator needs the top \(k\) order statistics, which sounds like it rules out a streaming version — order statistics are a property of the whole sample. But only the top \(k+1\) values ever enter the calculation, and which values those are can be maintained as the data arrives.
A min-heap of size \(k+1\) does it in \(O(k)\) memory and \(O(\log k)\) time per observation, whatever the length of the stream.
The estimate is exact, not approximate¶
from heavytails import Pareto
from heavytails.streaming import StreamingTailIndex
from heavytails.tail_index import hill_estimator
data = Pareto(alpha=2.0, xm=1.0).rvs(200_000, seed=1)
stream = StreamingTailIndex(k=1000)
stream.extend(data)
stream.hill() == hill_estimator(data, k=1000) # True
Not "close to". The same float. Both depend on the sample only through its top \(k+1\) values, and the streaming version keeps exactly those, in the same order, and combines them with the same arithmetic. The test suite asserts equality rather than closeness, because a tolerance would pass on an implementation that quietly dropped or duplicated an order statistic.
Nothing is traded away except the ability to ask for a different \(k\) later.
stream.moment() # (gamma, alpha), also bit-for-bit the batch result
stream.threshold # the (k+1)-th order statistic
stream.n_seen # 200000
Memory does not grow with the stream¶
| Observations | Retained |
|---|---|
| 10,000 | 1,001 |
| 100,000 | 1,001 |
| 1,000,000 | 1,001 |
Values below the current threshold are discarded as they arrive and never looked at again. They may be anything at all — including values the estimator could not take a logarithm of, since they never reach it.
Whole stream, or a window¶
These are different questions and the module keeps them separate.
| Memory | Answers | |
|---|---|---|
StreamingTailIndex |
\(O(k)\) | What is the tail index of everything seen? |
WindowedTailIndex |
\(O(\text{window})\) | What is it now? |
For monitoring, the second is usually the one wanted. A portfolio whose tail index has moved from 3 to 1.5 is not well described by an average of the two:
from heavytails.streaming import WindowedTailIndex
monitor = WindowedTailIndex(window=5000, k=400)
monitor.extend(Pareto(alpha=3.0, xm=1.0).rvs(5000, seed=1))
monitor.hill() # 0.330, against a true gamma of 0.333
monitor.extend(Pareto(alpha=1.5, xm=1.0).rvs(5000, seed=2))
monitor.hill() # 0.667 — the old regime has left the window
The whole-stream estimator over the same data reports 0.650, describing neither regime.
The window costs \(O(\text{window})\) memory, not \(O(k)\)
This is inherent, not an implementation gap. When the largest value in a window expires, the new largest can be any of the survivors, so nothing smaller than the window itself determines the answer. Anything claiming to track windowed order statistics in \(O(k)\) is either approximating or wrong.
A window also handles contamination that is temporary, which fixed trimming
cannot: one bad observation corrupts every whole-stream estimate from the moment
it arrives, whereas in a window it corrupts window of them and is then gone.
Before there is enough data¶
Both refuse rather than returning something:
stream = StreamingTailIndex(k=100)
stream.extend(data[:10])
stream.ready # False
stream.hill() # ValueError: need at least 101 observations for k=100, seen 10
An estimate from ten observations at k = 100 would be a number with no
meaning attached, and the caller has ready to test if they would rather
branch than catch.
Choosing between this and the batch estimators¶
Use heavytails.tail_index when the sample fits in memory and you want to sweep
k, look at a Hill plot, or apply the robust estimators — StreamingTailIndex
fixes k when it is constructed, and the trimmed and bias-reduced estimators
are not available over a stream.
Use heavytails.streaming when the data does not fit, arrives continuously, or
is being watched for change.