How to Interpolate Points to a Grid with IDW in Python
Problem statement
Inverse distance weighting is the interpolator you can write from memory: predict each location as a weighted average of nearby samples, with weights falling off as 1/distance^p.
It is also the interpolator with the most ways to be quietly wrong. The defaults in most implementations β all points, power 2, no distance limit β cost real accuracy. Measured on 500 samples from a real elevation model:
nearest 6 points 51.5 m RMSE
nearest 12 points 54.0 m
nearest 24 points 59.6 m
all 500 points 90.1 m
Using every sample instead of the nearest six is 75% worse. That is the single largest parameter effect in the method, and it is the one most implementations leave switched on.
Quick answer
import numpy as np
from scipy.spatial import cKDTree
def idw(points, values, targets, power=2, k=12, max_distance=None):
"""Inverse distance weighting over the k nearest samples."""
points = np.asarray(points, dtype=float)
values = np.asarray(values, dtype=float)
targets = np.asarray(targets, dtype=float)
tree = cKDTree(points)
distance, index = tree.query(targets, k=min(k, len(points)))
# query returns 1-D arrays when k == 1
distance = distance.reshape(len(targets), -1)
index = index.reshape(len(targets), -1)
weights = 1.0 / np.maximum(distance, 1e-12) ** power
predicted = (weights * values[index]).sum(1) / weights.sum(1)
# a target sitting exactly on a sample takes that sample's value
exact = distance[:, 0] < 1e-9
predicted[exact] = values[index[exact, 0]]
if max_distance is not None:
predicted = np.where(distance[:, 0] <= max_distance, predicted, np.nan)
return predicted
Step-by-step solution
1. Project the coordinates first
IDW weights by distance, and distance in degrees is not distance. One degree of longitude is 111 km at the equator and 71 km at 50Β° north; weighting in degrees stretches the east-west axis by whatever 1/cos(latitude) happens to be.
gdf = gdf.to_crs(gdf.estimate_utm_crs())
points = np.column_stack([gdf.geometry.x, gdf.geometry.y])
2. Set k, and set it small
k 1 3 6 12 24 48 500
RMSE 64.5 52.5 51.5 54.0 59.6 66.9 90.1
Six to twelve is the sweet spot. Below that the surface is noisy and tends towards nearest-neighbour blockiness; above it, distant samples with small but non-zero weights collectively drag every prediction towards the global mean, flattening the map.
This is the parameter to tune. It is worth 75%; the power is worth 6%.
3. Choose the power, then stop worrying about it
power 0.5 1 2 3 4 6
RMSE 72.2 64.4 54.0 50.7 50.9 53.2
Anything between 2 and 4 is fine. Below 2 the surface is over-smoothed towards the local mean; above 4 it collapses towards nearest neighbour, with each prediction dominated by its single closest sample.
4. Cap the search distance
Beyond the variogram range, distance carries no information about similarity β so weighting by it produces a confident-looking number with nothing behind it. Measured error by distance to the nearest sample:
0 - 100 m 18.9 m
100 - 250 m 54.0 m
250 - 500 m 106.1 m
500 - 1,000 m 169.8 m
over 1,000 m 334.3 m
Set max_distance to something defensible β the variogram range, or two to three times the mean sample spacing β and let the far areas come out as NaN.
5. Choose an output cell size that matches the sample spacing
With 500 samples over 105 kmΒ², the mean spacing is 457 m. Output grids:
cell size cells RMSE
10 m 1,045,233 54.01 m
30 m 116,137 53.96 m
120 m 7,216 53.89 m
250 m 1,638 54.17 m
Ninety times more cells buys nothing. See How to choose a cell size for an interpolated surface.
Code examples
Example 1 β IDW to a raster, written straight to GeoTIFF
import numpy as np
import rasterio
from rasterio.transform import from_origin
from scipy.spatial import cKDTree
def idw_raster(points, values, bounds, cell_size, crs, power=2, k=12,
max_distance=None, out_path=None):
"""Interpolate to a regular grid and write it with its support raster."""
left, bottom, right, top = bounds
width = int(np.ceil((right - left) / cell_size))
height = int(np.ceil((top - bottom) / cell_size))
transform = from_origin(left, top, cell_size, cell_size)
xs = left + (np.arange(width) + 0.5) * cell_size
ys = top - (np.arange(height) + 0.5) * cell_size
grid = np.column_stack([np.repeat(xs[None, :], height, 0).ravel(),
np.repeat(ys[:, None], width, 1).ravel()])
tree = cKDTree(points)
distance, index = tree.query(grid, k=min(k, len(points)))
distance = distance.reshape(len(grid), -1)
index = index.reshape(len(grid), -1)
weights = 1.0 / np.maximum(distance, 1e-12) ** power
predicted = (weights * np.asarray(values)[index]).sum(1) / weights.sum(1)
nearest = distance[:, 0]
if max_distance is not None:
predicted = np.where(nearest <= max_distance, predicted, np.nan)
surface = predicted.reshape(height, width).astype("float32")
support = nearest.reshape(height, width).astype("float32")
if out_path:
profile = dict(driver="GTiff", height=height, width=width, count=2,
dtype="float32", crs=crs, transform=transform,
nodata=np.nan, compress="deflate", tiled=True)
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(surface, 1)
dst.write(support, 2)
dst.set_band_description(1, "interpolated value")
dst.set_band_description(2, "distance to nearest sample (m)")
dst.update_tags(method="idw", power=power, k=k,
max_distance=str(max_distance))
print(f" {width} x {height} at {cell_size} m, "
f"{np.isnan(surface).mean():.1%} masked")
return surface, support, transform
Writing the support distance as a second band is the habit worth forming. It travels with the surface, it costs one extra band, and it is the only thing that tells the next person which parts of the map to believe.
Example 2 β tuning k by leave-one-out
import numpy as np
from scipy.spatial import cKDTree
def tune_k(points, values, candidates=(3, 6, 9, 12, 18, 24), power=2):
"""Leave-one-out RMSE for each neighbour count."""
tree = cKDTree(points)
results = {}
for k in candidates:
distance, index = tree.query(points, k=min(k + 1, len(points)))
distance, index = distance[:, 1:], index[:, 1:] # drop self
weights = 1.0 / np.maximum(distance, 1e-12) ** power
predicted = (weights * values[index]).sum(1) / weights.sum(1)
results[k] = float(np.sqrt(np.mean((predicted - values) ** 2)))
print(f" k={k:3d} LOO RMSE {results[k]:7.2f}")
best = min(results, key=results.get)
print(f" best k = {best}")
return best
The [:, 1:] slice removes each point from its own prediction. Without it, every sample predicts itself exactly, LOO RMSE is zero for every k, and the tuning is meaningless.
Note that this tunes against the sample locations, not against the whole surface, so it inherits whatever bias the sampling design has β see How to cross-validate an interpolated surface.
Example 3 β anisotropic IDW, when structure has a direction
import numpy as np
from scipy.spatial import cKDTree
def idw_anisotropic(points, values, targets, major_angle_deg, ratio,
power=2, k=12):
"""IDW in a stretched coordinate system, so influence reaches further
along the major axis than across it.
ratio: minor/major, e.g. 0.25 means influence reaches 4x further along.
"""
theta = np.radians(major_angle_deg)
rotate = np.array([[np.cos(theta), np.sin(theta)],
[-np.sin(theta), np.cos(theta)]])
stretch = np.array([[1.0, 0.0], [0.0, 1.0 / ratio]])
transform = stretch @ rotate
p = points @ transform.T
t = targets @ transform.T
tree = cKDTree(p)
distance, index = tree.query(t, k=min(k, len(p)))
distance = distance.reshape(len(t), -1); index = index.reshape(len(t), -1)
weights = 1.0 / np.maximum(distance, 1e-12) ** power
return (weights * values[index]).sum(1) / weights.sum(1)
Rainfall along a valley, a contaminant plume down a hydraulic gradient and coastal salinity all have a preferred direction. Get the angle and ratio from directional variograms rather than by eye.
Explanation
Why too many neighbours flattens the map
With power 2, a sample at 5 km has a weight of 1/25,000,000 β tiny, but not zero. With one such sample it is irrelevant. With four hundred of them spread around the study area, their weights sum to something comparable with the handful of near samples, and they all pull towards the same place: the global mean.
That is exactly what the measurement shows. RMSE climbed from 51.5 m at k=6 to 90.1 m using all 500 samples, and the surface flattened towards the average elevation.
Restricting to the nearest k is not an approximation for speed. It is the correct model: only nearby samples carry information.
Why IDW produces bullseyes
IDW is an exact interpolator β at a sample location the weight on that sample is infinite relative to all others, so the surface passes exactly through every observation.
The consequence is that every sample is a local extremum of the surface. Measured on a 500-point IDW surface: 83.1% of sample locations were local maxima or minima of the interpolated grid, against 0.38% of cells being local extrema in the real DEM.
Each of those becomes a concentric ring in the output. See My IDW surface is full of bullseyes.
Why the power matters so little
Between power 2 and power 4 the RMSE moved from 54.0 m to 50.9 m β under 6%. The reason is that within a small neighbourhood, all reasonable weightings put most of the weight on the same two or three nearest samples.
The power only becomes decisive at the extremes: at 0.5 the weights are nearly equal and the surface is a local mean (72.2 m), and above 6 it is nearly nearest-neighbour.
Why IDW cannot give you uncertainty
There is no statistical model behind IDW. The weights come from an exponent you chose, not from measured spatial structure, so there is no expression for the variance of the prediction.
What you can and should report instead is the distance to the nearest sample, which is the geometric part of uncertainty. If you need a real variance, use kriging β while remembering that kriging variance overstated the true error by 68% in How to fit a variogram and krige a surface.
Edge cases or notes
- Project first. Degrees are not metres, and IDW weights by distance.
- Guard the zero distance. A target exactly on a sample gives
1/0; take that sample's value. cKDTree.queryreturns 1-D arrays whenk=1. Reshape, or the weighting silently transposes.- Set
kto 6β12 and capmax_distance. - IDW never extrapolates beyond the sample range. Predictions are bounded by the minimum and maximum observed values, so a true peak between samples is always underestimated.
- Duplicate coordinates are harmless for IDW (unlike kriging) but double-count that location.
- Use a projected equal-area CRS for large areas; UTM distorts distance beyond a few zones.
- A tie at the
kth neighbour is resolved arbitrarily by the tree, which makes the surface very slightly non-deterministic across scipy versions.
Internal links
- Spatial interpolation explained β how error grows with distance
- My IDW surface is full of bullseyes β the artefact this method always produces
- IDW, kriging, splines or TIN? Choosing an interpolator β when to use something else
- How to fit a variogram and krige a surface in Python β the accurate alternative
- How to choose a cell size for an interpolated surface β sizing the output grid
- How to cross-validate an interpolated surface β tuning
khonestly - The variogram explained β where
max_distancecomes from - My interpolated surface extends far beyond the data β why to cap the search radius
FAQ
How do I do IDW in Python?
Build a scipy.spatial.cKDTree over the sample points, query the k nearest for each target, weight them by 1/distance^p and take the weighted mean. Guard the zero-distance case.
What power should I use for IDW?
Between 2 and 4. The difference across that range was under 6% here, so it is not worth extensive tuning.
How many neighbours should IDW use?
Six to twelve. Using all 500 samples raised RMSE from 51.5 m to 90.1 m because distant points drag every prediction to the global mean.
Why does my IDW surface look like a set of rings?
Because IDW is an exact interpolator, so every sample is a local extremum β 83.1% of sample locations were extrema in the surface measured here.
Can IDW extrapolate?
It will produce values anywhere, but they are always bounded by the observed minimum and maximum, and the error grows steeply with distance β 18.9 m within 100 m of a sample, 334.3 m beyond a kilometre.
Does IDW give me an uncertainty estimate?
No. There is no statistical model behind it. Report the distance to the nearest sample instead, or use kriging.
Should I use IDW or kriging?
IDW when you need something defensible quickly and the samples are dense and even. Kriging when the variogram shows clear structure, the field is anisotropic, or you need an uncertainty map. Kriging was 25% more accurate here.