Empirical Variogram Estimation in Python

The empirical (or experimental) variogram is the first quantitative product of any geostatistical study: it turns a cloud of scattered samples into a curve that shows how spatial similarity decays with distance. Everything downstream — the fitted model, the kriging weights, the prediction intervals — is only as trustworthy as this estimate. This guide covers how to compute it correctly in Python, within the broader context of variogram modeling and semivariance analysis, using both the classical Matheron estimator and the robust Cressie-Hawkins estimator.

Prerequisites

  • Python 3.10+
  • scikit-gstat>=0.6, numpy>=1.22, geopandas>=0.14, matplotlib>=3.6
  • A GeoDataFrame of point observations in a projected (metric) CRS — UTM or a regional projection, never EPSG:4326
  • Values that are already detrended and declustered, so the intrinsic stationarity assumption holds
  • At least ~100 well-distributed samples so that each lag bin can hold 30–50 pairs

Mathematical Core

Two estimators dominate practice. The classical Matheron estimator averages half the squared differences of all pairs whose separation falls in the lag class N(h)N(h):

γ^(h)=12N(h)(i,j)N(h)[z(si)z(sj)]2\hat{\gamma}(h) = \frac{1}{2\,|N(h)|} \sum_{(i,j) \in N(h)} \left[ z(s_i) - z(s_j) \right]^{2}

Here z(si)z(s_i) is the observed value at location sis_i, N(h)N(h) is the set of pairs separated by a distance within the tolerance of lag hh, and N(h)|N(h)| is the number of such pairs. Because the differences are squared, a single outlying pair can dominate a bin.

The Cressie-Hawkins robust estimator replaces the squared differences with fourth roots of absolute differences and applies a bias correction:

γˉ(h)=12(1N(h)(i,j)N(h)z(si)z(sj)1/2)40.457+0.494N(h)+0.045N(h)2\bar{\gamma}(h) = \frac{1}{2}\cdot\frac{\left(\dfrac{1}{|N(h)|}\displaystyle\sum_{(i,j)\in N(h)} |z(s_i) - z(s_j)|^{1/2}\right)^{4}}{0.457 + \dfrac{0.494}{|N(h)|} + \dfrac{0.045}{|N(h)|^{2}}}

The denominator constant 0.457+0.494/N(h)+0.045/N(h)20.457 + 0.494/|N(h)| + 0.045/|N(h)|^2 makes the estimator approximately unbiased for a Gaussian field. Use Cressie-Hawkins whenever the value distribution has a heavy upper tail; use Matheron for clean, near-Gaussian data where its lower variance is an advantage.

Annotated Implementation

scikit-gstat centres on one class, Variogram, whose signature exposes every estimation choice. The essential call is Variogram(coordinates, values, ...), where coordinates is an (n, 2) array of projected coordinates and values is a length-n array of observations.

1. Extract coordinates and values

python
import geopandas as gpd
import numpy as np

# Point samples in a projected, metric CRS (UTM Zone 33N here)
gdf = gpd.read_file("soil_zinc.gpkg").to_crs("EPSG:32633")

# scikit-gstat wants a plain (n, 2) coordinate array and a length-n value array
coordinates = np.column_stack([gdf.geometry.x.values, gdf.geometry.y.values])
values = gdf["zinc"].to_numpy(dtype=float)

assert coordinates.shape[0] == values.shape[0]
assert not np.isnan(values).any(), "Remove or impute NaNs before estimation"

The to_crs call is not optional. Variogram computes Euclidean distances straight from the coordinate array, so degrees would produce meaningless lag bins.

2. Instantiate the Variogram

python
from skgstat import Variogram

V = Variogram(
    coordinates,
    values,
    estimator="matheron",   # classical estimator; use "cressie" for robust
    model="spherical",      # theoretical model fitted to the binned points
    bin_func="even",        # equal-width lag classes
    n_lags=15,              # number of lag bins
    maxlag=0.5,             # cap at half the maximum pairwise distance
    normalize=False,        # keep lags and semivariance in data units
)

print(V)                    # prints estimator, model, fitted nugget/sill/range

The constructor immediately estimates the empirical variogram and fits the named model. To work with the empirical estimate alone, read V.bins (lag-class upper edges) and V.experimental (the estimated semivariance per bin) before trusting the fit.

3. Switch to the robust estimator

python
# Rebuild with the Cressie-Hawkins robust estimator for heavy-tailed data
V_robust = Variogram(
    coordinates, values,
    estimator="cressie",
    model="spherical",
    n_lags=15,
    maxlag=0.5,
)

# Compare the two empirical curves directly
for h, g_m, g_r in zip(V.bins, V.experimental, V_robust.experimental):
    print(f"lag {h:8.1f} m   matheron {g_m:8.2f}   cressie {g_r:8.2f}")

If the two curves diverge sharply at particular lags, those lags contain influential outlier pairs and the robust estimate should be preferred.

4. Inspect the variogram cloud and pair counts

python
# Per-bin pair counts — the single most important diagnostic
counts = np.fromiter((len(g) for g in V.lag_classes()), dtype=int)
for h, c in zip(V.bins, counts):
    flag = "  <-- under-supported" if c < 30 else ""
    print(f"lag {h:8.1f} m : {c:5d} pairs{flag}")

# The raw cloud: one half-squared-difference per pair, and each pair's distance
distances = V.distance          # condensed pairwise distances
cloud = 0.5 * np.square(V.pairwise_diffs)   # per-pair semivariances
print(f"cloud points: {cloud.size}, expected n*(n-1)/2 = "
      f"{values.size * (values.size - 1) // 2}")

The cloud is the un-aggregated scatter behind the binned curve; scanning it reveals the outlier pairs that motivate the robust estimator.

Diagnostic Configuration

Three parameters control the quality of the empirical estimate. Tune them deliberately rather than accepting defaults.

Number of lag bins (n_lags). Too few bins over-smooth and hide the range; too many leave each bin under-populated and noisy. Ten to twenty bins is typical. Sweep the value and watch the pair counts.

Maximum lag (maxlag). Restrict to roughly half the domain diameter. scikit-gstat accepts a float (absolute distance), a fraction in (0,1)(0, 1) of the maximum pairwise distance, or the string "median" for the median pairwise distance:

python
# Absolute cap at 1500 m; often clearer than a fraction of the max distance
V_capped = Variogram(coordinates, values, maxlag=1500.0, n_lags=12)

# Equal-count binning: every bin holds the same number of pairs, ideal for
# unevenly sampled data where equal-width bins leave sparse tails.
V_uniform = Variogram(coordinates, values, bin_func="uniform", n_lags=12)

Minimum pairs per bin. scikit-gstat does not hard-enforce a floor, so verify it yourself. Bins with fewer than 30 pairs should be dropped or absorbed by widening bins or lowering maxlag:

python
def flag_thin_bins(V, min_pairs=30):
    """Return the lag distances whose bins are under-supported."""
    counts = np.fromiter((len(g) for g in V.lag_classes()), dtype=int)
    thin = V.bins[counts < min_pairs]
    if thin.size:
        print(f"WARNING: {thin.size} bins below {min_pairs} pairs: {thin}")
    return thin

flag_thin_bins(V)

Empirical Cloud versus Binned Variogram

Variogram cloud versus binned empirical variogram Left: the variogram cloud as a dense scatter of points. Right: the same data reduced to averaged binned points rising from a nugget toward a sill at the range. lag distance h ½[z(sⁱ)-z(sⱼ)]² Variogram cloud lag distance h Binned empirical variogram sill range nugget

The cloud on the left is what V.distance and V.pairwise_diffs expose; the binned curve on the right is V.bins against V.experimental. Model fitting operates only on the binned points, which is why their quality — driven by pair counts — matters more than the raw cloud.

Output Interpretation

Read the binned empirical variogram before fitting anything:

  • Rise from a positive intercept: the intercept at the shortest lag approximates the nugget — measurement error plus sub-sample-spacing variability. A large intercept relative to the plateau means a noisy or under-sampled process.
  • Plateau (sill): where the curve flattens marks the total variance and, by γ(h)=C(0)C(h)\gamma(h) = C(0) - C(h), the point beyond which pairs are uncorrelated. A curve that never flattens signals residual trend — stop and revisit stationarity and trend analysis before proceeding.
  • Range: the lag at which the plateau is reached is the correlation range, the key input to kriging and to distance-band weights.
  • Noisy tail: erratic points at the widest lags are the tell-tale of thin bins. They should be excluded by capping maxlag, not fitted through.

Good signs are a smooth monotone rise to a clear plateau with every bin holding ample pairs. Warning signs are a sawtooth curve (too many bins, too few pairs), an unbounded climb (trend), or a flat line at the sill from lag zero (pure nugget — no resolvable spatial structure).

Production Considerations

The variogram cloud has n(n1)/2n(n-1)/2 pairs, so pairwise-distance computation is O(n2)O(n^2) in both time and memory. At n=5,000n = 5{,}000 that is ~12.5 million pairs — manageable — but at n=50,000n = 50{,}000 the condensed distance array alone approaches 10 GB and naive estimation becomes infeasible. Three mitigations apply:

python
# 1. Subsample for exploratory estimation, then confirm on the full set.
rng = np.random.default_rng(42)
idx = rng.choice(values.size, size=3000, replace=False)
V_sample = Variogram(coordinates[idx], values[idx], n_lags=15, maxlag=0.5)

# 2. Cap maxlag so distant pairs are never materialised into bins.
# 3. For very large n, estimate per spatial tile and pool bin sums,
#    or move directional/large-scale estimation to gstools, which
#    supports binned estimators over chunked inputs.

Because the estimate is a sum over pairs, tiled estimation is exact if you accumulate the per-bin pair sums and counts across tiles rather than averaging tile-level curves. Keep the random seed fixed when subsampling so the exploratory variogram is reproducible.

Troubleshooting

Symptom Likely cause Fix
Variogram rises without ever flattening Deterministic trend violates stationarity Detrend the values or switch to universal kriging; re-estimate on residuals
Lag distances look absurdly small (e.g. < 5) Coordinates still in degrees (EPSG:4326) gdf.to_crs("EPSG:32633") to a metric CRS before extracting coordinates
Erratic, sawtooth points at large lags Too few pairs in the widest bins Lower maxlag to ~half the domain diameter; reduce n_lags
Flat line at the sill from lag zero Pure nugget — no resolvable structure Confirm sampling is dense enough; the process may be spatially random at this scale
Matheron and Cressie curves differ sharply Outlier pairs inflating squared differences Prefer the "cressie" estimator; inspect the cloud for anomalies
MemoryError on instantiation O(n2)O(n^2) pair array too large Subsample, cap maxlag, or estimate per tile and pool bin sums
Sill far below the sample variance Preferential (clustered) sampling biases the estimate Apply declustering weights first (see sampling bias mitigation)

Next Steps

Once the empirical curve is trustworthy, fit a theoretical model to it: the mechanics of weighted least-squares fitting in scikit-gstat are covered in fitting empirical variograms with scikit-gstat, and the binning decisions that shape the curve are detailed in choosing lag bins and bandwidth for variograms. From there, move to the sibling guides on theoretical variogram models and anisotropy and directional variograms.


Related

← Back to Variogram Modeling & Semivariance Analysis

Frequently Asked Questions

What is the difference between the variogram cloud and the empirical variogram?

The variogram cloud is the raw scatter of one half-squared-difference per sample pair, plotted against each pair’s separation distance. The empirical variogram aggregates that cloud into lag bins and reports one averaged semivariance per bin. The cloud reveals outlier pairs and the spread within a lag; the binned curve is what you fit a theoretical model to.

How do I set the maximum lag distance in scikit-gstat?

Pass maxlag to the Variogram. Use a float for an absolute distance, a fraction between 0 and 1 to take that share of the maximum pairwise distance, or the string "median" to use the median pairwise distance. A robust default is about half the domain diameter, because bins beyond that contain too few pairs to estimate reliably.

Why are the last few points of my empirical variogram so noisy?

The widest lag bins contain the fewest pairs, because only observations on opposite sides of the study area fall into them. With few pairs the averaged semivariance has high variance and scatters wildly. Cap maxlag near half the domain diameter and require at least 30 pairs per bin so the unreliable tail is excluded from fitting.

Does scikit-gstat need projected coordinates?

Yes. The Variogram computes Euclidean lag distances directly from the coordinate array, so those coordinates must be in a projected metric CRS. Passing longitude and latitude in degrees produces lag distances in degrees, which correspond to different ground distances at different latitudes and distort the entire curve. Reproject with GeoPandas to a UTM zone first.