Skip to main content

whitetree

whitetree

Exact nearest-neighbour search for sensor data. Updates without rebuilds.

Intro

whitetree finds the records that look most like a new one. It is built for records that are a few numbers with different units that tend to move together, like (temperature, pressure, flow) from a sensor.

Where whitetree fits: equipment anomaly detection, robotic repetitive tasks, data deduplication during collection, battery pack monitoring

Plain Euclidean distance gives wrong neighbours on sensor records. The column with the biggest numbers wins, so pressure in pascals decides the answer and temperature barely counts. Scaling each column fixes the units but ignores the fact that temperature and pressure rise and fall together. Mahalanobis distance corrects for both the units and the correlation, and it is the standard distance for correlated measurements. Using Mahalanobis distance in Python is the hard part. scikit-learn makes you work out the inverse covariance matrix and pass it in yourself, and every tree index in scikit-learn and scipy has to be built again from scratch each time a new record arrives.

whitetree works out the covariance for you, gives exact answers (the same as a scipy.spatial.cKDTree built on the same data), and lets you add and remove records whenever you like without rebuilding anything.

How it works

The name says it: whiten, then tree.

  1. fit(X) works out the covariance and its Cholesky factor L, then multiplies every row by L⁻¹. After the multiplication, Mahalanobis distance between rows equals plain Euclidean distance, so the rows can go into a normal scipy.spatial.cKDTree.
  2. insert(x) multiplies the new row by the same L⁻¹ and drops it into a small buffer. Once the buffer holds 8 points, the buffer becomes a tiny tree. The trees are kept in order of size, and each tree has to be at least 32 times bigger than the next. When a new tree would be too big for its place, the new tree is merged with the smaller trees and rebuilt as one. As a result there are only 3 or 4 trees even with a million points, and the biggest tree almost never gets rebuilt.
  3. kneighbors(q) multiplies the query by L⁻¹, searches the biggest tree first, and uses the k-th distance found in the biggest tree as a cutoff for the smaller trees, so the smaller trees are searched quickly.

Since the trees are plain cKDTrees, workers=-1 runs queries on several cores. Each query reads one snapshot of the tree list, so queries can keep running while another thread inserts.

Results

All numbers below are single-threaded, k = 10, and every answer was checked against float64 brute force. The datasets are public sensor logs from UCI. The full tables are in docs/benchmarks.md and the scripts are in benchmarks/.

Searching a fixed dataset (ann-benchmarks protocol, 500,000 points, one query at a time). On 3 to 7 dimensional sensor data, whitetree answers 8,000 to 14,000 queries per second. That is 40 to 300 times faster than scikit-learn's Mahalanobis BallTree, 7 to 60 times faster than FAISS IndexFlatL2, and about the same as whitening the data yourself and using scipy's cKDTree directly. FAISS can whiten too (PCAMatrix), but it estimates the covariance from a small sample in float32, so it misses 2 to 4% of the true neighbours, and on data with a big offset it returns NaN.

search speed

Streaming (big-ann-benchmarks streaming protocol). Keep a window of 200,000 points. At every step, insert one new point, delete the oldest one, and answer one query that has to see the point you just inserted. whitetree gets through about 1,100 steps a second. FAISS IndexFlatL2 manages about 20, because its delete scans the whole index. numpy brute force does 30 to 40. Rebuilding a scipy cKDTree or a scikit-learn BallTree for every query is slower than whitetree, FAISS and numpy.

streaming

When to use it

whitetree is a good fit when the following four conditions are true:

  • Each record is a handful of numbers, up to about 16, in different units or with columns that move together. Sensor channels, robot or vehicle state, GPS plus IMU, process measurements.
  • You need the exact nearest records, not close enough.
  • Records keep coming in (and maybe going out) while you keep asking questions, and the questions are mixed in with the updates rather than saved up for after them.
  • You have at least 20,000 records and expect to run at least 10,000 queries.

Typical jobs: comparing a machine's current state with its own history inside a control loop, spotting odd readings in a live sensor stream, finding past paths that look like the one a robot is on now, catching duplicate measurements as they get logged.

whitetree is the wrong tool in four situations. If updates come in big batches with lots of queries in between, just rebuilding a scipy.spatial.cKDTree after each batch is simpler and faster. Under 20,000 records, a flat scan (faiss.IndexFlatL2) wins. Past about 32 dimensions, KD-trees stop helping; use FAISS or hnswlib. And right after a run of inserts, single queries run at 20 to 80% of full speed because they have to look in 2 or 3 trees; consolidate() merges them and gets the speed back.

Check your installation

Two scripts check that the library works on your machine with your versions of numpy and scipy. The first inserts and deletes records in lots of different patterns and compares every answer with a scipy.spatial.cKDTree built on the same points, including ties, empty indexes, k bigger than the index, and agreement with scikit-learn's Mahalanobis BallTree. The second runs several query threads while another thread inserts, deletes and re-fits, and makes sure no query ever comes back with a broken or missing answer. Both finish with all checks passed.

pip install .[test]
python tests/test_dynamic_kdtree.py
python tests/test_concurrency.py

Usage

pip install whitetree
from whitetree import MahalanobisIndex

idx = MahalanobisIndex().fit(X)      # works out the covariance
d, i = idx.kneighbors(q, k=5)        # (nq, k) distances and ids
pid = idx.insert(new_row)
idx.delete(pid)

idx.consolidate()                    # merge the trees into one
idx.drift()                          # 0.0 means the fitted covariance still matches recent data
idx.refit()                          # estimate it again and rebuild

Python 3.10 or newer. Needs numpy and scipy, nothing else. examples/streaming.py is a full example, docs/api.md lists every parameter and method, and docs/design.md explains the design and the measurements behind it.

Details

The package is whitetree. The class you import from it is MahalanobisIndex, named after the distance it computes. Its methods:

  • fit(X) estimates the covariance from X (n rows, d columns), whitens the rows and builds the index. It raises an error on NaN, inf, constant columns or fewer than 2 rows, and warns when the covariance is close to singular. Pass cov= to use your own covariance instead.
  • kneighbors(q, k=5, workers=-1) returns the k nearest records for one query or a batch of queries, as (distances, ids) arrays of shape (nq, k). The distances are Mahalanobis distances. Safe to call from several threads at once.
  • insert(row) adds one record and returns its id. insert_many(rows) adds a batch. Ids count up from 0 in the order records were added and are never reused.
  • delete(id) removes one record. delete_many(ids) removes a batch in one go. Both report what was actually removed; deleting an id that is not there is not an error.
  • consolidate() merges the internal trees into one. After a run of inserts, merging brings query speed back to full. You can keep inserting after a merge.
  • drift() tells you how far the covariance from fit has drifted from the most recent cov_window records. 0.0 means the covariance still fits. refit() estimates the covariance again from the most recent cov_window records and rebuilds the index. Set refit_threshold=0.35 to make refit() run automatically. Automatic refit is off by default because a rebuild pauses the stream, about 1.8 seconds at 2 million points.
  • len(idx) is the number of live records.

Constructor options: cov=None (covariance; estimated when None), reg=1e-9 (ridge, relative to the data scale), shrinkage="auto" (Ledoit-Wolf shrinkage, only when there are fewer than 5d rows), dynamic=True (False gives a plain static index), size_ratio=32 and micro_size=8 (tree layout), cov_window=100_000, refit_threshold=None, check_every=10_000.

What you can count on: distances match a scipy.spatial.cKDTree exactly after any mix of inserts and deletes. Which of two records at the same distance comes first is not guaranteed. One thread can write while any number of threads read.

DynamicKDTree is the plain Euclidean index underneath. You can use it directly if your data is already whitened. Full signatures for both classes are in docs/api.md.

Reference

To cite whitetree:

@software{choi2026whitetree,
  title  = {whitetree: exact Mahalanobis nearest-neighbour search with insert and delete},
  author = {Juhyuk Choi},
  year   = {2026},
  url    = {https://github.com/whitetree-dev/whitetree}
}

None of the parts of whitetree are new. Whitening with a Cholesky factor is textbook. The covariance shrinkage is from Ledoit and Wolf (2004), A well-conditioned estimator for large-dimensional covariance matrices. The tree layout is the logarithmic method from Bentley and Saxe (1980), Decomposable searching problems I, with level sizes set the way LSM trees do it. The benchmarks follow ann-benchmarks and the big-ann-benchmarks streaming track.

License

MIT

Metadata

Release files for whitetree 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for whitetree 0.1.1
File Size Uploaded
whitetree-0.1.1.tar.gz 29.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for whitetree 0.1.1
File Interpreter ABI Platform
whitetree-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 46.3 kB

Release files / whitetree-0.1.1.tar.gz

Download URL whitetree-0.1.1.tar.gz
Size 29.4 kB
Tags Source
SHA-256 checksum
How to use checksums
6277a4e32c7bc122a1436bcfc6e1179bc2b6c38eda199b27f6189c40551da5e5
BLAKE2b-256 checksum
How to use checksums
c44ce9aa8243ded4b5d9c52b29734ce5bdfaba3a313b7ab9b320c7804e7dbb4c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release files / whitetree-0.1.1-py3-none-any.whl

Download URL whitetree-0.1.1-py3-none-any.whl
Size 17.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e3f59a539c0eed898bd93f706876409f296f41382cb24c759ae798c47c523771
BLAKE2b-256 checksum
How to use checksums
75cfd048aae1cab92287368191a173ad0260a0e01863a59d32c42a2fbdc99337
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page