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
GeoDataFrameof 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 :
Here is the observed value at location , is the set of pairs separated by a distance within the tolerance of lag , and 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:
The denominator constant 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
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
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
# 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
# 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 of the maximum pairwise distance, or the string "median" for the median pairwise distance:
# 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:
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
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 , 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 pairs, so pairwise-distance computation is in both time and memory. At that is ~12.5 million pairs — manageable — but at the condensed distance array alone approaches 10 GB and naive estimation becomes infeasible. Three mitigations apply:
# 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 |
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
- Fitting Empirical Variograms with SciKit-GStat — weighted least-squares fitting of a model to the binned points
- Choosing Lag Bins and Bandwidth for Variograms — binning schemes, bin count, and maximum-lag selection
- Theoretical Variogram Models — the model families you fit to this empirical estimate
- Stationarity & Trend Analysis — the assumption a bounded variogram depends on
← 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.