Spatial Structure¶
Does a model's point-wise bias have exploitable spatial structure — does the error at one station tell you anything about the error at the next, and over what distance?
The empirical variogram answers this directly. Its two headline numbers are the ones that decide whether a bias field can be interpolated at all:
| quantity | reads as |
|---|---|
| range | the distance beyond which errors are effectively unrelated |
| nugget | the irreducible point-scale component — variance that no amount of interpolation removes |
relative nugget (nugget / sill) |
how much of the field is noise. High means heavy smoothing and gauges that will not be reproduced exactly |
Use the log-ratio, not the raw difference¶
Fit on log(model / obs) rather than model - obs. A multiplicative bias field is usually the
stationary one; the raw difference typically keeps climbing with lag instead of reaching a clean sill,
which means the stationarity assumption underpinning any interpolation does not hold.
fit_bias_variogram reports ch_n and mat_n — the
Cressie–Hawkins and Matheron estimates near the origin — precisely so this is checkable rather than
assumed. A ratio near 1 means the field is well behaved at short range; far from 1 means heavy
tails that the classical estimator will exaggerate.
The nugget is fixed, not fitted¶
fit_exponential_variogram holds the nugget at a
data-derived near-origin estimate and fits only (psill, range).
This is deliberate and worth understanding before changing it. On a typical station network there are too few sub-10 km pairs to anchor the intercept, and a free three-parameter fit collapses the nugget toward zero — which then reads as a smoothly interpolable bias field when the data say no such thing.
Quote a range with its resampling band, or not at all
With few stations the range is poorly identified — worst when the field is nugget-dominated,
where many (range, sill) pairs fit a flattish cloud about equally well.
bootstrap_variogram_params gives the p5/p50/p95
band. A range reported without it is a number with no error bar, and the band is often wide
enough to change the conclusion.
That function takes a seed and builds its own generator rather than accepting one. This is load-bearing: callers typically invoke it once per field, and each call is meant to start from the same stream so results are independently reproducible. Threading a shared generator through instead silently changes every result after the first.
Example¶
import numpy as np
from pyproj import Transformer
from modverif.spatial import fit_bias_variogram, bootstrap_variogram_params
# Coordinates must be in a PROJECTED, metric CRS -- the lags are distances in km.
T = Transformer.from_crs(4326, 2193, always_xy=True) # e.g. NZTM
gx, gy = T.transform(lons, lats)
x_km, y_km = np.asarray(gx) / 1000.0, np.asarray(gy) / 1000.0
fit = fit_bias_variogram(x_km, y_km, np.log(model / obs))
band = bootstrap_variogram_params(x_km, y_km, np.log(model / obs))
if fit['fit_ok']:
print(f"range {fit['range_km']:.0f} km (p5-p95 {band['range_km'][0]:.0f}-{band['range_km'][2]:.0f})")
print(f"nugget effect {100 * fit['rel_nugget']:.0f}%")
API¶
Spatial-structure diagnostics for scattered model-minus-observation error.
The question this answers is whether a model's point-wise bias has exploitable spatial structure: does the error at one station tell you anything about the error at the next one, and over what distance? The empirical variogram is the standard tool, and its fitted range and nugget say directly how far a bias field can be interpolated and how much of it is irreducible point-scale noise.
Intended on the log-ratio log(model / obs) rather than the raw difference: a multiplicative bias
field is usually the stationary one, and comparing the Cressie--Hawkins and Matheron estimators near
the origin is a cheap test of which behaves better (see fit_bias_variogram's ch_n /
mat_n).
The fitted length scale is called range_km throughout -- parameter and result key alike. Nothing
here named rng is anything but a numpy.random.Generator.
best_kmeans(xy, k, rng, n_restart=20)
¶
k-means on point coordinates, keeping the lowest-inertia labelling over several restarts.
k-means converges to a local optimum that depends on initialisation, so a single run is a coin toss dressed as an answer. Restarts make the labelling reproducible in practice rather than only in principle.
NOTE: The restart seeds are drawn in one vectorised call. Replacing that with per-restart scalar draws consumes the caller's generator differently and changes every downstream result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
xy
|
ndarray
|
|
required |
k
|
int
|
Number of clusters. |
required |
rng
|
Generator
|
Caller's generator, used only to seed the restarts. |
required |
n_restart
|
int
|
Restarts to attempt. |
20
|
Returns:
| Type | Description |
|---|---|
ndarray or None
|
Cluster label per point, or None if every restart collapsed to fewer than |
bootstrap_variogram_params(x_km, y_km, z, estimator='ch', n_boot=400, drop_frac=0.1, seed=0)
¶
Resampling sensitivity of the fitted range and nugget.
Refits on n_boot random subsamples, each dropping drop_frac of the points, and reports
p5/p50/p95 bands. With few points the range is poorly identified -- worst when the field is
nugget-dominated, where many (range, sill) pairs fit a flattish cloud about equally well --
so its band is usually wide. The nugget, anchored to near-origin data, is much tighter. A
range quoted without this band is a number with no error bar.
Subsampling without replacement, deliberately: resampling with replacement would create zero-distance duplicate pairs and corrupt the near-origin estimate.
WARNING:
This function takes a seed and constructs its own generator; it does not accept one.
That is intentional and load-bearing. Callers commonly invoke it more than once per run (say,
for a log-ratio field and a raw-difference field), and each call is meant to start from the
same stream so the two are independently reproducible. Threading one shared generator through
instead -- the usual tidy-up -- silently changes every result after the first call.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x_km
|
ndarray
|
Point coordinates in a projected, metric CRS, in km. |
required |
y_km
|
ndarray
|
Point coordinates in a projected, metric CRS, in km. |
required |
z
|
ndarray
|
Field value per point. |
required |
estimator
|
(ch, matheron)
|
Passed through to |
'ch'
|
n_boot
|
int
|
Number of subsample refits to attempt. |
400
|
drop_frac
|
float
|
Fraction of points dropped per subsample; at least 6 points are always kept. |
0.1
|
seed
|
int
|
Seed for this call's generator. |
0
|
Returns:
| Type | Description |
|---|---|
dict or None
|
|
ch_gamma(abs_diffs)
¶
Cressie--Hawkins robust semivariance from a set of |z_i - z_j| pair values.
Built on the mean square-root difference rather than the mean squared difference, so a single
outlying pair cannot dominate the estimate. Prefer this to matheron_gamma on
heavy-tailed fields; their ratio near the origin is itself a diagnostic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
abs_diffs
|
ndarray
|
Absolute pairwise differences of the field. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Semivariance, or NaN if no pairs were supplied. |
empirical_variogram(distances, abs_diffs, estimator='ch', min_pairs_per_bin=MIN_PAIRS_PER_BIN, max_lag_pct=MAX_LAG_PCT)
¶
Bin scattered pairs by separation distance and estimate semivariance in each bin.
The bin count is derived from the number of pairs rather than fixed, so the same call works for a few dozen stations and for a few thousand without retuning.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
distances
|
ndarray
|
Condensed pairwise distances (as from |
required |
abs_diffs
|
ndarray
|
Absolute pairwise field differences, in the same condensed order as |
required |
estimator
|
(ch, matheron)
|
|
'ch'
|
min_pairs_per_bin
|
int
|
Bins with fewer pairs are dropped entirely rather than reported noisily. |
MIN_PAIRS_PER_BIN
|
max_lag_pct
|
float
|
Percentile of |
MAX_LAG_PCT
|
Returns:
| Name | Type | Description |
|---|---|---|
centers |
ndarray
|
Bin centre distances, km. |
gammas |
ndarray
|
Semivariance per retained bin. |
counts |
ndarray
|
Pair count per retained bin -- the natural fit weight. |
max_lag |
float
|
The lag cap actually used, km. |
exponential_variogram(h, nugget, psill, range_km)
¶
Isotropic exponential semivariogram model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
h
|
ndarray
|
Lag distances, km. |
required |
nugget
|
float
|
Semivariance at zero separation -- the irreducible point-scale component. |
required |
psill
|
float
|
Partial sill; the model asymptotes to |
required |
range_km
|
float
|
e-folding length scale, km. Reported under the same name in the fit result dicts. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
Modelled semivariance at each lag. |
fit_bias_variogram(x_km, y_km, z, estimator='ch', near_origin_km=NEAR_ORIGIN_KM)
¶
Full variogram pipeline for a scattered field: empirical estimate plus a nugget-fixed fit.
Single entry point so a diagnostic and any downstream interpolation cannot disagree about which variogram they are using.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x_km
|
ndarray
|
Point coordinates in a projected, metric CRS, in km. |
required |
y_km
|
ndarray
|
Point coordinates in a projected, metric CRS, in km. |
required |
z
|
ndarray
|
Field value per point -- typically |
required |
estimator
|
(ch, matheron)
|
Semivariance estimator, and which near-origin estimate becomes the fixed nugget. |
'ch'
|
near_origin_km
|
float
|
Separation below which pairs count toward the nugget estimate. |
NEAR_ORIGIN_KM
|
Returns:
| Type | Description |
|---|---|
dict
|
The fit parameters (
|
fit_exponential_variogram(centers, gammas, counts, var_z, max_lag, d_min, nugget)
¶
Fit an isotropic exponential variogram with the nugget fixed, not free.
Fixing it is deliberate. With a typical station network there are too few sub-10 km pairs to
anchor the intercept, and a free three-parameter fit collapses the nugget toward zero -- which
then reads as a smoothly interpolable bias field when the data say no such thing. Only
(psill, range) are fitted, weighted by pair count.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
centers
|
ndarray
|
Empirical variogram points from |
required |
gammas
|
ndarray
|
Empirical variogram points from |
required |
counts
|
ndarray
|
Empirical variogram points from |
required |
var_z
|
float
|
Variance of the field; bounds the sill. |
required |
max_lag
|
float
|
Upper bound for the fitted range, km. |
required |
d_min
|
float
|
Smallest pairwise distance; lower bound for the fitted range. |
required |
nugget
|
float
|
The data-derived near-origin estimate to hold fixed. Clipped into |
required |
Returns:
| Type | Description |
|---|---|
dict or None
|
|
matheron_gamma(abs_diffs)
¶
Classical Matheron semivariance from a set of |z_i - z_j| pair values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
abs_diffs
|
ndarray
|
Absolute pairwise differences of the field. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Semivariance, or NaN if no pairs were supplied. |
morans_i(zc, weights, s0, n, denom)
¶
Moran's I for a pre-centred field under a given weight matrix.
Takes s0, n and denom as arguments rather than deriving them because callers evaluate
many weight matrices, or many permutations, against the same field -- recomputing the invariants
each time is the dominant cost.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
zc
|
ndarray
|
Field with its mean already removed. |
required |
weights
|
ndarray
|
|
required |
s0
|
float
|
Sum of |
required |
n
|
int
|
Number of locations. |
required |
denom
|
float
|
|
required |
Returns:
| Type | Description |
|---|---|
float
|
Moran's I. Its null expectation is |
morans_i_at_points(values, x, y, rng, n_perm=999, min_points=8)
¶
Moran's I and its permutation p-value for scattered points, with inverse-distance weights.
The convenience layer over morans_i and morans_i_permutation, which take pre-computed weight
matrices and invariants because callers evaluating many bands or many permutations should not
recompute them. That is the right shape for a hot loop and the wrong shape for the common case:
"is this scattered field spatially clustered?"
This exists because the low-level pair was, in practice, easier to re-implement than to call -- which is a design smell worth fixing rather than documenting.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
values
|
ndarray
|
Field value per point. |
required |
x
|
ndarray
|
Point coordinates in a projected metric CRS. Units set the weight scale; only relative distances matter. |
required |
y
|
ndarray
|
Point coordinates in a projected metric CRS. Units set the weight scale; only relative distances matter. |
required |
rng
|
Generator
|
Caller's generator, consumed once per permutation. |
required |
n_perm
|
int
|
Permutations for the null. |
999
|
min_points
|
int
|
Below this, |
8
|
Returns:
| Name | Type | Description |
|---|---|---|
morans_i |
float
|
Observed statistic. Its null expectation is |
p_value |
float
|
One-sided permutation p-value for positive autocorrelation. |
morans_i_permutation(zc, weight_mats, n, denom, rng, n_perm=999)
¶
Permutation null for Moran's I across one or more weight matrices.
WARNING:
One shuffle is evaluated against every weight matrix, by design. Testing each matrix with
its own independent permutations would draw n_perm x len(weight_mats) times instead of
n_perm, and -- more importantly -- would destroy the correlation between the bands'
nulls. A correlogram's bands are not independent tests of independent quantities; they are one
field viewed at several scales, and a multiple-comparison correction applied across
independently-generated nulls is answering a different question from the one asked.
This is why the function takes a list of weight matrices rather than being called once per matrix. Refactoring it into a per-matrix helper changes both the draw count and the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
zc
|
ndarray
|
Field with its mean already removed. |
required |
weight_mats
|
sequence of (np.ndarray, float)
|
|
required |
n
|
int
|
Number of locations. |
required |
denom
|
float
|
|
required |
rng
|
Generator
|
Caller's generator. Consumed once per permutation, in order. |
required |
n_perm
|
int
|
Number of permutations. |
999
|
Returns:
| Name | Type | Description |
|---|---|---|
observed |
ndarray
|
Moran's I per weight matrix on the real data. |
null |
ndarray
|
|