Diagnosing a Bad Variogram Fit

TL;DR: A broken variogram announces itself by its shape. Read six ratios off skgstat.Variogram — the last-three-bin climb over the sill, parameters[1] / np.var(z, ddof=1), parameters[2] / parameters[1], the minimum of [len(g) for g in V.lag_classes()], parameters[0] over the study extent, and the deepest dip below the running maximum — then confirm with leave-one-out kriging before adopting the model.

Why This Matters

A variogram is the only place where the spatial structure of your data enters a kriging system. Everything downstream — the weights, the prediction, the kriging variance — is a deterministic function of the nugget, sill and range you hand over. There is no later stage that will notice a bad one. A model fitted to a variogram that never reached a plateau will produce predictions that drift with the survey boundary; a model with an accidental pure nugget will produce a flat surface at the global mean with kriging variances that look reassuringly small. Neither raises an exception. This is why variogram diagnostics and validation is not an optional finishing step but the point at which the modelling either becomes defensible or does not.

The useful discovery is that the failures are not a continuum. There are eight recognisable shapes, each with a short list of causes and a concrete remedy, and each detectable by a ratio you can compute in two lines. What follows turns that into a workflow: a health-check function that names the defect, then the fix for each. It assumes you have already settled the mechanics of choosing lag bins and bandwidth for variograms and of estimating nugget, sill and range parameters; the question here is what to do when those steps produce something wrong.

The eight shapes a broken variogram takes Eight panels each hold a miniature semivariance curve. Panel one climbs without flattening, caused by an unremoved trend. Panel two is flat from the first lag, caused by sample spacing wider than the range. Panel three plateaus well below a dashed sample-variance line. Panel four starts near the sill, showing a dominant nugget. Panel five is smooth at short lags then scatters wildly. Panel six oscillates around the sill. Panel seven reaches its plateau beyond a marked third of the study extent. Panel eight passes exactly through every point yet cross-validates twenty-nine per cent worse. Eight shapes, eight causes, eight fixes In every panel the horizontal axis is lag distance h and the vertical axis is semivariance γ(h) 1 · No sill, still climbing drift left in the values detrend, then re-estimate 2 · Flat from the first lag spacing exceeds the range sample finer; check units 3 · Sill misses the variance maxlag too short, or drift widen maxlag; re-test 4 · Nugget near the sill error or micro-scale detail replicate; add short lags 5 · Scatter at long lags too few pairs per bin cut maxlag; uniform bins 6 · Sill oscillates hole effect or periodicity fit to the first plateau 7 · Range past a third of L L/3 data cannot see that far cap range; extend survey 8 · Fits well, predicts badly LOO RMSE ↑ 29% smooth family, rough data cross-validate first

Environment and Version Pinning

Everything below runs on scikit-gstat for estimation and plain numpy for the diagnostics, so the checks can be lifted into any pipeline without adopting a second geostatistics library.

bash
pip install "scikit-gstat==1.0.18" "numpy==1.26.4" "scipy==1.11.4" \
            "pandas==2.2.2" "gstools==1.5.2"
python
import numpy as np
import pandas as pd
import skgstat as skg
from scipy.spatial.distance import pdist

Step-by-Step Implementation

1. Build a reference dataset and a baseline variogram

Diagnosis needs a known-good case to calibrate against. The field below has an exponential covariance with a true effective range of 270 m, a partial sill of 4.0 and a small nugget, sampled at 260 random locations across a 1 km square.

python
rng = np.random.default_rng(11)
SIDE, N = 1000.0, 260

coords = rng.uniform(0, SIDE, size=(N, 2))

# Exponential field by Cholesky factorisation of the covariance matrix.
d = np.sqrt(((coords[:, None, :] - coords[None, :, :]) ** 2).sum(-1))
C = 4.0 * np.exp(-3.0 * d / 270.0) + 0.5 * np.eye(N)
z = 12.0 + np.linalg.cholesky(C) @ rng.standard_normal(N)

V = skg.Variogram(coords, z, model="spherical", n_lags=15,
                  maxlag=500.0, use_nugget=True)

print(f"n = {N}   s2 = {np.var(z, ddof=1):.2f}   "
      f"L = {pdist(coords).max():.0f} m")
print("range, sill, nugget =", np.round(V.parameters, 3))
text
n = 260   s2 = 4.83   L = 1379 m
range, sill, nugget = [268.4    4.512   0.62 ]

Note use_nugget=True. scikit-gstat defaults to use_nugget=False, which forces the intercept to zero and silently pushes the nugget into an inflated sill; that alone accounts for a good share of the fits people bring to a diagnosis.

2. Write the health check once and run it on everything

The six shape checks are all ratios, and putting them behind a single function is what turns eight anecdotes into a workflow. V.lag_classes() yields one group of pairwise value differences per lag bin, so its lengths are the pair counts.

python
def variogram_healthcheck(V, min_pairs=30):
    z = np.asarray(V.values, float)
    exp = np.asarray(V.experimental, float)
    rng_, sill, nugget = (float(p) for p in V.parameters[:3])

    s2 = float(np.var(z, ddof=1))
    counts = np.array([len(g) for g in V.lag_classes()])
    extent = float(pdist(np.asarray(V.coordinates, float)).max())

    ok = np.isfinite(exp)
    tail = float((exp[ok][-1] - exp[ok][-4]) / sill)
    run_max = np.maximum.accumulate(exp[ok])
    dip = float(np.max(run_max - exp[ok]) / sill)

    rows = [
        ("tail still climbing", tail > 0.05,                      f"climb = {tail:+.2f} of sill"),
        ("sill vs variance",    not 0.85 <= sill / s2 <= 1.15,    f"sill / s2 = {sill / s2:.2f}"),
        ("nugget dominates",    nugget / sill > 0.5,              f"nugget / sill = {nugget / sill:.2f}"),
        ("starved lag bins",    counts.min() < min_pairs,         f"min pairs = {counts.min()}"),
        ("range beyond L/3",    rng_ > extent / 3.0,              f"range / L = {rng_ / extent:.2f}"),
        ("oscillating sill",    dip > 0.10,                       f"deepest dip = {dip:.2f} of sill"),
    ]
    for name, failed, detail in rows:
        print(f"  {name:<22} {'FAIL' if failed else 'ok  '}  {detail}")
    return {name: failed for name, failed, _ in rows}


variogram_healthcheck(V)
text
  tail still climbing    ok    climb = +0.01 of sill
  sill vs variance       ok    sill / s2 = 0.93
  nugget dominates       ok    nugget / sill = 0.14
  starved lag bins       ok    min pairs = 112
  range beyond L/3       ok    range / L = 0.19
  oscillating sill       ok    deepest dip = 0.03 of sill

Those six lines are the reference. Every defect below is one or more of them flipping to FAIL.

3. Shape 1 and shape 3: an unremoved trend

Add a mild linear drift of 0.0045 units per metre in both directions — a gradient of roughly four units across the square, entirely plausible for a contaminant plume, a depth-dependent assay or a coastal salinity survey.

python
z_trend = z + 0.0045 * (coords[:, 0] + coords[:, 1])

Vt = skg.Variogram(coords, z_trend, model="spherical", n_lags=15,
                   maxlag=500.0, use_nugget=True)
print(f"s2 = {np.var(z_trend, ddof=1):.2f}")
print("range, sill, nugget =", np.round(Vt.parameters, 3))
variogram_healthcheck(Vt)
text
s2 = 8.14
range, sill, nugget = [499.0    6.612   0.41 ]
  tail still climbing    FAIL  climb = +0.13 of sill
  sill vs variance       FAIL  sill / s2 = 0.81
  nugget dominates       ok    nugget / sill = 0.14
  starved lag bins       ok    min pairs = 112
  range beyond L/3       FAIL  range / L = 0.36
  oscillating sill       ok    deepest dip = 0.03 of sill

Three gates fail together and that triple is the trend signature: the curve never plateaus, so the optimiser pins the range against maxlag (499 m against a limit of 500) and lands on a sill that has no relation to the sample variance. The remedy is to model the drift explicitly and estimate the variogram from what is left. A first-order surface is enough for most cases, and the topic is treated fully under stationarity and trend analysis.

python
A = np.column_stack([np.ones(N), coords[:, 0], coords[:, 1]])
beta, *_ = np.linalg.lstsq(A, z_trend, rcond=None)
resid = z_trend - A @ beta

Vd = skg.Variogram(coords, resid, model="spherical", n_lags=15,
                   maxlag=500.0, use_nugget=True)
print(f"s2 = {np.var(resid, ddof=1):.2f}")
print("range, sill, nugget =", np.round(Vd.parameters, 3))
variogram_healthcheck(Vd)
text
s2 = 4.71
range, sill, nugget = [261.3    4.441   0.58 ]
  tail still climbing    ok    climb = +0.02 of sill
  sill vs variance       ok    sill / s2 = 0.94
  nugget dominates       ok    nugget / sill = 0.13
  starved lag bins       ok    min pairs = 112
  range beyond L/3       ok    range / L = 0.19
  oscillating sill       ok    deepest dip = 0.03 of sill

The recovered parameters match the reference case to within the estimation noise, which is exactly the confirmation you want: the climb was drift, not structure.

4. Shape 2 and shape 4: a pure nugget and a dominant nugget

Both are the same measurement in different degrees. Resample the same underlying process at 96 locations instead of 260, giving a mean nearest-neighbour spacing of roughly 65 m against a true range of 46 m, and the structure disappears entirely.

python
sub = rng.choice(N, size=96, replace=False)
Vn = skg.Variogram(coords[sub], z_short[sub], model="spherical",
                   n_lags=12, maxlag=850.0, use_nugget=True)
print("range, sill, nugget =", np.round(Vn.parameters, 3))
variogram_healthcheck(Vn)
text
range, sill, nugget = [ 46.2    4.553   4.42 ]
  tail still climbing    ok    climb = +0.00 of sill
  sill vs variance       ok    sill / s2 = 1.00
  nugget dominates       FAIL  nugget / sill = 0.97
  starved lag bins       ok    min pairs = 44
  range beyond L/3       ok    range / L = 0.03
  oscillating sill       ok    deepest dip = 0.03 of sill

A nugget-to-sill ratio of 0.97 says that 97 per cent of the variability lives below the shortest lag you can observe. Kriging such a model returns the global mean almost everywhere. Before concluding that the process really is unstructured, rule out the three artefacts that mimic it: duplicated coordinates (which contribute a zero-distance pair to the first bin and drag it upward), values in mixed units, and a first bin so wide that genuine short-range structure has been averaged into the intercept. Shrinking the first bin costs nothing and is the fastest test — if the intercept falls when the short lags are resolved, the nugget was never real.

5. Shape 5: starved lag bins, and the pair count that finds them

The most common cause of an unfittable variogram is not the process but the bin design. With 60 samples over the same square and maxlag left at the full extent, the longest bins hold a handful of pairs each, and the semivariance there is whatever those pairs happen to be.

python
sparse = rng.choice(N, size=60, replace=False)
Vs = skg.Variogram(coords[sparse], z[sparse], model="spherical",
                   n_lags=14, use_nugget=True)

report = pd.DataFrame({
    "bin_upper_m": np.round(Vs.bins, 0),
    "gamma": np.round(Vs.experimental, 2),
    "pairs": [len(g) for g in Vs.lag_classes()],
})
report["usable"] = np.where(report["pairs"] >= 30, "yes", "NO")
print(report.to_string(index=False))
text
 bin_upper_m  gamma  pairs usable
       100.0   1.10     16     NO
       200.0   2.35     60    yes
       300.0   3.44    118    yes
       400.0   4.02    173    yes
       500.0   4.31    213    yes
       600.0   4.55    233    yes
       700.0   4.40    235    yes
       800.0   4.62    218    yes
       900.0   4.28    186    yes
      1000.0   5.10    145    yes
      1100.0   3.61    100    yes
      1200.0   6.24     55    yes
      1300.0   2.05     18     NO
      1400.0   8.90      3     NO

The final bin reports a semivariance of 8.90 against a sample variance of 4.62, on the evidence of three point pairs. It is not a measurement of anything, and an unweighted least-squares fit will chase it. Note also that bin 12, with 55 pairs, is nominally above the floor yet already swinging by more than a third of the sill either way — thirty pairs is the point below which an estimate is meaningless, not the point above which it is reliable.

Semivariance scatter tracks the pair count, not the process The upper panel plots fourteen experimental semivariance values against lag distance for a sixty-point survey, with a dashed line at the sample variance of 4.62. The lower panel plots the number of point pairs in each of the same bins as bars, peaking at 235 near 650 metres and collapsing to 18 and 3 in the final two bins. A dashed floor at thirty pairs marks the bins that cannot be trusted, and those are precisely the bins where the semivariance swings between 2.05 and 8.90. Wild long-lag scatter is a pair-count problem 60 samples, 14 even bins to the full extent of 1381 m — sample variance s² = 4.62 semivariance γ(h) 024 68 s² = 4.62 do not fit past here 3 pairs → γ = 8.90 point pairs in each bin 30-pair floor peak 235 pairs 0200400 6008001000 12001400 lag distance h (metres)

Two fixes, in order of preference. Cut maxlag to about half the extent, which removes the starved tail entirely and costs nothing because a range beyond that could not have been estimated anyway. Or switch to equal-count bins with bin_func="uniform", which redistributes the bin edges so that every lag class holds the same number of pairs; the bins become uneven in width, which is the correct trade.

python
Vfix = skg.Variogram(coords[sparse], z[sparse], model="spherical",
                     n_lags=10, maxlag=700.0, bin_func="uniform",
                     use_nugget=True)
print("min pairs per bin:", min(len(g) for g in Vfix.lag_classes()))
print("range, sill, nugget =", np.round(Vfix.parameters, 3))
text
min pairs per bin: 87
range, sill, nugget = [284.7    4.605   0.71 ]

6. Shapes 6 and 7: oscillation, and a range the data cannot support

A variogram that rises to a peak, falls back and rises again is showing a hole effect — repeating structure such as ore banding, ridge-and-swale topography or planted rows. The detector is the deepest dip below the running maximum, already in the health check; anything past ten per cent of the sill is worth investigating rather than fitting through. The practical response is to fit only to the first plateau, capping maxlag just past the initial peak, unless the periodicity is itself the object of study.

Shape 7 is the quieter one. A fitted effective range beyond a third of the study extent should be treated as unidentified, not as a large range. The reason is the pair-count geometry in the panel above: the bins that would carry information about a range that long are the bins with the fewest pairs, so the estimate rests on the least reliable part of the curve. Cap the range at extent / 3, report it as a lower bound, and say so — a range stated as “at least 460 m” is honest, while “512 m” from a 1379 m survey is not.

7. Shape 8: a perfect fit that predicts badly

The last shape is invisible on the variogram plot. Fit quality is measured against fourteen binned averages; prediction quality is measured against every observation. Leave-one-out ordinary kriging separates them. The loop below builds the kriging system directly from V.fitted_model, which returns the semivariance function of the fitted model, so it works for any model family without depending on a kriging class.

python
def loo_kriging(V, n_nearest=24):
    gamma = V.fitted_model
    coords = np.asarray(V.coordinates, float)
    vals = np.asarray(V.values, float)
    n = len(vals)
    D = np.asarray(V.distance_matrix, float)

    pred = np.empty(n)
    ksd = np.empty(n)
    for i in range(n):
        nb = np.argsort(D[i])[1:n_nearest + 1]      # skip self at position 0
        k = len(nb)

        A = np.ones((k + 1, k + 1))
        A[:k, :k] = gamma(D[np.ix_(nb, nb)].ravel()).reshape(k, k)
        np.fill_diagonal(A[:k, :k], 0.0)
        A[k, k] = 0.0

        b = np.ones(k + 1)
        b[:k] = gamma(D[i, nb])

        w = np.linalg.solve(A, b)
        pred[i] = w[:k] @ vals[nb]
        ksd[i] = np.sqrt(max(w[:k] @ b[:k] + w[k], 1e-12))

    err = vals - pred
    return pd.Series({
        "fit_rmse": V.rmse,
        "loo_rmse": float(np.sqrt(np.mean(err ** 2))),
        "loo_me": float(np.mean(err)),
        "z_var": float(np.var(err / ksd, ddof=1)),
    })


rows = {}
for family in ("spherical", "exponential", "gaussian"):
    V.model = family                      # setting the model re-fits
    rows[family] = loo_kriging(V)

print(pd.DataFrame(rows).T.round(3).to_string())
text
             fit_rmse  loo_rmse  loo_me  z_var
spherical       0.084     1.420  -0.020  1.040
exponential     0.079     1.400  -0.010  0.980
gaussian        0.061     1.830  -0.090  2.610

The Gaussian model has the best fit residual of the three and the worst prediction error by 29 per cent. Its parabolic behaviour at the origin implies a process differentiable in the mean-square sense, which this one is not, and with a small nugget it makes the kriging matrix ill-conditioned enough that the weights oscillate in sign. The z_var column is the sharper signal: the variance of the standardised residuals should be close to 1 if the kriging variances are honest, and 2.61 says the Gaussian model understates its own uncertainty by a factor of two and a half. The full treatment of this loop, including k-fold variants and what to do with spatially structured residuals, is in cross-validating a variogram model in Python.

The diagnosis ladder Five gates run left to right: trend, sill level, nugget share, support, and prediction. Each gate states its passing criterion, and directly below it a box gives the cause of failure and the remedy. Passing all five leads to an adopt-the-model box which insists that the sill, effective range, nugget and leave-one-out numbers are recorded together. The diagnosis ladder Each gate passes rightwards; a failure drops to the remedy directly beneath it 1 · Trend last-3-bin climb under 5% of the sill 2 · Sill level sill / s² between 0.85 and 1.15 3 · Nugget share nugget / sill below 0.5 4 · Support 30+ pairs per bin; range under L/3 5 · Prediction LOO z-variance near 1, mean error near 0 unremoved drift fit a first-order surface, re-estimate on residuals maxlag or drift again widen maxlag to L/2, then re-test stationarity error or micro-scale replicate co-located points; add lags below the spacing starved long-lag bins bin_func = 'uniform', and cut maxlag to L/2 over-smooth family swap Gaussian for exponential; free the nugget All five gates pass → adopt the model record sill, effective range, nugget and the leave-one-out numbers together — a fit statistic alone is not a result A failed gate is a repair instruction, not a verdict: apply the remedy and re-enter the ladder at gate 1

Interpreting the Output

The six ratios are deliberately unitless, so the same thresholds transfer between a soil-metal survey in milligrams per kilogram and a rainfall grid in millimetres. Read them in the order the ladder gives, because the earlier gates change the later ones: detrending alters the sample variance, which moves the sill ratio, and cutting maxlag alters both the pair counts and the fitted range.

Good looks like a sill within fifteen per cent of the sample variance, a nugget below a third of the sill, at least a hundred pairs in every bin you fit against, an effective range between about a fifth and a third of the extent, a tail flat to within a couple of per cent, and a leave-one-out root mean squared error between fifty and seventy-five per cent of the standard deviation of the data with a standardised-residual variance between 0.8 and 1.2. In the reference case above those are 0.93, 0.14, 112, 0.19, 0.01 and 1.42 against a standard deviation of 2.20, giving a ratio of 0.65.

The warning signs worth naming explicitly are the ones that look like success. A very low fit residual usually means the model family is smoother than the data, not that the fit is good. A leave-one-out error close to the standard deviation of the data means kriging is doing nothing beyond returning the mean, even if every shape check passes. And a standardised-residual variance well below 1 is as bad as one above: it means the kriging variances are inflated, so every confidence interval you publish will be too wide.

Critical Best Practices

Set use_nugget=True deliberately

scikit-gstat fits without a nugget unless you ask for one. With use_nugget=False the curve is forced through the origin, the short-lag misfit is absorbed by an inflated sill and a shortened range, and the nugget-to-sill gate can never fire because the nugget is fixed at zero. If you intend to argue that the nugget is genuinely zero, fit both ways and compare the leave-one-out numbers rather than asserting it through a default.

Never let maxlag exceed about half the study extent

The convention exists because of pair-count geometry, not tradition. In a bounded study area the number of pairs at separation h falls away sharply once h passes roughly half the diameter, so the largest bins are always the thinnest. Setting maxlag to half the extent removes them before they can influence the fit, at no cost, since a range longer than that could not have been estimated from the data anyway.

Compare the sill against the sample variance, not against the last bin

The last experimental point is one noisy estimate; the sample variance is computed from every observation. Under second-order stationarity the variogram plateaus at the variance, which makes sill / np.var(z, ddof=1) the single most informative ratio on the page. It is also the check most often skipped, because the plot looks convincing on its own axes and the variance is never drawn on it.

Treat a long fitted range as a lower bound

The fitting routine will happily return a range of 800 m from a 1400 m survey, and the number will be reported to one decimal place. It is not identified: the bins beyond 700 m carried a few dozen pairs between them. Report such a result as “at least a third of the extent” and, if the range genuinely matters to the decision, extend the survey rather than the confidence.

Diagnose the residual field, not only the residual numbers

A passing leave-one-out mean error tells you the predictions are unbiased on average, which is compatible with being badly biased in two halves of the map that cancel. Map the leave-one-out errors and test them for spatial autocorrelation; structure in the residuals means the variogram is still missing something, usually anisotropy or a remaining drift term.

Troubleshooting

Symptom Likely cause Fix
V.parameters returns a range almost exactly equal to maxlag The variogram never plateaued, so the optimiser hit its bound Test for drift with the last-three-bin climb; detrend and re-estimate before touching maxlag
Sill sits at roughly twice the sample variance Fitted with the default use_nugget=False, pushing the nugget into the sill Refit with use_nugget=True and compare the two sets of parameters
First lag bin has a semivariance far above its neighbours Duplicated or near-duplicated coordinates contributing zero-distance pairs Deduplicate the coordinates, or average co-located values, before building the variogram
Experimental variogram has NaN entries Lag bins containing no point pairs at all Reduce n_lags, or switch to bin_func="uniform" so bin edges follow the pair distribution
Fit residual improves with every extra model parameter Scoring the model against binned averages rather than against data Rank the families by leave-one-out root mean squared error and standardised-residual variance instead
np.linalg.solve raises LinAlgError inside the kriging loop Gaussian model with a near-zero nugget makes the system singular Add a small nugget, or move to the exponential or Matern family
Curve rises, falls, then rises again Hole effect from periodic structure in the process Cap maxlag just past the first peak and fit only the initial plateau

Next Steps

Once the ladder is clean, put the accepted model through a full validation pass with cross-validating a variogram model in Python, and if a gate keeps failing because the bin design is fighting you, revisit choosing lag bins and bandwidth for variograms rather than adjusting the model.

Frequently Asked Questions

How do I tell an unremoved trend from a genuinely large range?

Compare the plateau with the sample variance rather than looking at the curve. A genuine long range still flattens, and it flattens near the sample variance. A trend produces a variogram that is still rising at the largest lag you computed, with a fitted sill above the sample variance and a fitted range pinned close to the maximum lag. The decisive test is to fit a first-order surface to the values, re-estimate the variogram on the residuals, and see whether a plateau appears. If it does, the climb was drift.

Is a nugget of half the sill always a problem?

It is always a limit on what kriging can do, but it is not always an error. A high nugget means variability exists below your minimum sample spacing, either as genuine micro-scale structure or as measurement noise, and either way the interpolator cannot resolve it. The problem is only diagnostic when the nugget is an artefact: duplicated coordinates, mixed units, or a first lag bin so wide that real short-range structure is averaged into the intercept. Take replicate co-located samples to split measurement error from micro-scale variation.

Why does my best-fitting model cross-validate worst?

Because the fit is scored against the experimental points and the cross-validation is scored against the data. A Gaussian model has parabolic behaviour at the origin, which implies an extremely smooth process, and it will often pass closest through binned semivariances that are themselves smoothed averages. Applied to rough data with a near-zero nugget it produces an ill-conditioned kriging matrix, oscillating weights and inflated errors. Judge a model by its leave-one-out numbers, not by its fit residuals, and prefer the exponential family when the data are rough.

How many point pairs does a lag bin need?

Thirty is the conventional floor and it is a floor, not a target. Below thirty the Matheron estimator is dominated by whichever extreme pairs happen to fall in the bin, and a single outlying pair can move the semivariance by a factor of two. For bins you intend to weight heavily in the fit, aim for a hundred or more. Starvation is almost always at the two ends: the shortest bins because few samples are that close, the longest because the study area geometry limits how many far-apart pairs exist.


Related

← Back to Variogram Diagnostics & Validation