NBLAST¶
See Concepts › NBLAST for how the scoring works and what the Rust pipeline does under the hood. This page covers the Python API.
A "dotprop" is any object exposing points (an (N, 3) array of coordinates)
and vect (an (N, 3) array of unit tangent vectors). When use_alpha is
enabled it must additionally expose alpha (an (N,) array).
Building dotprops¶
dotprops derives vect and alpha from a bare point
cloud, so nothing outside fastcore is needed to get to a scoreable neuron:
import navis_fastcore as fastcore
dp = fastcore.Dotprop.from_points(points, k=20) # or:
vect, alpha = fastcore.dotprops(points, k=20)
k counts the point itself, matching scipy.spatial.cKDTree.query and what navis
does. This replaces the usual cKDTree.query plus N 3x3 SVDs — a single-threaded
step that on 100,000 points takes ~510 ms against ~33 ms here, and which was the only
reason navis-fastcore would have needed scipy at all.
The sign of each tangent vector is arbitrary (NBLAST scores on |dot|) but
deterministic. Degenerate neighbourhoods — coincident points — give alpha = 0 and a
unit vector rather than the NaNs that would silently poison every score they touch.
navis_fastcore.dotprops(points, k=20, threads=None)
¶
Compute tangent vectors and alpha values for a point cloud.
For each point, takes its k nearest neighbours (the point itself included),
forms the scatter matrix of that neighbourhood about its centroid, and returns
the principal direction plus a measure of how elongated the neighbourhood is.
Together with the points themselves those are the dotprops representation
:func:~navis_fastcore.nblast consumes - see
:meth:navis_fastcore.Dotprop.from_points for the one-call form.
This replaces the usual scipy.spatial.cKDTree.query plus N 3x3 SVDs,
which is single-threaded and was the only reason navis-fastcore would have
needed scipy at all.
| PARAMETER | DESCRIPTION |
|---|---|
points
|
TYPE:
|
k
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
vect
|
Principal direction of each neighbourhood, unit length.
TYPE:
|
alpha
|
TYPE:
|
Notes
The sign of each vector is arbitrary - an eigenvector is only defined up to
sign, and NBLAST scores on |dot| so it never observes the choice. It is
nonetheless deterministic here (the largest-magnitude component is forced
positive), so results are reproducible run to run and across thread counts.
Degenerate neighbourhoods - all-coincident points, or a single point - have
no defined direction and an alpha of 0/0. They come back as
alpha = 0 with the unit vector [1, 0, 0], rather than the NaNs that
would silently poison every downstream NBLAST score.
Examples:
Points on a line are perfectly anisotropic:
All-by-all¶
import navis_fastcore as fastcore
import numpy as np
from collections import namedtuple
Dotprop = namedtuple("Dotprop", ["points", "vect"])
# 10 example dotprops (use real tangent vectors in practice)
dps = [Dotprop(np.random.rand(100, 3), np.random.rand(100, 3)) for _ in range(10)]
# (10, 10) float32 matrix; row = query, column = target, diagonal = 1.0
scores = fastcore.nblast_allbyall(dps)
# Symmetric scores (also 'min' / 'max')
scores_sym = fastcore.nblast_allbyall(dps, symmetry="mean")
Query vs target¶
# (len(query), len(target)) score matrix
scores = fastcore.nblast(query_dps, target_dps)
# 'mean' / 'min' / 'max' also combine with the reverse (target-vs-query) NBLAST
scores = fastcore.nblast(query_dps, target_dps, symmetry="mean")
Selected pairs only¶
When you already know which comparisons you want — a candidate list from a cheaper
filter, the non-zero cells of a connectivity matrix, a set of putative matches to
verify — nblast_pairs scores exactly those and hands back one value per pair
instead of a matrix:
pairs = np.array([(0, 5), (0, 7), (3, 5)]) # positions, not IDs
scores = fastcore.nblast_pairs(query_dps, target_dps, pairs)
k pairs cost k comparisons rather than n_query x n_target. Each neuron is
still indexed once and the pairs are grouped by target, so a target whose whole
column you request reproduces that column of nblast exactly — this is the same
primitive Smart NBLAST uses for its second pass. Pass
target=None to compare a set against itself without preparing it twice.
k nearest neighbours¶
nblast_knn returns each neuron's k nearest neighbours without ever building
the score matrix — the usual reason being a UMAP embedding, which needs a k-NN
graph rather than every pairwise score. See
Concepts › NBLAST for why the shortlist it uses is sound
rather than merely plausible.
# (n, 20) neighbour indices and their exact NBLAST scores
idx, scores = fastcore.nblast_knn(dotprops, k=20)
# straight into UMAP: it wants distances, not similarities
import umap
emb = umap.UMAP(precomputed_knn=(idx, 1.0 - scores)).fit_transform(
np.zeros((len(dotprops), 2))
)
# query vs target: `idx` then indexes `target`
idx, scores = fastcore.nblast_knn(query_dps, target=target_dps, k=5)
Only which neurons make the shortlist is approximate; every returned score is an
exact NBLAST value. On 163,976 real neurons at the default n_candidates=200,
recall@20 was 0.99 while scoring 0.16% of the pairs — about 5 minutes against an
estimated 35 hours, and 39 MB against 107 GB.
Beyond the shared options below, it takes:
k(default20): neighbours per neuron. Rows with fewer available neighbours are padded with-1inidxand-infinscores.target(defaultNone): search among these instead of amongdotprops, makingidxindex them. Note the saving is on pair scoring, so it grows with the size of the query set — for a handful of queries against a large library both paths pay the same target-indexing cost and plainnblastis simpler.symmetry(default"mean"): unlike the matrix functions this defaults to symmetrising, because the combine has to happen before the top-kcut — once onlykneighbours per row survive there is no transpose left to symmetrise against. See the concepts page.n_candidates(default200): shortlist size, and the one recall/cost knob. Measured recall@20 on 163,976 neurons: 0.91 at 50, 0.97 at 100, 0.99 at 200, 0.996 at 400.voxel(default20.0),n_dirs(default3),splat(defaultTrue): signature resolution. 10–20 µm voxels measured equivalently;splatis worth about 0.05 recall@20.
Measuring recall against a ground truth
Compare neighbour sets, not positions. An order-sensitive
(idx == truth).mean() counts a swap between two near-tied neighbours as two
misses and will read several points lower than the set recall — on real data
0.957 against 0.988 for the same result. UMAP consumes the set and the
distances, and is invariant to ordering within a row.
Smart NBLAST¶
nblast_smart is a two-pass approximation for large comparisons. It first runs a
cheap "pre-NBLAST" on downsampled dotprops, then keeps only the best-scoring
targets per query and recomputes those pairs at full resolution. Unselected cells
keep their coarse pre-pass score. This mirrors navis' nblast_smart (its scores
argument is spelled symmetry here, matching the other functions).
# Keep the top 10% of targets per query (percentile 90) for the full pass
scores = fastcore.nblast_smart(query_dps, target_dps, t=90, criterion="percentile")
# All-by-all; also return the boolean mask of cells recomputed at full resolution
scores, mask = fastcore.nblast_smart(dps, t=90, return_mask=True)
# Other selection criteria: an absolute score threshold, or a fixed number per query
scores = fastcore.nblast_smart(dps, t=0.3, criterion="score")
scores = fastcore.nblast_smart(dps, t=10, criterion="N")
Extra options beyond the shared ones below: t / criterion select the candidate
targets ("percentile", "score" or "N"), downsample (default 10) sets the
pre-pass point stride, and return_mask additionally returns which cells were
recomputed. Because fastcore's dense NBLAST is already fast, nblast_smart pays off
mainly for large all-by-all comparisons where the full-resolution scoring
dominates; on small inputs the extra pre-pass can make it a wash.
syNBLAST (synapse-based)¶
synblast compares neurons by their synapses (connectors) instead of their
skeleton points: for every query connector it finds the nearest target connector
of the same type and scores that distance through the same lookup matrix with the
dot product fixed at 1 (synapses carry no tangent vector). This mirrors navis'
synblast (its scores argument is spelled symmetry here).
A "synapse cloud" is any object exposing connectors — an (N, 3) or (N, 4)
array of [x, y, z, (type)], where the optional 4th column is a numeric connector
type (e.g. 0 = presynapse, 1 = postsynapse). The Synapses namedtuple is a
minimal container for one.
import navis_fastcore as fastcore
from navis_fastcore import Synapses
import numpy as np
# 5 neurons; each connector is [x, y, z, type] with type in {0, 1}
neurons = [
Synapses(np.hstack([np.random.rand(200, 3) * 10, np.random.randint(0, 2, (200, 1))]))
for _ in range(5)
]
# (5, 5) all-by-all matrix; diagonal = 1.0
scores = fastcore.synblast(neurons)
# Only compare like-typed synapses (pre-vs-pre, post-vs-post)
scores = fastcore.synblast(neurons, by_type=True)
# Query vs target, symmetric, restricted to presynapses
scores = fastcore.synblast(neurons[:2], neurons, symmetry="mean", cn_types=[0])
synblast shares smat, normalize, symmetry, n_cores, precision and
progress with nblast (see below), plus two synapse-specific options: by_type
(default False) restricts matches to same-type connectors, and cn_types keeps
only connectors whose type is in the given set before scoring. It does not take
use_alpha or limit_dist (neither applies to synapses).
Options¶
Both nblast_allbyall and nblast accept the same options:
smat: the scoring matrix.None(default) uses the embedded FCWB matrix. You may also pass a navisLookup2d, or a(values, dist_edges, dot_edges)tuple where the edges are the ascending left bin boundaries.normalize(defaultTrue): divide each score by the query's self-hit so a perfect self-match scores 1.0.symmetry(defaultNone):None/"forward"returns the raw forward (asymmetric) matrix;"mean","min"or"max"combine it with the reverse direction (its transpose fornblast_allbyall, an explicit reverse NBLAST for the rectangularnblast). Symmetrising is done in place and allocates nothing — the numpy spelling(M + M.T) / 2would build two fulln x ntemporaries, and evennp.add(M, M.T, out=M)still builds one, since numpy sees the output overlappingM.Tand defensively copies. At 100k neurons that is 80 GB of peak that no longer exists.use_alpha(defaultFalse): weight each point's dot product bysqrt(alpha_query * alpha_target), emphasising locally linear (backbone) regions. Requires each dotprop to expose a per-pointalpha. With no explicitsmat, this auto-selects the alpha-calibrated FCWB matrix (as navis does); an explicitsmatis used as given.limit_dist(defaultNone): a distance upper bound. A query point whose nearest neighbour is farther than this is scored at the matrix's "far + orthogonal" corner (aannprunes such searches). Pass a number, or"auto"for1.05 ×the last distance-bin edge (as in navis).n_cores(defaultNone): cap the number of worker threads.Noneuses all available cores.precision(default32): dtype of the returned matrix —16,32or64(or"half"/"single"/"double"). The scoring math always runs in float64;precisiononly sets the storage width of the result.progress(defaultFalse): show a progress bar over the scoring pairs (drawn from Rust to stderr; plain reprinted text under Jupyter).
API¶
nblast(query, target, smat=None, normalize=True, symmetry=None, use_alpha=False, limit_dist=None, n_cores=None, precision=32, progress=False)
¶
NBLAST every query neuron against every target neuron.
A long run can be interrupted with Ctrl-C / the Jupyter interrupt button;
it stops promptly and raises KeyboardInterrupt.
| PARAMETER | DESCRIPTION |
|---|---|
query
|
TYPE:
|
target
|
TYPE:
|
smat
|
TYPE:
|
normalize
|
TYPE:
|
symmetry
|
TYPE:
|
use_alpha
|
TYPE:
|
limit_dist
|
TYPE:
|
n_cores
|
TYPE:
|
precision
|
TYPE:
|
progress
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
np.ndarray
|
(n_query, n_target) score matrix; row = query, column = target. |
navis_fastcore.nblast_allbyall(dotprops, smat=None, normalize=True, symmetry=None, use_alpha=False, limit_dist=None, n_cores=None, precision=32, progress=False)
¶
All-by-all NBLAST.
A long run can be interrupted with Ctrl-C / the Jupyter interrupt button;
it stops promptly and raises KeyboardInterrupt.
| PARAMETER | DESCRIPTION |
|---|---|
dotprops
|
TYPE:
|
smat
|
TYPE:
|
normalize
|
TYPE:
|
symmetry
|
TYPE:
|
use_alpha
|
TYPE:
|
limit_dist
|
TYPE:
|
n_cores
|
TYPE:
|
precision
|
TYPE:
|
progress
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
np.ndarray
|
(n, n) score matrix; row = query, column = target. |
navis_fastcore.nblast_pairs(query, target, pairs, smat=None, normalize=True, symmetry=None, use_alpha=False, limit_dist=None, n_cores=None, precision=32, progress=False)
¶
NBLAST only the given (query, target) pairs.
The sparse counterpart to :func:~navis_fastcore.nblast: scoring k pairs
costs k comparisons rather than the full n_query x n_target grid, and the
result is a k-vector rather than a matrix. Use it when you already know which
comparisons you want — a candidate list from a cheaper filter, the non-zero
cells of a connectivity matrix, or a set of putative matches to verify.
Each neuron is still indexed once and pairs are grouped by target, so a target
whose whole query column is requested reproduces that column of
:func:~navis_fastcore.nblast exactly.
A long run can be interrupted with Ctrl-C / the Jupyter interrupt button; it
stops promptly and raises KeyboardInterrupt.
| PARAMETER | DESCRIPTION |
|---|---|
query
|
TYPE:
|
target
|
TYPE:
|
pairs
|
TYPE:
|
smat
|
TYPE:
|
normalize
|
TYPE:
|
symmetry
|
TYPE:
|
use_alpha
|
TYPE:
|
limit_dist
|
TYPE:
|
n_cores
|
TYPE:
|
precision
|
TYPE:
|
progress
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
scores
|
One score per row of
TYPE:
|
Examples:
Score selected pairs within one set, without preparing it twice:
navis_fastcore.nblast_knn(dotprops, target=None, k=20, symmetry='mean', n_candidates=200, voxel=20.0, n_dirs=3, splat=True, smat=None, normalize=True, use_alpha=False, limit_dist=None, n_cores=None, precision=32, progress=False)
¶
The k nearest neighbours of every neuron, without the n x n matrix.
An all-by-all is the wrong shape for a k-NN question at scale: 164k neurons is 2.7e10 pairs and a 107 GB matrix, when the answer wanted from it is a 26 MB k-NN graph (typically to feed a UMAP embedding). This computes that graph directly, in three stages:
- each neuron becomes a coarse voxel-occupancy signature;
- the
n_candidatesmost similar neurons per row are shortlisted from those signatures; - the exact NBLAST score is computed for the shortlisted pairs only.
The pre-filter is sound because the FCWB scoring matrix has finite support —
beyond its last distance bin (40 um) every cell is ~ -10, so neurons that do
not overlap in space score at that floor regardless of shape. Only which
neurons make the shortlist is approximate; every returned score is an exact
NBLAST value, because a neuron that belongs in the true top-k outranks the
global k-th and so cannot be dropped by the rerank once shortlisted.
Measured on 163,976 zebrafish neurons: recall@20 = 0.990 at the default
n_candidates=200, scoring 0.16% of pairs.
.. note::
Scores agree with :func:`~navis_fastcore.nblast` but are not guaranteed
bit-identical to it. Where a query point is *exactly* equidistant from two
target points the nearest neighbour is ambiguous, and the two functions
can pick different (equally valid) tied tangents, shifting the score in
its last digits. ``aann-graph >= 0.2.1`` resolves such ties to the lowest
vertex index, which makes the two agree in all but a rare case where the
tied points are not adjacent in the neighbourhood graph. Ties are common
on real neurons, whose coordinates sit on a quantised grid, and
essentially absent on continuous synthetic data. Compare the two with a
small tolerance rather than for exact equality.
.. note::
Coordinates are held at the dtype they arrive as, so passing float32
``points`` / ``vect`` builds a float32 spatial index and roughly halves peak
memory — worth doing at this scale, where every neuron's index is alive at
once and they dominate the resident set. Scores shift by ~1e-4 relative
(float32 resolves ~1e-4 at millimetre-scale coordinates, which is four
orders below the finest distance bin, but it can flip a near-tied nearest
neighbour). Both sides of a query/target call must agree; a mix runs at
float64. Do not narrow if you need to reproduce an earlier float64 run.
A long run can be interrupted with Ctrl-C / the Jupyter interrupt button.
| PARAMETER | DESCRIPTION |
|---|---|
dotprops
|
TYPE:
|
target
|
TYPE:
|
k
|
TYPE:
|
symmetry
|
TYPE:
|
n_candidates
|
TYPE:
|
voxel
|
TYPE:
|
n_dirs
|
TYPE:
|
splat
|
TYPE:
|
smat
|
DEFAULT:
|
normalize
|
DEFAULT:
|
use_alpha
|
DEFAULT:
|
limit_dist
|
DEFAULT:
|
n_cores
|
DEFAULT:
|
precision
|
DEFAULT:
|
progress
|
DEFAULT:
|
| RETURNS | DESCRIPTION |
|---|---|
idx
|
(n_query, k) int64 neighbour indices, descending by score —
into |
scores
|
(n_query, k) NBLAST similarities aligned to |
Examples:
>>> idx, scores = nblast_knn(dotprops, k=20)
>>> reducer = umap.UMAP(precomputed_knn=(idx, 1.0 - scores))
Search a small query set against a large reference set:
navis_fastcore.nblast_smart(query, target=None, t=90, criterion='percentile', downsample=10, smat=None, normalize=True, symmetry=None, use_alpha=False, limit_dist=None, n_cores=None, precision=32, progress=False, return_mask=False)
¶
Smart(er) NBLAST: a fast two-pass approximation of an all-by-all / query-target.
A cheap "pre-NBLAST" is run on downsampled dotprops; for each query the
best-scoring targets are then kept (per criterion) and the full-resolution
NBLAST is computed only for those query-target pairs. Unselected cells keep their
coarse pre-pass score. This matches navis' nblast_smart (its scores
argument is spelled symmetry here, as in nblast/nblast_allbyall).
A long run can be interrupted with Ctrl-C / the Jupyter interrupt button
(during either pass); it stops promptly and raises KeyboardInterrupt.
| PARAMETER | DESCRIPTION |
|---|---|
query
|
TYPE:
|
target
|
TYPE:
|
t
|
TYPE:
|
criterion
|
TYPE:
|
downsample
|
TYPE:
|
smat
|
DEFAULT:
|
normalize
|
DEFAULT:
|
symmetry
|
DEFAULT:
|
use_alpha
|
DEFAULT:
|
limit_dist
|
DEFAULT:
|
n_cores
|
DEFAULT:
|
precision
|
DEFAULT:
|
progress
|
DEFAULT:
|
return_mask
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
np.ndarray | (np.ndarray, np.ndarray)
|
The (n_query, n_target) score matrix, or |
navis_fastcore.synblast(query, target=None, by_type=False, cn_types=None, smat=None, normalize=True, symmetry=None, n_cores=None, precision=32, progress=False)
¶
Synapse-based NBLAST (syNBLAST).
Compares neurons by their synapses (connectors) rather than their skeleton
points: for every query connector the nearest target connector of the same
type is found, and the euclidean distance is scored through the NBLAST lookup
matrix with the dot product fixed at 1 (synapses have no tangent vector). This
matches navis' synblast (navis' scores argument is spelled symmetry
here, as in nblast / nblast_allbyall).
A long run can be interrupted with Ctrl-C / the Jupyter interrupt button;
it stops promptly and raises KeyboardInterrupt.
| PARAMETER | DESCRIPTION |
|---|---|
query
|
TYPE:
|
target
|
TYPE:
|
by_type
|
TYPE:
|
cn_types
|
TYPE:
|
smat
|
TYPE:
|
normalize
|
TYPE:
|
symmetry
|
TYPE:
|
n_cores
|
TYPE:
|
precision
|
TYPE:
|
progress
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
np.ndarray
|
(n_query, n_target) score matrix; row = query, column = target. |
navis_fastcore.Synapses = namedtuple('Synapses', ['connectors'])
module-attribute
¶
Extracting matches¶
Once you have a score matrix, pulling the best matches back out of it is its own problem —
the matrices are big (a few 100k on a side is tens of GB), so the extraction must not copy
or transpose them. Three criteria, all reading the matrix at its native width (float16,
float32 or float64) and returning plain numpy arrays:
import numpy as np
import navis_fastcore as fastcore
scores = fastcore.nblast_allbyall(dps) # (n, n) float32
# The 5 best targets per query, best first. `skip_self` drops the diagonal, which
# would otherwise be every neuron's own top hit.
indices, values = fastcore.top_matches(scores, 5, skip_self=True)
# Everything above an absolute cutoff, or within 5% of each query's own best match.
# Ragged, so returned CSR-style: query `q` owns indices[offsets[q]:offsets[q + 1]].
offsets, indices, values = fastcore.matches_above(scores, threshold=0.5)
offsets, indices, values = fastcore.matches_above(scores, percentage=0.05)
Expand the ragged result into a long-format table without a Python loop:
counts = np.diff(offsets)
query = np.repeat(np.arange(len(counts)), counts)
rank = np.arange(len(indices)) - np.repeat(offsets[:-1], counts)
Notes:
axis=1gives matches per target instead of per query. It costs no more thanaxis=0and does not transpose: the kernel walks column stripes of the row-major buffer, so the matrix is read exactly once either way. Passing a transposed view (scores.T) is likewise free.- The matrix is borrowed, never copied — including a
np.memmap. Nothing here will cast your dtype behind your back, so a strided view or an unsupported dtype raises rather than silently materialising tens of GB. distances=Trueif lower is better.NaNis never a match. A query with no valid scores yields-1/NaNslots (top_matches) or an empty group (matches_above).- Ties break toward the lower index, so results don't depend on the thread count.
matches_abovecounts before it allocates, somax_matchescan refuse an over-broad cutoff instead of exhausting the machine. Usecount_matchesto size a result first.
navis_fastcore.top_matches(scores, n, axis=0, distances=False, skip_self=False, n_cores=None, progress=False)
¶
Extract the n best matches for each query (or target).
The matrix is neither copied nor transposed, whichever axis you ask for - matches
along axis=1 are served by striding the row-major buffer in column blocks, so it is
read exactly once either way. That matters: these matrices run to tens of GB.
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
n
|
TYPE:
|
axis
|
TYPE:
|
distances
|
TYPE:
|
skip_self
|
TYPE:
|
n_cores
|
TYPE:
|
progress
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
indices
|
Index along the other axis, best match first. -1 where the group had
fewer than
TYPE:
|
values
|
The matching scores, in the dtype of
TYPE:
|
Examples:
>>> import numpy as np
>>> import navis_fastcore as fastcore
>>> scores = np.array([[1.0, 0.2, 0.9],
... [0.2, 1.0, 0.4],
... [0.9, 0.4, 1.0]], dtype=np.float32)
>>> idx, val = fastcore.top_matches(scores, 2)
>>> idx
array([[0, 2],
[1, 2],
[2, 0]])
Ignore the self-hits on the diagonal:
navis_fastcore.matches_above(scores, threshold=None, percentage=None, axis=0, distances=False, skip_self=False, max_matches=None, n_cores=None, progress=False)
¶
Extract every match clearing a cutoff. Ragged, so returned CSR-style.
Give exactly one of:
threshold- an absolute cutoff: keep every cell>= threshold(<=ifdistances).percentage- a band around each group's own best value:percentage=0.05keeps everything within 5% of that group's top match. Note this is "within X% of the best", not "the top X%".
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
threshold
|
TYPE:
|
percentage
|
TYPE:
|
axis
|
TYPE:
|
distances
|
TYPE:
|
skip_self
|
TYPE:
|
max_matches
|
TYPE:
|
n_cores
|
TYPE:
|
progress
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
offsets
|
Group
TYPE:
|
indices
|
Index along the other axis, best first within each group. Same
quantity, and same dtype, as
TYPE:
|
values
|
The matching scores, in the dtype of
TYPE:
|
Examples:
>>> import numpy as np
>>> import navis_fastcore as fastcore
>>> scores = np.array([[1.0, 0.2, 0.9],
... [0.2, 1.0, 0.4],
... [0.9, 0.4, 1.0]], dtype=np.float32)
>>> offsets, indices, values = fastcore.matches_above(scores, threshold=0.5)
>>> offsets
array([0, 2, 3, 5])
>>> indices[offsets[0]:offsets[1]] # query 0's matches, best first
array([0, 2])
Expand to a long-format table without a Python loop:
navis_fastcore.count_matches(scores, threshold=None, percentage=None, axis=0, distances=False, skip_self=False, n_cores=None)
¶
Count the matches each group would yield, without materialising them.
The counting half of matches_above on its own. Use it
to size a result - or to pick a cutoff - on a matrix you cannot afford to guess wrong
about.
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
threshold
|
TYPE:
|
percentage
|
TYPE:
|
axis
|
TYPE:
|
distances
|
TYPE:
|
skip_self
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
counts
|
TYPE:
|
Examples:
Clustering¶
The other thing you do with a score matrix is cluster it. The textbook route —
symmetrise, convert similarity to distance, condense, then
scipy.cluster.hierarchy.linkage — allocates another n x n array at almost every step,
and at 100k neurons that, not the clustering, is what exhausts the machine:
# What this replaces. Each line materialises a fresh n x n array.
m = (scores + scores.T) / 2
m = 1 - m
Z = linkage(squareform(m, checks=False), method="ward")
fastcore.linkage fuses the first three steps into a single pass that writes the
condensed distance vector directly, then clusters that buffer in place:
import navis_fastcore as fastcore
from scipy.cluster.hierarchy import fcluster, dendrogram
scores = fastcore.nblast_allbyall(dps) # (n, n) float32
# Symmetrise, 1 - score, condense and cluster - no n x n temporary anywhere.
Z = fastcore.linkage(scores, method="ward")
# Z is a SciPy linkage matrix, so the rest of the ecosystem just works.
labels = fcluster(Z, 10, criterion="maxclust")
The condensed distances are available on their own if you want them:
cond = fastcore.condensed_distances(scores, symmetry="mean", transform="one_minus")
Z = fastcore.linkage(cond, method="average", copy=False) # clusters in place
Notes:
float32staysfloat32.scipy.cluster.hierarchy.linkageup-casts its input tofloat64unconditionally, so handing it afloat32matrix to save memory instead costs you a second, doubled copy of the condensed matrix — plus abooltemporary the size of the input for its finiteness check. Here the condensed matrix is the only allocation, and the finiteness check rides along on the fused pass for free.- Measured on a 14-core machine at
n = 40,000,method="ward",float32input: 38.2 s / 12.5 GB peak for the numpy+SciPy pipeline versus 10.8 s / 9.6 GB here. The output is the same dendrogram. Zmatches SciPy's layout exactly —(n-1, 4),float64, singletons labelled0..nand the cluster formed at stepilabelledn + i, rows ordered by increasing distance — sofcluster,dendrogramandcut_treeall take it directly.symmetrymirrorsnblast_allbyall's: use"none"if the matrix is already symmetric, which is also the fastest path since it reads the buffer strictly sequentially.- Clustering consumes its input as scratch. From a square matrix that never matters (the
buffer is its own); from a condensed vector,
copy=Falseclusters in place and halves peak memory at the cost of your array. - The linkage itself is single-threaded and cannot be interrupted —
Ctrl-Cis honoured up to the end of the condensing pass only.
leaf_order is SciPy's leaves_list, for callers who would rather not add scipy just to
draw the thing:
order = fastcore.leaf_order(Z) # (n,) observation indices, left to right
labels[order] # your labels, in drawing order
symmetrize is the standalone version of the combine linkage and
condensed_distances fold into their own pass. You do not need it before either of them
— reach for it when something else has to read the matrix, and note that it rewrites
your array in place rather than handing back a copy:
navis_fastcore.linkage
¶
Hierarchical clustering of a score matrix (e.g. NBLAST output).
condensed_distances(scores, symmetry='mean', transform='one_minus', n_cores=None)
¶
Turn a square score matrix into a condensed distance vector.
Symmetrisation, the similarity-to-distance transform and the condensing all
happen in a single fused pass, so the only allocation is the n(n-1)/2
output itself. The numpy equivalent::
cond = squareform(1 - (M + M.T) / 2, checks=False)
materialises three more n x n arrays on the way, which at 100k neurons is
where the memory actually goes.
The diagonal is never read, so a matrix carrying self-scores rather than zeros needs no fixing up first.
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
symmetry
|
TYPE:
|
transform
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
condensed
|
Upper triangle in row-major order, i.e. the layout
TYPE:
|
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> scores = np.array([[1.00, 0.75],
... [0.25, 1.00]], dtype=np.float32)
>>> fastcore.condensed_distances(scores)
array([0.5], dtype=float32)
Already-symmetric distances can be passed straight through:
leaf_order(Z)
¶
The order to place the leaves in so a dendrogram draws without crossings.
The equivalent of scipy.cluster.hierarchy.leaves_list, for callers who have
no scipy: a depth-first walk of the merge tree from the root, emitting each
observation as it is reached. Iterative, so a 200k-observation chain does not
need 200k stack frames.
| PARAMETER | DESCRIPTION |
|---|---|
Z
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
order
|
Observation indices, left to right. A permutation of
TYPE:
|
Notes
Any consumer that draws the tree must use the same child order as the linkage
matrix it came from, which is why this reads Z rather than a pre-built tree.
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> scores = np.array([[1.00, 0.90, 0.20, 0.10],
... [0.70, 1.00, 0.30, 0.15],
... [0.25, 0.35, 1.00, 0.80],
... [0.05, 0.10, 0.60, 1.00]], dtype=np.float32)
>>> Z = fastcore.linkage(scores, method="average")
>>> fastcore.leaf_order(Z)
array([0, 1, 2, 3])
linkage(x, method='ward', symmetry='mean', transform='one_minus', copy=True, n_cores=None)
¶
Hierarchical clustering, returning a SciPy-compatible linkage matrix.
Accepts either a square score matrix — in which case the whole pipeline
(symmetrise, convert to distance, condense, cluster) runs fused, with no
n x n temporary ever materialised — or an existing condensed distance
vector.
The result is interchangeable with scipy.cluster.hierarchy.linkage output
and can be handed straight to fcluster, dendrogram or cut_tree.
Two things make this cheaper than the SciPy pipeline at scale:
- float32 stays float32.
scipy.cluster.hierarchy.linkageup-casts its input to float64 unconditionally, so handing it a float32 matrix to save memory instead costs you a second, doubled copy of it. - Nothing is copied. The condensed matrix is clustered in place.
| PARAMETER | DESCRIPTION |
|---|---|
x
|
TYPE:
|
method
|
TYPE:
|
symmetry
|
TYPE:
|
transform
|
TYPE:
|
copy
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
Z
|
One merge per row as
TYPE:
|
Notes
Clustering cannot be interrupted once it starts: it exposes no per-merge hook,
so Ctrl-C is only honoured up to the end of the condensing pass. At 100k
observations the linkage is the part that takes minutes.
On float32 input the merge heights match a float64 run to about 1e-7 relative. Where distances are very nearly tied, that rounding can also swap the order of two otherwise-equivalent merges; on structured data (what a real NBLAST matrix looks like) this does not happen, but on near-degenerate input it affects a small fraction of merges. Pass float64 if you need merge order to be reproducible against SciPy down to the last tie.
Examples:
Two obvious pairs, from a score matrix:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> scores = np.array([[1.00, 0.90, 0.20, 0.10],
... [0.70, 1.00, 0.30, 0.15],
... [0.25, 0.35, 1.00, 0.80],
... [0.05, 0.10, 0.60, 1.00]], dtype=np.float32)
>>> Z = fastcore.linkage(scores, method="average")
>>> Z[:, :2].astype(int)
array([[0, 1],
[2, 3],
[4, 5]])
Cut it into two clusters, using SciPy as usual:
>>> from scipy.cluster.hierarchy import fcluster
>>> fcluster(Z, 2, criterion="maxclust")
array([1, 1, 2, 2], dtype=int32)
From an existing condensed distance vector:
symmetrize(scores, symmetry='mean', n_cores=None)
¶
Symmetrise a square score matrix against its own transpose, in place.
This is the case numpy cannot do cheaply. (M + M.T) / 2 builds two full
n x n temporaries, and even np.add(M, M.T, out=M) still builds one,
because numpy sees the output overlapping M.T and defensively copies the
input first. The kernel here writes both triangles as it walks the upper one,
so it allocates nothing — at 100k neurons that is 80 GB of peak that stops
existing.
You do not need this before :func:~navis_fastcore.linkage or
:func:~navis_fastcore.condensed_distances: both take a symmetry argument
and fold the same combine into their own pass. Use it when something else is
going to read the matrix — plotting it, writing it out, or feeding it to a
library that assumes symmetry.
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
symmetry
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
scores
|
original name refers to the symmetrised matrix either way.
TYPE:
|
Examples:
navis_fastcore.condensed_distances(scores, symmetry='mean', transform='one_minus', n_cores=None)
¶
Turn a square score matrix into a condensed distance vector.
Symmetrisation, the similarity-to-distance transform and the condensing all
happen in a single fused pass, so the only allocation is the n(n-1)/2
output itself. The numpy equivalent::
cond = squareform(1 - (M + M.T) / 2, checks=False)
materialises three more n x n arrays on the way, which at 100k neurons is
where the memory actually goes.
The diagonal is never read, so a matrix carrying self-scores rather than zeros needs no fixing up first.
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
symmetry
|
TYPE:
|
transform
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
condensed
|
Upper triangle in row-major order, i.e. the layout
TYPE:
|
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> scores = np.array([[1.00, 0.75],
... [0.25, 1.00]], dtype=np.float32)
>>> fastcore.condensed_distances(scores)
array([0.5], dtype=float32)
Already-symmetric distances can be passed straight through:
navis_fastcore.leaf_order(Z)
¶
The order to place the leaves in so a dendrogram draws without crossings.
The equivalent of scipy.cluster.hierarchy.leaves_list, for callers who have
no scipy: a depth-first walk of the merge tree from the root, emitting each
observation as it is reached. Iterative, so a 200k-observation chain does not
need 200k stack frames.
| PARAMETER | DESCRIPTION |
|---|---|
Z
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
order
|
Observation indices, left to right. A permutation of
TYPE:
|
Notes
Any consumer that draws the tree must use the same child order as the linkage
matrix it came from, which is why this reads Z rather than a pre-built tree.
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> scores = np.array([[1.00, 0.90, 0.20, 0.10],
... [0.70, 1.00, 0.30, 0.15],
... [0.25, 0.35, 1.00, 0.80],
... [0.05, 0.10, 0.60, 1.00]], dtype=np.float32)
>>> Z = fastcore.linkage(scores, method="average")
>>> fastcore.leaf_order(Z)
array([0, 1, 2, 3])
navis_fastcore.symmetrize(scores, symmetry='mean', n_cores=None)
¶
Symmetrise a square score matrix against its own transpose, in place.
This is the case numpy cannot do cheaply. (M + M.T) / 2 builds two full
n x n temporaries, and even np.add(M, M.T, out=M) still builds one,
because numpy sees the output overlapping M.T and defensively copies the
input first. The kernel here writes both triangles as it walks the upper one,
so it allocates nothing — at 100k neurons that is 80 GB of peak that stops
existing.
You do not need this before :func:~navis_fastcore.linkage or
:func:~navis_fastcore.condensed_distances: both take a symmetry argument
and fold the same combine into their own pass. Use it when something else is
going to read the matrix — plotting it, writing it out, or feeding it to a
library that assumes symmetry.
| PARAMETER | DESCRIPTION |
|---|---|
scores
|
TYPE:
|
symmetry
|
TYPE:
|
n_cores
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
scores
|
original name refers to the symmetrised matrix either way.
TYPE:
|
Examples: