lenatriestounderstand

Lab · runnable experiments

Isotonic Regression on GPU

Created Oct 26, 2026 Updated Oct 27, 2026

Read the parent note

This lab rebuilds the note’s argument on one T4, mostly with CUDA kernels short enough to read in full.

Part 1 writes pool adjacent violators as a GPU kernel with one thread per row and compares it with one CPU core as the rows get longer. Part 2 checks in NumPy that the fit is the slope of the lower convex hull of the running totals. Part 3 builds a lane’s hull twice, with the stack in an array and in one 32-bit integer, and compares what the compiler makes of each and how long each takes. Part 4 merges chunk hulls up a tree and counts the steps of every bridge walk. Part 5 measures what copying merged hulls costs, with the whole GPU and with one warp. Part 6 runs the same rows through torchisotonic, forward and backward, Part 7 uses it to calibrate a click model on the Criteo advertising data, and Part 8 uses it as a soft rank inside a training loop.

Setup

Run on Kaggle with Settings → Accelerator → GPU T4 x2 (the lab uses one card), Internet on, and the dataset Criteo dataset (mrkmakr/criteo-dataset) added under Input for Part 7. The first call to torchisotonic compiles its CUDA operator, which takes a few minutes.

"""Isotonic regression on a GPU, by hand: one thread per row, the hull of running totals, a
lane's stack in a register, bridges, the price of copying, and torchisotonic on real work."""
import os, sys, json, time, glob, subprocess, datetime
import numpy as np, torch
import matplotlib.pyplot as plt

assert torch.cuda.is_available(), "No GPU: Settings → Accelerator → GPU T4 x2"
torch.cuda.set_device(0)
try:
    import cupy as cp
except ImportError:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "cupy-cuda12x"], check=True)
    import cupy as cp
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "torchisotonic==0.2.1"], check=True)
import numba
from sklearn.isotonic import isotonic_regression, IsotonicRegression

OUT = {"meta": {"gpu": torch.cuda.get_device_name(0), "torch": torch.__version__,
                "cupy": cp.__version__,
                "executed": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")}}
print(OUT["meta"])


def gpu_ms(fn, repeats=5, warmup=True):
    """Median wall-clock milliseconds of fn(), waiting for the GPU after every call. An idle GPU
    runs at a low clock and takes a while to raise it, so it is kept busy for a moment first:
    otherwise short calls are timed at a fraction of the speed they run at under load."""
    def sync():
        cp.cuda.Device().synchronize()
        torch.cuda.synchronize()
    if warmup:
        fn(); sync()
    torch.cuda._sleep(1_000_000_000); sync()
    times = []
    for _ in range(repeats):
        start = time.perf_counter()
        fn(); sync()
        times.append((time.perf_counter() - start) * 1e3)
    return float(np.median(times))
{'gpu': 'Tesla T4', 'torch': '2.11.0+cu128', 'cupy': '14.0.1', 'executed': '2026-10-26T04:23:41+00:00'}

gpu_ms keeps the GPU busy for a second before timing. An idle T4 runs at a low clock and needs a while to raise it, so a short call timed right after a pause runs well below its speed. The first run of this lab skipped this and measured torchisotonic at 4.6 ms on a row it fits in 1.9.

Part 1 — One thread per row

The kernel below runs pool adjacent violators with one thread per row. Each thread walks its row from left to right with a stack of blocks, a sum and a size for each, and pools the newest block into the previous one while its mean is lower. It is the loop from the note, unchanged.

PAVA_ROWS = cp.RawKernel(r'''
extern "C" __global__
void pava_rows(const float* y, float* out, double* sums, int* sizes, int rows, int n) {
    int r = blockIdx.x * blockDim.x + threadIdx.x;      // one thread, one whole row
    if (r >= rows) return;
    const float* yr = y + (long long)r * n;
    double* S = sums + (long long)r * n;                 // the stack of blocks: their sums
    int* C = sizes + (long long)r * n;                   // and their sizes
    int top = 0;
    for (int i = 0; i < n; ++i) {
        double s = yr[i];
        int c = 1;
        // pool while the new block's mean is below the mean of the block before it
        while (top > 0 && s * C[top - 1] < S[top - 1] * c) {
            --top;
            s += S[top];
            c += C[top];
        }
        S[top] = s; C[top] = c; ++top;
    }
    long long k = (long long)r * n;
    for (int b = 0; b < top; ++b) {
        float m = (float)(S[b] / C[b]);
        for (int j = 0; j < C[b]; ++j) out[k++] = m;
    }
}
''', "pava_rows")


def noisy(rows, n, seed=0):
    """Rows of a rising trend plus noise, float32: the data of the note's first table."""
    rng = np.random.default_rng(seed)
    return np.linspace(0, 3, n, dtype=np.float32) + rng.standard_normal((rows, n), dtype=np.float32)


def pava_rows(y):
    """One thread per row; returns the fit and a function that reruns the kernel alone."""
    rows, n = y.shape
    out = cp.empty_like(y)
    sums, sizes = cp.empty((rows, n), cp.float64), cp.empty((rows, n), cp.int32)
    threads = 128
    launch = lambda: PAVA_ROWS(((rows + threads - 1) // threads,), (threads,),
                               (y, out, sums, sizes, np.int32(rows), np.int32(n)))
    launch()
    return out, launch


# first, is it right? against scikit-learn's pool adjacent violators, row by row
y = noisy(64, 1000)
fit, _ = pava_rows(cp.asarray(y))
err = max(np.abs(fit.get()[i] - isotonic_regression(y[i])).max() for i in range(64))
print(f"largest difference from scikit-learn on 64 rows of 1,000: {err:.1e}")
largest difference from scikit-learn on 64 rows of 1,000: 0.0e+00

Every shape below holds the same 8,388,608 values, from half a million rows of 16 down to a single row, so the total work stays the same.

N = 1 << 23
shapes = [(1 << k, N >> k) for k in (19, 16, 13, 10, 7, 4, 2, 0)]
part1 = []
for rows, n in shapes:
    y = cp.asarray(noisy(rows, n))
    _, launch = pava_rows(y)
    long_rows = n >= (1 << 21)
    ms = gpu_ms(launch, repeats=1 if long_rows else 5, warmup=not long_rows)
    part1.append({"rows": rows, "n": n, "ms": ms})
    print(f"{rows:>9,} rows of {n:>9,}   {ms:9.1f} ms   threads with work: {rows:,}")
    del y

row = noisy(1, N)[0]
t = time.perf_counter(); isotonic_regression(row); cpu_ms = (time.perf_counter() - t) * 1e3
print(f"\nscikit-learn, one CPU core, one row of {N:,}: {cpu_ms:.0f} ms")
OUT["part1"] = {"shapes": part1, "cpu_ms": cpu_ms}

fig, ax = plt.subplots()
ax.loglog([p["n"] for p in part1], [p["ms"] for p in part1], "o-", label="GPU, one thread per row")
ax.axhline(cpu_ms, color="0.4", ls="--", lw=1, label="one CPU core, one row of all of them")
ax.set_xlabel("row length (same 8.4 million values in every shape)")
ax.set_ylabel("ms")
ax.legend(fontsize=8)
plt.show()
  524,288 rows of        16        17.3 ms   threads with work: 524,288
   65,536 rows of       128        35.3 ms   threads with work: 65,536
    8,192 rows of     1,024         4.3 ms   threads with work: 8,192
    1,024 rows of     8,192        13.0 ms   threads with work: 1,024
      128 rows of    65,536       113.9 ms   threads with work: 128
       16 rows of   524,288       449.7 ms   threads with work: 16
        4 rows of 2,097,152      1340.6 ms   threads with work: 4
        1 rows of 8,388,608      3594.5 ms   threads with work: 1

scikit-learn, one CPU core, one row of 8,388,608: 170 ms

With thousands of rows, every shape finishes in milliseconds, and the shapes differ from each other by tens of milliseconds at most. With few rows, the time grows with the row length: sixteen rows take 0.45 s, four take 1.3 s, and one takes 3.6 s. One CPU core fits the same single row in 0.17 s. The solver that differentiable-sorting libraries ship takes 5.5 s for ten million values on this card.

Changing threads = 128 to 32 or 1,024 makes no difference to the long rows, which never have more than four threads with work.

Part 2 — Nails and a string

Here the running totals become points, and the monotone chain finds their lower convex hull, the string from the note. It uses the same kind of stack as Part 1, with points instead of blocks.

@numba.njit
def lower_hull(P):
    """Indices of the lower convex hull of the points (k, P[k]), by the monotone chain."""
    st = np.empty(len(P), np.int64)
    top = 0
    for k in range(len(P)):
        while top >= 2:
            a, b = st[top - 2], st[top - 1]
            if (b - a) * (P[k] - P[a]) - (P[b] - P[a]) * (k - a) <= 0:
                top -= 1                    # b is on or above the line from a to k: let it go
            else:
                break
        st[top] = k
        top += 1
    return st[:top]


def fit_from_string(y):
    """The isotonic fit as the slopes of the string under the running totals."""
    P = np.concatenate([[0.0], np.cumsum(y, dtype=np.float64)])
    h = lower_hull(P)
    return np.repeat(np.diff(P[h]) / np.diff(h), np.diff(h)), h, P


print("1, 3, 2, 4  ->", fit_from_string(np.array([1.0, 3, 2, 4]))[0])
y = noisy(1, 100_000, seed=1)[0].astype(np.float64)
print("largest difference from pool adjacent violators on 100,000 values:",
      f"{np.abs(fit_from_string(y)[0] - isotonic_regression(y)).max():.1e}")

y = noisy(1, 24, seed=3)[0].astype(np.float64)
fit, h, P = fit_from_string(y)
fig, (a1, a2) = plt.subplots(2, 1, figsize=(7, 5), sharex=True)
a1.bar(np.arange(24) + 0.5, y, width=0.8, color="0.8", label="values")
a1.step(np.arange(25), np.append(fit, fit[-1]), where="post", color="C3", label="fit")
a1.legend(fontsize=8)
a2.plot(np.arange(25), P, "o", color="0.3", ms=4, label="running totals (nails)")
a2.plot(h, P[h], "-", color="C3", label="the string: lower convex hull")
a2.legend(fontsize=8)
plt.show()
1, 3, 2, 4  -> [1.  2.5 2.5 4. ]
largest difference from pool adjacent violators on 100,000 values: 8.9e-16

Next, how many points end up on the hull, for noise and for data that already rise. Part 5 depends on this number.

n = 1_000_000
rising = np.linspace(1.0, 2.0, n)           # strictly increasing: every running total bends up
counts = {"noise": len(lower_hull(np.concatenate([[0.0], np.cumsum(noisy(1, n, seed=2)[0], dtype=np.float64)]))),
          "rising": len(lower_hull(np.concatenate([[0.0], np.cumsum(rising)])))}
for k, v in counts.items():
    print(f"{k:7s} {v:>10,} of {n + 1:,} points are on the string")
OUT["part2"] = counts
noise          277 of 1,000,001 points are on the string
rising   1,000,001 of 1,000,001 points are on the string

On noise the hull has 277 of a million points, so the fit has 276 blocks. On rising data every point is on it.

Part 3 — A whole stack in one register

On the GPU the smallest unit is a lane: one thread, 32 consecutive points, one small hull. hull_stack keeps the stack in an array, like the monotone chain above. hull_mask keeps it in the bits of one 32-bit integer, with the top of the stack and the entry below it in two registers. After a pop, the new entry below the top is the highest set bit under the old one, and __clz (count leading zeros) finds it in one instruction.

Point k of lane t is stored at P[k * lanes + t], so at every step the 32 threads of a warp read 32 neighbouring numbers.

LANE_SRC = r'''
__device__ double turn(const double* P, int lanes, int t, int a, int b, int k) {
    double pa = P[a * lanes + t], pb = P[b * lanes + t], pk = P[k * lanes + t];
    return (double)(b - a) * (pk - pa) - (pb - pa) * (double)(k - a);
}

extern "C" __global__ void hull_stack(const double* P, unsigned* masks, int lanes) {
    int t = blockIdx.x * blockDim.x + threadIdx.x;
    if (t >= lanes) return;
    int st[32];                                  // the stack, an array of point numbers
    int top = 0;
    for (int k = 0; k < 32; ++k) {
        while (top >= 2 && turn(P, lanes, t, st[top - 2], st[top - 1], k) <= 0) --top;
        st[top++] = k;
    }
    unsigned m = 0;
    for (int i = 0; i < top; ++i) m |= 1u << st[i];
    masks[t] = m;
}

extern "C" __global__ void hull_mask(const double* P, unsigned* masks, int lanes) {
    int t = blockIdx.x * blockDim.x + threadIdx.x;
    if (t >= lanes) return;
    unsigned mask = 1u;                          // the stack, one bit per point: point 0 is on it
    int a = -1, b = 0;                           // the top b and the entry under it, a (-1: none)
    int k = 1;
    while (k < 32) {
        if (a >= 0 && turn(P, lanes, t, a, b, k) <= 0) {      // pop b
            mask &= ~(1u << b);
            b = a;
            unsigned below = mask & ((1u << a) - 1u);
            a = below ? 31 - __clz(below) : -1;               // the highest set bit below a
        } else {                                               // push k
            mask |= 1u << k;
            a = b; b = k; ++k;
        }
    }
    masks[t] = mask;
}
'''
hull_stack = cp.RawKernel(LANE_SRC, "hull_stack")
hull_mask = cp.RawKernel(LANE_SRC, "hull_mask")
hull_stack.compile(); hull_mask.compile()
for name, k in (("hull_stack", hull_stack), ("hull_mask", hull_mask)):
    print(f"{name:11s} registers {k.num_regs:3d}   local memory per thread {k.local_size_bytes:4d} bytes")
hull_stack  registers  32   local memory per thread  128 bytes
hull_mask   registers  24   local memory per thread    0 bytes

The array in hull_stack is indexed by values known only at run time, so the compiler cannot keep it in registers and puts it in local memory, which belongs to the thread but is as far away as global memory. hull_mask uses no local memory at all.

Both are checked against the CPU hull of each 32-point window, then timed.

def lane_points(y):
    """Running totals of each lane's 32 values, laid out point-major for coalesced reads."""
    P = np.cumsum(y.reshape(-1, 32), axis=1, dtype=np.float64)    # lane-local running totals
    return cp.asarray(np.ascontiguousarray(P.T)), P


def cpu_masks(P, lanes):
    out = np.empty(lanes, np.uint32)
    for t in range(lanes):
        out[t] = np.bitwise_or.reduce(np.left_shift(np.uint32(1), lower_hull(P[t]).astype(np.uint32)))
    return out


data = {"noise": noisy(1, N, seed=4)[0], "rising": np.linspace(1.0, 2.0, N)}
part3 = {}
for name, y in data.items():
    Pd, P = lane_points(y)
    lanes = N // 32
    masks = {k: cp.empty(lanes, cp.uint32) for k in ("stack", "mask")}
    grid, threads = ((lanes + 255) // 256,), (256,)
    run_stack = lambda: hull_stack(grid, threads, (Pd, masks["stack"], np.int32(lanes)))
    run_mask = lambda: hull_mask(grid, threads, (Pd, masks["mask"], np.int32(lanes)))
    ms_stack, ms_mask = gpu_ms(run_stack, 20), gpu_ms(run_mask, 20)
    sample = cpu_masks(P[:2000], 2000)
    ok = bool((masks["stack"][:2000].get() == sample).all() and (masks["mask"].get() == masks["stack"].get()).all())
    vertices = float(np.mean([bin(int(m)).count("1") for m in masks["mask"][:4096].get()]))
    part3[name] = {"stack_ms": ms_stack, "mask_ms": ms_mask, "agree": ok, "mean_vertices": vertices}
    print(f"{name:6s} stack in an array {ms_stack:6.2f} ms   in a bit mask {ms_mask:6.2f} ms   "
          f"({ms_stack / ms_mask:.2f}x)   agree with the CPU: {ok}   vertices per lane: {vertices:.1f}")
    m = int(masks["mask"][7].get())
    print(f"       lane 7's stack when it is done: {m:032b}  (bit 0 on the right)")
OUT["part3"] = part3
noise  stack in an array   3.71 ms   in a bit mask   1.04 ms   (3.57x)   agree with the CPU: True   vertices per lane: 5.0
       lane 7's stack when it is done: 10011000000000010010000100000001  (bit 0 on the right)
rising stack in an array   0.60 ms   in a bit mask   0.54 ms   (1.12x)   agree with the CPU: True   vertices per lane: 32.0
       lane 7's stack when it is done: 11111111111111111111111111111111  (bit 0 on the right)

Both agree with the CPU on every lane checked. On noise, where points leave the stack constantly, the array version takes 3.6 times as long, because every pop and every check after it goes through local memory. On rising data nothing is popped and the two are within 12%. The paper measured the real chunk kernel on this card at 2.1 times on noise and 3.4 times on rising data. One difference matters here: this toy uses no shared memory, so its stack has the SM’s L1 cache to itself, while the real kernel gives that memory to shared memory first.

A lane of 64 points is a small change to try: the mask becomes a 64-bit integer, with 1ull << k and __clzll, and the array grows to 64 entries.

Part 4 — Glue the pieces, and count the walk

The row is cut into chunks of 1,024 points, each chunk’s hull is built, and neighbours are merged up a tree. A merge finds the bridge: from the shared point, one end moves left and the other right, dropping every point above the line between them. glue counts those steps.

@numba.njit
def turn3(P, a, b, c):
    return (b - a) * (P[c] - P[a]) - (P[b] - P[a]) * (c - a)


@numba.njit
def glue(P, L, R):
    """Merge two neighbouring hulls (index arrays sharing their boundary point); returns the
    merged hull and the number of nails the walk to the bridge let go."""
    nl, nr = len(L), len(R)
    if nl < 2 or nr < 2 or turn3(P, L[nl - 2], L[nl - 1], R[1]) > 0:
        return np.concatenate((L, R[1:])), 0          # already bends upwards: nothing to search
    a, b, steps = nl - 2, 1, 1                        # the shared point itself is the first to go
    moved = True
    while moved:
        moved = False
        while a > 0 and turn3(P, L[a - 1], L[a], R[b]) <= 0:
            a -= 1; steps += 1; moved = True
        while b < nr - 1 and turn3(P, L[a], R[b], R[b + 1]) <= 0:
            b += 1; steps += 1; moved = True
    return np.concatenate((L[:a + 1], R[b:])), steps


def merge_tree(y, chunk=1024):
    P = np.concatenate([[0.0], np.cumsum(y, dtype=np.float64)])
    n = len(y)
    nodes = [np.arange(j, min(j + chunk, n) + 1)[lower_hull(P[j:min(j + chunk, n) + 1])] for j in range(0, n, chunk)]
    walks = []                                         # per level: the steps of every merge
    while len(nodes) > 1:
        merged, level = [], []
        for k in range(0, len(nodes) - 1, 2):
            h, steps = glue(P, nodes[k], nodes[k + 1])
            merged.append(h); level.append(steps)
        if len(nodes) % 2:
            merged.append(nodes[-1])
        nodes = merged
        walks.append(level)
    return walks, len(nodes[0])


n = 1 << 22
cliff = np.linspace(1.0, 2.0, n); cliff[-1] = -1e9    # rising, and one value far below at the end
part4 = {}
for name, y in (("noise", noisy(1, n, seed=5)[0].astype(np.float64)), ("rising", np.linspace(1.0, 2.0, n)), ("cliff", cliff)):
    walks, final = merge_tree(y)
    every = np.concatenate([np.asarray(w) for w in walks])
    part4[name] = {"merges": len(every), "max_steps": int(every.max()), "over_64": int((every > 64).sum()),
                   "last_level_steps": int(walks[-1][0]), "final_vertices": final}
    print(f"{name:6s} {len(every):,} merges   longest walk {every.max():>9,} steps   "
          f"walks over 64 steps: {(every > 64).sum():,}   final hull: {final:,} vertices")
OUT["part4"] = part4
noise  4,095 merges   longest walk        18 steps   walks over 64 steps: 0   final hull: 455 vertices
rising 4,095 merges   longest walk         0 steps   walks over 64 steps: 0   final hull: 4,194,305 vertices
cliff  4,095 merges   longest walk 2,097,152 steps   walks over 64 steps: 12   final hull: 2 vertices

On noise the longest walk is 18 steps. On rising data no merge walks at all, since the joined hull is already convex at every shared point. With one value far below at the end, the last merge walks 2,097,152 steps, across every vertex of the left half. Twelve of the 4,095 walks go past 64 steps, the twelve merges on the right edge of the tree. In torchisotonic no walk gets that far: a merge done by one thread stops at 64 steps and switches to binary search, and the few merges at the top, done by a whole warp, search from the start.

Part 5 — The price of copying

Suppose every merge writes its result out as a new array. A copied vertex holds its height (8 bytes), its position (4) and its chunk (4), 16 bytes in all. On a rising row of ten million values nothing is ever dropped, so each of the 14 levels rewrites about ten million vertices. The cell below times the copying alone, without any bridge search.

n = 10_000_000
src = [cp.random.random(n), cp.arange(n, dtype=cp.int32), cp.arange(n, dtype=cp.int32) // 1024]
dst = [cp.empty_like(a) for a in src]
level_ms = gpu_ms(lambda: [cp.copyto(d, s) for d, s in zip(dst, src)], 10)
print(f"one level, {n * 16 / 1e6:.0f} MB rewritten with the whole GPU: {level_ms:.2f} ms "
      f"({2 * n * 16 / level_ms / 1e6:.0f} GB/s read + write)")
print(f"14 levels: {14 * level_ms:.0f} ms of copying (the paper's whole grid-copy merge stage: 25.1 ms)")
one level, 160 MB rewritten with the whole GPU: 1.42 ms (226 GB/s read + write)
14 levels: 20 ms of copying (the paper's whole grid-copy merge stage: 25.1 ms)

One level takes 1.4 ms, reading and writing at 226 GB/s, about 70% of the card’s 320 GB/s. Fourteen levels come to 20 ms, against the 25.1 ms the paper measured for the whole grid-copy merge stage, bridge searches included.

The textbook merge copies inside the merge: the thread or warp that found the bridge moves the right hull’s surviving vertices behind the left one’s. Near the top of the tree only a few merges are left, and each copy is done by one warp. In the last merge of that row, the right half holds 1,611,392 vertices.

COPY = cp.RawKernel(r'''
extern "C" __global__
void copy_vertices(const double* h, const int* p, const int* c, double* h2, int* p2, int* c2, long long n) {
    long long stride = (long long)gridDim.x * blockDim.x;
    for (long long i = blockIdx.x * (long long)blockDim.x + threadIdx.x; i < n; i += stride) {
        h2[i] = h[i]; p2[i] = p[i]; c2[i] = c[i];
    }
}
''', "copy_vertices")

m = 1_611_392
args = (*[a[:m] for a in src], *[a[:m] for a in dst], np.int64(m))
one_warp = gpu_ms(lambda: COPY((1,), (32,), args), 5)
whole_gpu = gpu_ms(lambda: COPY((40 * 8,), (256,), args), 20)
desc = cp.random.random(1574 * 3)                       # 1,574 descriptors of 24 bytes: the same merge, listed
desc_ms = gpu_ms(lambda: cp.copyto(cp.empty_like(desc), desc), 20)
print(f"{m:,} vertices ({m * 16 / 1e6:.1f} MB): one warp {one_warp:.2f} ms, the whole GPU {whole_gpu:.3f} ms "
      f"({one_warp / whole_gpu:.0f}x)")
print(f"the same merge with segment lists moves 1,574 descriptors (38 KB): {desc_ms:.3f} ms")
OUT["part5"] = {"level_ms": level_ms, "levels14_ms": 14 * level_ms, "one_warp_ms": one_warp,
                "whole_gpu_ms": whole_gpu, "descriptors_ms": desc_ms}
1,611,392 vertices (25.8 MB): one warp 48.53 ms, the whole GPU 0.245 ms (198x)
the same merge with segment lists moves 1,574 descriptors (38 KB): 0.043 ms

One warp takes 48.5 ms to copy what the whole GPU copies in 0.25 ms. The per-merge copy pays this at each of the top levels, which is where its 171 ms in the paper come from. Moving the descriptors of the same merge takes 0.04 ms, most of it the cost of launching a kernel.

Part 6 — torchisotonic

The first call to torchisotonic compiles its CUDA operator for this card. After that, torchisotonic.isotonic(y) fits every row of y along the last dimension, with or without weights, in float32 or float64, on the CPU or the GPU.

import torchisotonic
t = time.perf_counter()
torchisotonic.isotonic(torch.randn(4, 100, device="cuda"))
torch.cuda.synchronize()
print(f"first call, which builds the CUDA operator: {time.perf_counter() - t:.0f} s")

y = torch.randn(256, 1000, dtype=torch.float64, device="cuda").cumsum(-1) * 0.05 + torch.randn(256, 1000, dtype=torch.float64, device="cuda")
w = torch.rand(256, 1000, dtype=torch.float64, device="cuda") + 0.1
fit = torchisotonic.isotonic(y, w)
ref = np.stack([isotonic_regression(r, sample_weight=s) for r, s in zip(y.cpu().numpy(), w.cpu().numpy())])
print(f"weighted, float64, 256 rows of 1,000: largest difference from scikit-learn {np.abs(fit.cpu().numpy() - ref).max():.1e}")
first call, which builds the CUDA operator: 80 s
weighted, float64, 256 rows of 1,000: largest difference from scikit-learn 2.0e-14

Compilation took about 80 seconds. With weights, in float64, the fit matches scikit-learn to within 2e-14.

The shapes from Part 1, through the library:

part6 = []
for rows, n in [(1 << 19, 16), (1, N)]:
    y = torch.as_tensor(noisy(rows, n), device="cuda")
    ms = gpu_ms(lambda: torchisotonic.isotonic(y), 20)
    mine = next(p["ms"] for p in OUT["part1"]["shapes"] if p["rows"] == rows)
    part6.append({"rows": rows, "n": n, "ms": ms, "one_thread_ms": mine})
    print(f"{rows:>9,} rows of {n:>9,}   torchisotonic {ms:7.2f} ms   one thread per row (Part 1) {mine:9.1f} ms")
y = (torch.arange(N, dtype=torch.int32, device="cuda") + 0x3F800000).view(torch.float32)[None]   # consecutive floats from 1.0
ms = gpu_ms(lambda: torchisotonic.isotonic(y), 20)
part6.append({"rows": 1, "n": N, "rising": True, "ms": ms})
print(f"        1 row  of {N:>9,}, rising: every point a vertex   torchisotonic {ms:7.2f} ms")
OUT["part6"] = part6
  524,288 rows of        16   torchisotonic    1.19 ms   one thread per row (Part 1)      17.3 ms
        1 rows of 8,388,608   torchisotonic    1.87 ms   one thread per row (Part 1)    3594.5 ms
        1 row  of 8,388,608, rising: every point a vertex   torchisotonic    3.00 ms

The single row that took one thread 3.6 s takes 1.9 ms. The rising row, where every point stays a vertex all the way up the tree, takes 3.0 ms. Both are in line with the paper’s 2.13 ms and 3.25 ms for ten million values. The half million short rows take 1.2 ms, against 17 ms with one thread per row.

The fit is differentiable: each fitted value is the mean of its block, so the gradient is averaged within each block.

y = torch.tensor([[1.0, 3.0, 2.0, 4.0]], requires_grad=True)
fit = torchisotonic.isotonic(y)
fit[0, 1].backward()
print("fit:", fit.detach().numpy()[0], "  d fit[1] / d y:", y.grad.numpy()[0])
fit: [1.  2.5 2.5 4. ]   d fit[1] / d y: [0.  0.5 0.5 0. ]

The second fitted value is the mean of the second and third inputs, so its gradient is split equally between them, and the other inputs get none.

Part 7 — Calibrating a click model on Criteo

Calibration is the most common use of isotonic regression. A click model trained on data with nine of every ten non-clicks removed overestimates click rates, and a monotone curve fitted on later traffic maps its scores back to observed rates. This part follows the calibration example in the torchisotonic repository, on the first seven million Criteo impressions, split in time order into seven “days” of a million: training on days 1 to 5, calibration on day 6, evaluation on day 7, which the curve never sees.

import pyarrow as pa, pyarrow.csv as pacsv

path = next(iter(glob.glob("/kaggle/input/**/dac/train.txt", recursive=True)), None)
assert path, "add the dataset mrkmakr/criteo-dataset under Input"
ROWS, BUCKETS, KEEP = 7_000_000, 1 << 18, 0.1
DENSE, CATS = [f"I{i}" for i in range(1, 14)], [f"C{i}" for i in range(1, 27)]

HEX = np.zeros(256, np.uint32)
for i, ch in enumerate(b"0123456789abcdef"):
    HEX[ch] = i
for i, ch in enumerate(b"ABCDEF"):
    HEX[ch] = 10 + i


def hex_codes(arr):
    """The 32-bit values of an Arrow array of 8-digit hex strings (0 where missing), vectorized."""
    n, out, buffers = len(arr), np.zeros(len(arr), np.uint32), arr.buffers()
    if buffers[2] is None:
        return out
    offsets = np.frombuffer(buffers[1], np.int32, count=arr.offset + n + 1)[arr.offset:]
    data = np.frombuffer(buffers[2], np.uint8)
    full = np.diff(offsets) == 8
    nibbles = HEX[data[offsets[:-1][full, None] + np.arange(8)]]
    out[full] = np.bitwise_or.reduce(nibbles << np.arange(28, -1, -4, dtype=np.uint32), axis=1)
    return out


t = time.perf_counter()
reader = pacsv.open_csv(path, read_options=pacsv.ReadOptions(column_names=["label", *DENSE, *CATS], block_size=1 << 26),
                        parse_options=pacsv.ParseOptions(delimiter="\t"),
                        convert_options=pacsv.ConvertOptions(column_types={"label": pa.int8(), **{c: pa.float32() for c in DENSE}, **{c: pa.string() for c in CATS}},
                                                             strings_can_be_null=True))
labels = np.empty(ROWS, np.float32); dense = np.empty((ROWS, 13), np.float32); cats = np.empty((ROWS, 26), np.int64)
got = 0
for batch in reader:
    m = min(batch.num_rows, ROWS - got)
    if m <= 0:
        break
    batch = batch.slice(0, m)
    labels[got:got + m] = batch.column(0).to_numpy(zero_copy_only=False)
    for j in range(13):
        dense[got:got + m, j] = np.log1p(np.maximum(np.nan_to_num(batch.column(1 + j).to_numpy(zero_copy_only=False)), 0))
    for j in range(26):
        cats[got:got + m, j] = hex_codes(batch.column(14 + j)) % BUCKETS
    got += m
print(f"read {got:,} impressions in {time.perf_counter() - t:.0f} s; click rate {labels.mean():.3f}")
read 7,000,000 impressions in 51 s; click rate 0.250

The model is a logistic regression on hashed categorical features and log-scaled counts, trained on days 1 to 5 with every click and a tenth of the non-clicks.

dev = torch.device("cuda")
day = ROWS // 7
torch.manual_seed(0); rng = np.random.default_rng(0)
train = np.arange(5 * day)
train = train[(labels[train] == 1) | (rng.random(len(train)) < KEEP)]

emb = torch.nn.Embedding(26 * BUCKETS, 1).to(dev); torch.nn.init.zeros_(emb.weight)
lin = torch.nn.Linear(13, 1).to(dev)
field = torch.arange(26, device=dev) * BUCKETS
logit = lambda d, c: emb(c + field).sum((1, 2)) + lin(d).squeeze(1)
opt = torch.optim.Adam([*emb.parameters(), *lin.parameters()], lr=1e-2)
D, Cc, Y = (torch.as_tensor(a[train], device=dev) for a in (dense, cats, labels))
t = time.perf_counter()
for epoch in range(2):
    for i in torch.randperm(len(Y), device=dev).split(1 << 14):
        opt.zero_grad(set_to_none=True)
        torch.nn.functional.binary_cross_entropy_with_logits(logit(D[i], Cc[i]), Y[i]).backward()
        opt.step()
torch.cuda.synchronize()
print(f"trained on {len(Y):,} impressions in {time.perf_counter() - t:.0f} s")

@torch.no_grad()
def scores(rows):
    d, c = torch.as_tensor(dense[rows], device=dev), torch.as_tensor(cats[rows], device=dev)
    return torch.sigmoid(logit(d, c).double())

cal, test = slice(5 * day, 6 * day), slice(6 * day, 7 * day)
s_cal, s_test = scores(cal), scores(test)
y_cal, y_test = (torch.as_tensor(labels[r], device=dev, dtype=torch.float64) for r in (cal, test))
print(f"day 7: mean predicted click probability {s_test.mean():.3f}, observed click rate {y_test.mean():.3f}")
trained on 1,627,597 impressions in 2 s
day 7: mean predicted click probability 0.662, observed click rate 0.254

On day 7 it predicts a mean click probability of 0.66, where the actual click rate is 0.25.

The calibration code sorts the scores, pools equal scores into one mean weighted by their count, fits a non-decreasing curve through the means, and reads the curve at new scores by interpolation. This is what scikit-learn’s IsotonicRegression does.

def isotonic_curve(scores, outcomes):
    keys, order = torch.sort(scores)
    xp, inverse, counts = torch.unique_consecutive(keys, return_inverse=True, return_counts=True)
    means = torch.zeros(len(xp), dtype=torch.float64, device=scores.device).index_add_(0, inverse, outcomes[order]) / counts
    return xp, torchisotonic.isotonic(means, counts.double())      # the one call


def apply_curve(s, xp, fp):
    i = torch.searchsorted(xp, s).clamp(1, len(xp) - 1)
    t = ((s - xp[i - 1]) / (xp[i] - xp[i - 1]).clamp_min(1e-300)).clamp(0, 1)
    return fp[i - 1] + t * (fp[i] - fp[i - 1])


def ece(p, y, bins=15):
    which = (p * bins).long().clamp(max=bins - 1)
    return float((torch.bincount(which, p, bins) - torch.bincount(which, y, bins)).abs().sum() / len(p))


xp, fp = isotonic_curve(s_cal, y_cal)
calibrated = {"none": s_test,
              "downsampling correction": s_test / (s_test + (1 - s_test) / KEEP)}
from sklearn.linear_model import LogisticRegression
z = torch.logit(s_cal.clamp(1e-12, 1 - 1e-12)).cpu().numpy()[:, None]
pl = LogisticRegression(C=1e6).fit(z, y_cal.cpu().numpy())
calibrated["Platt"] = torch.sigmoid(float(pl.coef_[0, 0]) * torch.logit(s_test.clamp(1e-12, 1 - 1e-12)) + float(pl.intercept_[0]))
calibrated["isotonic (torchisotonic)"] = apply_curve(s_test, xp, fp)

sk = IsotonicRegression(out_of_bounds="clip").fit(s_cal.cpu().numpy(), y_cal.cpu().numpy())
agree = float(np.abs(sk.predict(s_test.cpu().numpy()) - calibrated["isotonic (torchisotonic)"].cpu().numpy()).max())
t = time.perf_counter(); IsotonicRegression(out_of_bounds="clip").fit(s_cal.cpu().numpy(), y_cal.cpu().numpy()); sk_s = time.perf_counter() - t
gpu_s = gpu_ms(lambda: isotonic_curve(s_cal, y_cal), 5) / 1e3

part7 = {"ece": {k: ece(p, y_test) for k, p in calibrated.items()}, "agree_vs_sklearn": agree,
         "sklearn_s": sk_s, "torchisotonic_s": gpu_s, "unique_scores": len(xp)}
for k, v in part7["ece"].items():
    print(f"{k:26s} expected calibration error on day 7: {v:.4f}")
print(f"\none curve over {len(s_cal):,} scores ({len(xp):,} distinct): scikit-learn {sk_s:.2f} s, "
      f"torchisotonic {gpu_s * 1e3:.1f} ms; largest difference between the two curves {agree:.1e}")

fig, ax = plt.subplots(figsize=(4.6, 4.6))
ax.plot([0, 1], [0, 1], color="0.75", lw=1)
for k, p in calibrated.items():
    groups = torch.tensor_split(torch.argsort(p), 40)
    ax.plot([float(p[g].mean()) for g in groups], [float(y_test[g].mean()) for g in groups], ".-", lw=1, label=k)
ax.set_xlabel("predicted click probability"); ax.set_ylabel("observed click rate on day 7")
ax.legend(fontsize=7)
plt.show()
none                       expected calibration error on day 7: 0.4085
downsampling correction    expected calibration error on day 7: 0.0128
Platt                      expected calibration error on day 7: 0.0050
isotonic (torchisotonic)   expected calibration error on day 7: 0.0030

one curve over 1,000,000 scores (987,158 distinct): scikit-learn 0.23 s, torchisotonic 4.3 ms; largest difference between the two curves 3.3e-16

On day 7 the expected calibration error is 0.41 without calibration, 0.0128 after the analytic correction for the downsampling, 0.0050 after Platt scaling, and 0.0030 after the isotonic curve. torchisotonic and scikit-learn produce the same curve, to 3e-16. For a million scores, almost all distinct, the fit takes 4.3 ms on the GPU and 0.23 s in scikit-learn.

In practice there is often one curve per country, campaign or other segment. These can be fitted in a single call: key each score by its segment plus the score, and shift each segment’s outcomes by twice its index, so that no block can cross a segment boundary.

codes = cats[cal][:, 14]                                         # C15, a categorical feature
values, counts = np.unique(codes, return_counts=True)
frequent = values[counts >= 2000]
seg = np.searchsorted(frequent, codes)
seg = np.where((seg < len(frequent)) & (frequent[np.minimum(seg, len(frequent) - 1)] == codes), seg, -1)
keep = torch.as_tensor(seg >= 0, device=dev)
sg = torch.as_tensor(seg, device=dev)[keep].double()

def per_segment():
    keys = sg + s_cal[keep].clamp(max=1 - 1e-12)                 # segment + score: sorted segment by segment
    k, order = torch.sort(keys)
    xp, inverse, cnt = torch.unique_consecutive(k, return_inverse=True, return_counts=True)
    means = torch.zeros(len(xp), dtype=torch.float64, device=dev).index_add_(0, inverse, y_cal[keep][order]) / cnt
    shift = 2.0 * xp.floor()                                     # each segment lifted above the one before
    return xp, torchisotonic.isotonic(means + shift, cnt.double()) - shift

kp, kf = per_segment()
fits = gpu_ms(per_segment, 5)
sc, yc = s_cal.cpu().numpy(), y_cal.cpu().numpy()
t = time.perf_counter()
for g in range(len(frequent)):
    a = seg == g
    IsotonicRegression(out_of_bounds="clip").fit(sc[a], yc[a])
loop_s = time.perf_counter() - t
print(f"{len(frequent)} curves over {int(keep.sum()):,} scores: one torchisotonic call {fits:.1f} ms, "
      f"a scikit-learn loop {loop_s:.2f} s")
part7["segments"] = {"curves": int(len(frequent)), "rows": int(keep.sum()), "one_call_ms": fits, "sklearn_loop_s": loop_s}
OUT["part7"] = part7
91 curves over 412,820 scores: one torchisotonic call 2.5 ms, a scikit-learn loop 0.25 s

Ninety-one segments, one per frequent value of the feature, take 2.5 ms in one call. Fitting the same curves one at a time with scikit-learn takes 0.25 s.

Part 8 — Learning to rank through a soft rank

Ranks have no useful gradient: a small change in a score leaves the ranks unchanged until two items swap. A soft rank projects the scores onto the set of permutations of 1 to n, which gives ranks that change smoothly with the scores. The projection is one isotonic regression on the sorted scores, so with torchisotonic it takes four lines, and autograd handles the backward pass.

def soft_rank(x, eps):
    """Soft ranks of every row of x, 1 for the smallest: the projection of x / eps onto the
    permutahedron, one isotonic regression on the sorted values. eps -> 0 gives hard ranks."""
    n = x.shape[-1]
    w = torch.arange(n, 0, -1, device=x.device, dtype=x.dtype)          # n, n - 1, ..., 1
    s, perm = torch.sort(x / eps, descending=True, dim=-1)
    ranks = s - torchisotonic.isotonic(s - w, increasing=False)
    return ranks.gather(-1, torch.argsort(perm, dim=-1))


x = torch.tensor([[0.3, -1.2, 2.0, 0.7]], device="cuda")
for eps in (1e-3, 0.5, 5.0):
    print(f"eps {eps:<6} soft ranks {soft_rank(x, eps).cpu().numpy().round(2)[0]}")
eps 0.001  soft ranks [2. 1. 4. 3.]
eps 0.5    soft ranks [2.1 1.  4.  2.9]
eps 5.0    soft ranks [2.47 2.17 2.81 2.55]

Each query has a thousand items with 32 features, and a hidden relevance with noise defines their true order. A small network is trained on one minus the Spearman correlation between the soft ranks of its scores and the true ranks, so the loss depends only on the order.

torch.manual_seed(0)
Q, ITEMS, F = 64, 1000, 32
proj = torch.randn(F, 2, device="cuda") / F ** 0.5


def batch(q):
    """Queries of ITEMS items; true ranks (1..ITEMS) from a hidden relevance plus noise."""
    x = torch.randn(q, ITEMS, F, device="cuda")
    clean = torch.sin(2 * (x @ proj[:, 0])) + torch.relu(x @ proj[:, 1])
    ranks = lambda r: r.argsort(-1).argsort(-1).float() + 1
    return x, ranks(clean + 0.3 * torch.randn(q, ITEMS, device="cuda")), ranks(clean)


def spearman(r, t):
    r, t = r - r.mean(-1, keepdim=True), t - t.mean(-1, keepdim=True)
    return (r * t).sum(-1) / (r.norm(dim=-1) * t.norm(dim=-1))


net = torch.nn.Sequential(torch.nn.Linear(F, 128), torch.nn.ReLU(), torch.nn.Linear(128, 1)).cuda()
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
xv, tv, best = batch(256)
ceiling = float(spearman(best, tv).mean())          # the noise-free relevance itself: no model does better
history = []
t = time.perf_counter()
for step in range(401):
    x, tr, _ = batch(Q)
    loss = 1 - spearman(soft_rank(net(x).squeeze(-1), eps=0.1), tr).mean()
    opt.zero_grad(set_to_none=True); loss.backward(); opt.step()
    if step % 50 == 0:
        with torch.no_grad():
            hard = net(xv).squeeze(-1).argsort(-1).argsort(-1).float() + 1
            history.append((step, float(spearman(hard, tv).mean())))
torch.cuda.synchronize()
train_s = time.perf_counter() - t
for s, rho in history:
    print(f"step {s:3d}   Spearman on 256 held-out queries, with hard ranks: {rho:.3f}")
print(f"the best possible, the hidden relevance without its noise: {ceiling:.3f}")
print(f"400 steps of 64 queries x 1,000 items in {train_s:.1f} s")
OUT["part8"] = {"history": history, "train_s": train_s, "ceiling": ceiling}
step   0   Spearman on 256 held-out queries, with hard ranks: 0.072
step  50   Spearman on 256 held-out queries, with hard ranks: 0.607
step 100   Spearman on 256 held-out queries, with hard ranks: 0.667
step 150   Spearman on 256 held-out queries, with hard ranks: 0.883
step 200   Spearman on 256 held-out queries, with hard ranks: 0.925
step 250   Spearman on 256 held-out queries, with hard ranks: 0.930
step 300   Spearman on 256 held-out queries, with hard ranks: 0.930
step 350   Spearman on 256 held-out queries, with hard ranks: 0.931
step 400   Spearman on 256 held-out queries, with hard ranks: 0.932
the best possible, the hidden relevance without its noise: 0.944
400 steps of 64 queries x 1,000 items in 1.4 s

Measured with hard ranks on held-out queries, the Spearman correlation goes from 0.07 to 0.93 within 250 steps. The ceiling is 0.944, the score of the noise-free relevance itself. All 400 steps take 1.4 s.

eps is worth changing. At 1.0 the soft ranks are blurred and change little with the scores; at 0.01 they are nearly hard, and the gradient nearly vanishes.

Read the parent note