Skip to content

Note

Click here to download the full example code

quick tour

one pass from samples to a checked estimate. walker lake has 470 samples of V (ppm) over a 260 × 300 m area, and an exhaustive grid of 78 000 values over the same area to check the estimate against. each step fits in one cell and links to the page that covers it in full.

import sys
from pathlib import Path

HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import ACCENT, GRAY, INK, map_axes, save

samples = bt.datasets.walker_lake()
truth = bt.datasets.walker_lake_exhaustive()["V"].reshape(300, 260)
print(samples)

Out:

PointSet(470 points, crs: none)
  ID: Float64
  V: Float64
  U: Float64
  T: Float64

bt.datasets downloads a dataset once and caches it. the samples load as a PointSet: coordinates, plus one column per variable. the exhaustive values wait for the checks at the end.

statistics

a first campaign sampled a 20 m grid and later ones infilled the rich zones, so the plain mean of the samples overstates the area. cell declustering weights each sample by the inverse of the sample count in its cell and keeps the cell size with the lowest mean (declustering).

declustering = bt.cell_declustering(samples, "V", sizes=np.arange(2.5, 102.5, 2.5))
weights = declustering.weights
naive, declustered = bt.describe(samples["V"]), bt.describe(samples["V"], weights=weights)
print(f"declustering cell {declustering.cell_size:.1f} m")
print(f"{'':12}{'mean':>6}{'CV':>6}{'P50':>6}{'P90':>6}{'max':>6}")
for name, stats in (
    ("naive", naive),
    ("declustered", declustered),
    ("exhaustive", bt.describe(truth.ravel())),
):
    print(
        f"{name:<12}{stats['mean']:>6.0f}{stats['cv']:>6.2f}{stats['P50']:>6.0f}{stats['P90']:>6.0f}"
        f"{stats['max']:>6.0f}"
    )

fig, (a, b) = plt.subplots(1, 2, figsize=(9.5, 4.2), layout="constrained")
points = a.scatter(*samples.coords[:, :2].T, c=samples["V"], s=8, vmin=0, vmax=1500)
map_axes(a, "Samples")
fig.colorbar(points, ax=a, shrink=0.8, label="V (ppm)")
bt.plot.histogram(samples["V"], weights=weights, bins=30, stats=True, ax=b, color=ACCENT)
b.set(title="Declustered histogram", xlabel="V (ppm)")
save(fig, "samples")

Samples, Declustered histogram

Out:

declustering cell 22.5 m
              mean    CV   P50   P90   max
naive          435  0.69   424   819  1528
declustered    291  0.88   235   636  1528
exhaustive     278  0.90   221   634  1631

declustering takes the mean from 435 to 291 ppm, 13 ppm above the exhaustive mean, and brings the median and P90 close to the true ones. the histogram is skewed to the right, with a peak of near-zero values and a thin tail up to 1528 ppm.

top cut

Capping clips the grades at a cap chosen from the data, here the declustered P99 (top cuts chooses a cap, and capping transform compares the rules).

capping = bt.Capping(quantile=0.99).fit(samples["V"], weights=weights)
samples = samples.with_column("V_cut", capping.transform(samples["V"]))
print(
    f"cap {capping.caps_:.0f} ppm: {(samples['V'] > capping.caps_).sum()} samples cut, "
    f"{capping.metal_removed_:.1%} of the metal removed"
)

Out:

cap 982 ppm: 14 samples cut, 0.7% of the metal removed

the cap trims less than 1 % of the metal, since V has no erratic tail. the later steps use the capped column V_cut.

variogram

experimental_variogram computes γ(h) along one azimuth. Variogram.fit_directional fits one model (two spherical structures with a nugget) to eight directions at once and finds the direction of greatest continuity (experimental variograms, variogram fitting).

azimuths = np.arange(0, 180, 22.5)
directional = [bt.experimental_variogram(samples, "V_cut", 10.0, 120.0, azimuth=a) for a in azimuths]
model = bt.Variogram.fit_directional(
    directional, [(a, 0) for a in azimuths], ["spherical", "spherical"], weighting="count/gamma"
)
print(model)

major = model.rotation[0]
fig, ax = plt.subplots(figsize=(5.5, 3.6), layout="constrained")
for azimuth, color in ((major, ACCENT), (major + 90, GRAY)):
    experimental = bt.experimental_variogram(samples, "V_cut", 10.0, 120.0, azimuth=azimuth)
    bt.plot.variogram(
        experimental,
        variogram=model,
        direction=(azimuth, 0),
        ax=ax,
        color=color,
        label=f"N{azimuth % 180:.0f}°",
    )
ax.set(title="Along and across the major axis", xlabel="Lag distance (m)", ylabel="γ(h) (ppm²)")
ax.legend(loc="lower right")
save(fig, "variogram")

Along and across the major axis

Out:

Variogram(nugget=8867.786210090486, structures=[Structure("spherical", sill=30452.04768457904, range=24.786734362667683), Structure("spherical", sill=45879.78702049813, range=115)], rotation=(159.196766259666, 0.0, 0.0), ratios=(0.3373516036246563, 1.0))

V is most continuous along N159°, with a range of 115 m. across it the range drops to about 40 m (a ratio of 0.34). the nugget holds a tenth of the sill.

kriging

ordinary kriging estimates V on a 5 m grid from up to 24 samples in an ellipse aligned with the variogram. fit takes the samples and the column, predict a BlockModel, and with_columns stores the results on the model (ordinary kriging, search). the grid nodes fall on points of the exhaustive grid, so the true value at each node joins the model as a second column.

grid = bt.BlockModel(origin=(3, 3), size=(5, 5), count=(52, 60))
search = bt.Search(radius=80, max_samples=24, min_samples=4, rotation=model.rotation, ratios=(0.5, 1.0))
kriging = bt.OrdinaryKriging(model, search).fit(samples, "V_cut")
nodes = grid.coords.astype(int)
grid = grid.with_columns(
    {"estimate": kriging.predict(grid), "truth": truth[nodes[:, 1] - 1, nodes[:, 0] - 1]}
)
print(grid)

extent = (0.5, 260.5, 0.5, 300.5)
fig, axes = plt.subplots(1, 2, figsize=(9, 4.6), layout="constrained")
for ax, name, title in ((axes[0], "estimate", "Ordinary kriging"), (axes[1], "truth", "True V at the nodes")):
    image = ax.imshow(grid.grid(name)[0], origin="lower", extent=extent, vmin=0, vmax=1500)
    map_axes(ax, title)
axes[0].scatter(*samples.coords[:, :2].T, s=2, color=INK, linewidths=0)
fig.colorbar(image, ax=axes, shrink=0.8, label="V (ppm)")
save(fig, "estimate")

Ordinary kriging, True V at the nodes

Out:

BlockModel(regular, 3120 of 3120 cells, count [52, 60, 1], size [5.0, 5.0, 1.0], rotation [0.0, 0.0, 0.0])
  estimate: Float64
  truth: Float64

the estimate draws the NNW-trending rich zones of the truth, smoothed. kriging averages samples, so it misses the short-scale highs and lows.

checks

leave-one-out cross-validation re-estimates each sample from the others (cross-validation). a swath compares mean grades slice by slice for the declustered samples, the estimate and the truth (model checks, swaths).

cv = kriging.cross_validate()
print(f"cross-validation: RMSE {cv.rmse:.0f} ppm, correlation {cv.correlation:.2f}, slope {cv.slope:.2f}")
check = bt.compare("estimate", "truth", data=grid)
print(f"against the truth: RMSE {check['rmse']:.0f} ppm, correlation {check['correlation']:.2f}")
print(
    f"mean: estimate {grid['estimate'].mean():.0f}, declustered samples {declustered['mean']:.0f}, "
    f"truth {grid['truth'].mean():.0f} ppm"
)

fig, (a, b) = plt.subplots(1, 2, figsize=(10, 4), layout="constrained")
bt.plot.cross_validation(cv, ax=a)
a.set_title("Leave-one-out cross-validation")
series = [
    bt.swath(samples, "V_cut", 20.0, axis="y", weights=weights),
    bt.swath(grid, "estimate", 20.0, axis="y"),
    bt.swath(grid, "truth", 20.0, axis="y"),
]
bt.plot.swath(series, labels=["declustered samples", "estimate", "truth"], ax=b)
for line, color in zip(b.lines, (GRAY, ACCENT, INK), strict=True):
    line.set_color(color)
b.legend()
b.set(title="Swath along northing, 20 m slices", xlabel="Northing (m)", ylabel="V (ppm)")
save(fig, "checks")

Leave-one-out cross-validation, Swath along northing, 20 m slices

Out:

cross-validation: RMSE 179 ppm, correlation 0.78, slope 1.04
against the truth: RMSE 154 ppm, correlation 0.79
mean: estimate 288, declustered samples 291, truth 277 ppm

the slope of 1.04 shows no conditional bias. cross-validation (RMSE 179 ppm) and the truth (RMSE 154 ppm) give errors of the same size. the estimate follows the swath of the truth and flattens its peaks. its mean matches the declustered samples and sits 4 % above the truth, a bias that no check against the samples can detect. storing containers in parquet saves a grid like this one to a file.

Total running time of the script: ( 0 minutes 0.798 seconds)

Download Python source code: example_01_01.py

Download Jupyter notebook: example_01_01.ipynb

Gallery generated by mkdocs-gallery