Kwker

Top-k and selection

Often you do not need everything sorted: only the ten best scores, the median, or the slowest requests. These calls do much less work than a full sort.

You want Use
The k largest or smallest values, and where they were top_k
Only the positions of the k largest or smallest argselect
The value at one position of the sorted order (median, percentile) select
The first k positions sorted, the rest in any order partial_sort

The k largest or smallest values

top_k(a, k) returns two arrays: the k smallest values in order, and their positions in a. Ask for a descending order to get the k largest. a is not changed.

import numpy as np
import kwker

response_ms = np.array([120, 85, 430, 95, 610, 150, 520])
slowest, where = kwker.top_k(response_ms, 3, descending=True)
print(slowest)
print(where)
fastest, where = kwker.top_k(response_ms, 2)
print(fastest, where)
[610 520 430]
[4 6 2]
[85 95] [1 3]
import numpy as np
import kwker

points = np.array([10, 30, 30, 20, 30])
values, where = kwker.top_k(points, 2, descending=True)
print(values, where)
[30 30] [1 2]

If you do not need the k values in order, ask for them unsorted (sorted=False in Python, false as the last argument in Rust, 0 in C). It can save a little time on large k.

Only the positions

argselect(a, k) returns the positions of the k smallest values (or largest, in descending order), in no particular order.

import numpy as np
import kwker

prices = np.array([9.5, 2.0, 7.25, 1.5, 8.0])
cheapest = kwker.argselect(prices, 2)
print(np.sort(cheapest))
print(prices[np.sort(cheapest)])
[1 3]
[2.  1.5]

The median and other positions

select(a, k) moves the value that belongs at position k (counting from 0) into place k. Every value before it is smaller or equal, every value after it is larger or equal, but neither side is sorted. This is how you get a median or a percentile quickly.

import numpy as np
import kwker

a = np.array([31, 4, 15, 92, 65, 35, 89, 79, 26])
k = len(a) // 2
kwker.select(a, k)
print("median:", a[k])
print("smaller side:", np.sort(a[:k]))
print("larger side: ", np.sort(a[k + 1:]))
median: 35
smaller side: [ 4 15 26 31]
larger side:  [65 79 89 92]

For the 90th percentile of n values, select position int(0.9 * (n - 1)).

Sort only the beginning

partial_sort(a, k) sorts the first k positions and leaves the rest in any order. Use it when you show the first page of a long sorted list.

import numpy as np
import kwker

a = np.array([50, 10, 40, 20, 30, 60, 0])
kwker.partial_sort(a, 3)
print(a[:3])
[ 0 10 20]

Top-k with a filter

top_k_masked(a, mask, k) takes the top k among the entries where mask is true. The positions refer to the full array.

import numpy as np
import kwker

score = np.array([0.9, 0.4, 0.8, 0.95, 0.1])
active = np.array([True, True, True, False, True])
values, where = kwker.top_k_masked(score, active, 2, descending=True)
print(values, where)
[0.9 0.8] [0 2]

Top-k per group

top_k_by_group(a, groups, k) finds the top k entries of every group. It is the SQL pattern ROW_NUMBER() OVER (PARTITION BY group ORDER BY value) <= k. Groups are integer labels. The call returns three arrays: the group labels in ascending order, offsets, and positions. Group i's positions are positions[offsets[i]:offsets[i + 1]].

import numpy as np
import kwker

sales = np.array([300, 120, 450, 80, 200, 610])
store = np.array([1, 2, 1, 2, 1, 2])
labels, offsets, positions = kwker.top_k_by_group(sales, store, 2, descending=True)
for i, label in enumerate(labels):
    rows = positions[offsets[i]:offsets[i + 1]]
    print("store", label, "best sales:", sales[rows])
store 1 best sales: [450 300]
store 2 best sales: [610 120]

When the groups are runs of one array, such as the neighbors of each node in a graph stored as CSR (compressed sparse rows), top_k_segments(a, offsets, k) takes the k largest values of every segment: segment i is a[offsets[i]:offsets[i + 1]]. It returns the values and their positions inside each segment.

import numpy as np
import kwker

scores = np.array([0.9, 0.1, 0.5, 0.7, 0.3, 0.8], dtype=np.float32)
offsets = [0, 3, 4, 6]  # three segments: 0-2, 3, 4-5
values, positions = kwker.top_k_segments(scores, offsets, 2)
print(values)
print(positions)
[[0.9 0.5]
 [0.7 0. ]
 [0.8 0.3]]
[[ 0  2]
 [ 0 -1]
 [ 1  0]]

Medians and quantiles over a sliding window

rolling_median(a, window) gives, at every position, the median of the last window values. It is pandas' Series.rolling(window).median(), with the same results. rolling_quantile(a, window, q) does the same for any quantile q between 0 and 1. A rolling median removes short spikes from a signal and keeps its steps sharp.

import numpy as np
import kwker

temps = np.array([20.5, 21.0, 35.0, 21.5, 22.0, 21.75, 22.5])  # (35.0: a sensor glitch)
print(kwker.rolling_median(temps, 3))
print(kwker.rolling_quantile(temps, 3, 0.75))
[  nan   nan 21.   21.5  22.   21.75 22.  ]
[   nan    nan 28.    28.25  28.5   21.875 22.25 ]

The interpolation argument picks how a quantile between two values is formed: "linear" (the default), "lower", "higher", "midpoint" or "nearest", as in pandas.

rolling_mad(a, window) gives the median absolute deviation of each window: the median of |x - m|, where m is the window's median. It measures how far values usually stray from the median, and one glitch barely moves it: a Hampel filter flags a value as a spike when it lies more than 3 x 1.4826 x MAD from its window's median.

import numpy as np
import kwker

temps = np.array([20.5, 21.0, 35.0, 21.5, 22.0, 21.75, 22.5])
mad = kwker.rolling_mad(temps, 3)
print(mad)
spike = np.abs(temps - kwker.rolling_median(temps, 3)) > 3 * 1.4826 * mad
print(np.flatnonzero(spike))
[ nan  nan 0.5  0.5  0.5  0.25 0.25]
[2]

Notes