Kernel Design#
The Q-statistic measures the strength of spatial dependence in a feature, but the kernel decides which spatial patterns count as “dependent”. Smooth large-scale gradients (low-frequency), sharp boundaries between neighbouring spots (high-frequency), and graph-defined neighbourhoods are all valid choices, and they are captured by different kernels. Two genes can swap which one scores higher just by swapping the kernel.
This page is the practical guide. For the underlying math, see Theoretical Results (Theorems 1-2 and the CAR derivation).
Default recommendations#
The choice splits along two axes: what pattern you are looking for (smooth gradient or sharp local variation) and how the data sits in space (irregular coordinates, graph, or regular grid).
Data layout |
Pattern of interest |
Pick |
|---|---|---|
Coordinate cloud |
Smooth, large-scale gradient |
|
Coordinate cloud (graph-defined) |
Smooth, large-scale gradient |
|
Coordinate cloud (graph-defined) |
Sharp variation between neighbours |
|
Regular rasterised grid (Visium HD, imaging) |
Smooth, large-scale gradient |
|
Small |
Any |
|
If you don’t yet know which pattern type you want, start with CAR or Matérn. They look for smooth gradients and still report calibrated p-values when the true signal is sharp, just with lower power.
Code:
from quadsv import NUFFTKernel, MatrixKernel, FFTKernel
# Irregular coords, smooth pattern (default)
kernel = NUFFTKernel(coords, method="matern", bandwidth=25.0, nu=1.5)
# Irregular coords, graph-flavoured pattern
kernel = MatrixKernel.from_coordinates(
coords, method="car", rho=0.9, k_neighbors=4
)
# Regular rasterised grid (CAR)
kernel = FFTKernel(
shape=(1000, 1000), method="car", rho=0.9, neighbor_degree=1
)
# Regular rasterised grid (Matérn)
kernel = FFTKernel(
shape=(1000, 1000), method="matern", bandwidth=4.0, nu=1.5
)
The backend keyword on DetectorIrregular selects
between NUFFTKernel and
MatrixKernel. See Quick Start.
Picking a method#
Kernel |
What it captures |
Spectral decay |
|---|---|---|
|
Soft, distance-based smooth patterns. |
Exponential in \(|\omega|^2\). Drops mid- and high-frequency structure under the noise floor quickly. |
|
Distance-based smooth patterns with tunable smoothness. |
Polynomial, rate \(-(2\nu + d)\) in \(|\omega|\).
|
|
Graph-based smooth patterns. |
Polynomial, rate \(-2\) in \(|\omega|\) near
the cut-off. |
|
Graph-based local variation: sharp differences between neighbouring spots. |
High-pass; spectrum grows like \(|\omega|^2\) at low frequency. Pairs with CAR. Use when you care about boundaries or textures rather than smooth gradients. |
|
Classical autocorrelation. |
Indefinite spectrum, suffers from spectral cancellation. Avoid for SVG detection. Available for backwards comparison with legacy methods. See Theoretical Results Theorem 2. |
Why positive definiteness matters
The Q-test power for a pattern \(f\) is
where \(\lambda(\omega)\) is the kernel’s spectrum. If some eigenvalues \(\lambda(\omega)\) are negative, contributions from different frequencies cancel inside the sum and the test loses power on composite patterns. A strictly positive spectrum guarantees no cancellation. See Theoretical Results (Theorem 2) for the formal statement and proof. CAR is the smallest modification of Moran’s I that fixes the problem: \(\mathbf{K}_{\text{CAR}} = (\mathbf{I} - \rho \tilde{\mathbf{W}})^{-1}\) has every eigenvalue strictly positive for \(0 < \rho < 1\).
Spectral decay rate#
The kernel spectrum \(\lambda(\omega)\) is a frequency filter: \(Q(f) = \sum_\omega |\hat{f}_\omega|^2 \lambda(\omega)\). How \(\lambda\) falls off at large \(|\omega|\) decides which spatial frequencies the test still has power for.
Two regimes show up across the kernels in this library:
Exponential decay (Gaussian). \(\lambda(\omega) \propto \exp(-\sigma^2 |\omega|^2 / 2)\), where \(\sigma\) is the bandwidth. Power on a frequency \(\omega\) collapses to numerical zero once \(|\omega| \gtrsim 1/\sigma\). Anything finer than that scale is invisible to the test.
Polynomial decay (Matérn, CAR). Power falls off as a fixed power of \(|\omega|\), so even modes well past the cut-off keep some weight. The test loses power gradually rather than abruptly.
For dense rasterised data (Visium HD, imaging), interesting biology often shows up at fine scales, so the polynomial tail of Matérn or CAR usually wins over Gaussian even when the dominant signal is smooth.
How decay rates depend on hyper-parameters:
Kernel |
Spectrum (large \(|\omega|\), dimension \(d\)) |
Hyper-parameters |
|---|---|---|
Gaussian |
\(\lambda(\omega) \propto e^{-\sigma^{2}|\omega|^{2}/2}\) |
|
Matérn |
\(\lambda(\omega) \propto |\omega|^{-(2\nu + d)}\) |
|
CAR (regular grid) |
\(\lambda(\omega) \propto |\omega|^{-2}\) past the cut-off |
|
Graph Laplacian (regular grid) |
\(\lambda(\omega) \propto |\omega|^{2}\) at low \(|\omega|\) |
High-pass with no smoothing knob. |
Where these decay rates come from
On a regular 2-D grid in \(d\) dimensions, all four kernels are diagonalised by the Fourier basis. The spectra come out as:
where \(\mu(\omega)\) is the row-normalised adjacency symbol (e.g. \((\cos\omega_x + \cos\omega_y)/2\) for a 4-NN square grid). Expanding around \(|\omega| = 0\), \(1 - \mu(\omega) \approx \tfrac{1}{4}|\omega|^{2}\), which gives CAR its \(|\omega|^{-2}\) tail and the graph Laplacian its \(|\omega|^{2}\) rise. Matérn’s \(|\omega|^{-(2\nu+d)}\) follows directly from its Bochner-theorem spectral density.
On irregular coordinates the same shapes hold for
NUFFTKernel, with an oversampled auxiliary
grid in place of the data points. See Scalable Computation
for the NUFFT operator definition and its analytic moments.
Tuning the hyper-parameters#
Bandwidth (Gaussian, Matérn). A reasonable starting value is the median pairwise distance:
import numpy as np
from scipy.spatial.distance import pdist
bandwidth0 = float(np.median(pdist(coords)))
kernel = NUFFTKernel(coords, method="matern", bandwidth=bandwidth0, nu=1.5)
ρ (CAR). Larger rho means stronger smoothing.
rho = 0.5: moderate smoothing.rho = 0.9: strong smoothing (the default).rhoclose to1: maximum smoothing. The spectrum becomes very heavy-tailed.
k_neighbors (graph kernels). Larger k means a denser graph
and more global patterns.
kbetween 5 and 10: sparse, local.k = 30or more: dense, global.
If you sweep a small grid of values, look at how \(Q\) and the trace move with the parameter. Neither metric replaces a held-out validation set, but together they highlight runs where the kernel collapses onto a single mode.
Custom kernels#
Two extension points cover almost everything: a custom matrix and a custom subclass.
1. Custom adjacency or distance matrix
import numpy as np
from quadsv.kernels import MatrixKernel
# Build a CAR precision from a custom adjacency
W = ... # (n, n) adjacency
precision = np.eye(W.shape[0]) - 0.9 * W # I - ρW
kernel = MatrixKernel.from_matrix(
precision, method="car", is_precision=True
)
2. Subclass an ABC from quadsv.kernels
Backend authors get two extension points, both in
quadsv.kernels:
quadsv.kernels.Kernelis the universal ABC. Subclass it for any custom kernel that you can express through a singleself._Kbuffer.quadsv.kernels.MatrixKernelBaseis the matrix-family base used byMatrixKernel. Subclass it if you need the dense / sparse / sparse-precision auto-switching machinery for a new matrix backend.
Neither ABC is re-exported from the top-level quadsv
namespace. Always import them through quadsv.kernels.
The Kernel ABC ships default
implementations of Kx(),
xtKx(),
xtKy(),
trace(),
square_trace(), and
eigenvalues() that all read off the
single self._K buffer. Override
_build_kernel() to plug in your own
matrix:
import numpy as np
from quadsv.kernels import Kernel
class MyKernel(Kernel):
def _build_kernel(self):
# Return any (n, n) symmetric PSD matrix.
return self.params["K"]
K = np.eye(100)
kernel = MyKernel(n=100, method="custom", K=K)
Override Kx() only if you have a
faster operator than K @ x. That is what the FFT and NUFFT
backends do.
Subclassing FFTKernel for a custom spectrum
For a regular grid, override
_compute_eigenvalues() to
define a custom spectral filter:
import numpy as np
import scipy.fft
from quadsv.kernels.fft import FFTKernel
class CustomFFTKernel(FFTKernel):
def _compute_eigenvalues(self):
fy = scipy.fft.fftfreq(self.ny, d=self.dy)
fx = scipy.fft.fftfreq(self.nx, d=self.dx)
FY, FX = np.meshgrid(fy, fx, indexing="ij")
r2 = FX ** 2 + FY ** 2
lam = 1.0 / (1.0 + 10.0 * r2) # polynomial low-pass
if self.fft_solver == "rfft2":
lam = lam[:, : (self.nx // 2 + 1)]
return lam.ravel()
Keep \(\lambda(\omega) > 0\) for every frequency to preserve test consistency.
Inspecting a kernel’s spectrum#
Plot eigenvalues to verify positivity
import numpy as np
import matplotlib.pyplot as plt
evals = kernel.eigenvalues(k=100)
print(f"Min eigenvalue: {evals[-1]:.3e}")
print(f"All positive : {np.all(evals > 1e-9)}")
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.semilogy(evals, "o-"); ax1.set_title("log scale")
ax2.plot(evals, "o-"); ax2.axhline(0, color="red", ls="--")
ax2.set_title("linear scale")
plt.tight_layout(); plt.show()
How to read the plot:
All
evals > 0: strictly positive definite, so the test is consistent.Some
evals <= 0: indefinite, so the test risks spectral cancellation.Monotone decay: the kernel emphasises low frequencies.
Power-law decay: a heavy tail at high frequencies (CAR-like).
See also#
Quick Start for practical recipes.
Theoretical Results for derivations and proofs.
Scalable Computation for how kernel choice affects runtime and memory.
quadsv.kernels for the kernel API reference.