17. Case study: a folded quartz vein

The last case study is the one the first two could not be: a real orebody in three dimensions, logged in drillholes, where the deliverable is a surface rather than a map. Fifty-three holes intersect a thin quartz vein folded through the host rock. Two categories, Vein and Waste, and one question: where is the contact, and how sure are we?

This is also the chapter where a stationary model is visibly not enough, which makes it the natural home for the deep network of chapter 5. Both models are built and both surfaces are drawn, so the cost and the gain are on the same page.

The data is fetched from the public copies used by the tutorial notebooks, about 20 kB in total, and cached beside the chapter, so only the first run needs a connection.

17.1 The database

import io
import os
import urllib.request

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import geoml

os.makedirs("figures", exist_ok=True)
os.makedirs("data", exist_ok=True)
geoml.set_seed(1234)

BASE = "https://drive.google.com/uc?export=download&id="
SHEETS = {"vein-collar.csv": "1AT-Hp540CzBP6UFDt6X7MwNTgQ0NA8Gs",
          "vein-lito.csv": "1ktNiQmvsETtIlXu1fhHFW3jzHRFI0BvW"}

for name, key in SHEETS.items():
    local = os.path.join("data", name)
    if not os.path.exists(local):
        raw = urllib.request.urlopen(BASE + key, timeout=60).read()
        pd.read_excel(io.BytesIO(raw)).to_csv(local, index=False)

collar = pd.read_csv("data/vein-collar.csv")

# the log also carries per-interval coordinates; they are not variables,
# and geoML computes them from the collar and survey anyway (chapter 9)
lito = pd.read_csv("data/vein-lito.csv",
                   usecols=["HOLE", "FROM", "TO", "SIMPLE LITO"])

holes = geoml.data.DrillholeData(
    collar,
    hole="HOLE", x="EAST", y="NORTH", z="RL", length="TDEPTH")

holes.add_intervals("lito", lito,
                    hole="HOLE", fr="FROM", to="TO",
                    categorical=["SIMPLE LITO"])

print(holes)

Fifty-three vertical holes and about 5.6 km drilled: a small database by any standard, and the point of the chapter is that the geometry, not the sample count, is what makes it hard.

Note what the summary reports: 247 intervals from a file with 250 rows. The three missing ones belong to a hole, GL76, that the lithology log records and the collar file does not, so there is nowhere to put them in space. add_intervals warns and drops them rather than guessing a collar, which is the behaviour to want. A silent guess here would place three intervals at a fabricated location, and every model downstream would honour them. Real databases disagree with themselves at the edges, and the useful thing a tool can do is say where. Pass on_error="raise" to make it a hard stop instead, which is the right setting in a pipeline that should never proceed on a partial database.

17.2 From logged intervals to contacts

Chapter 9’s conversion applies, with one preparatory step. The log records many touching intervals of the same rock, and the contacts are what carry information about the surface. merge_domains collapses each run of one category into a single interval, so that the conversion drops points along genuine runs and puts a contact only where the rock actually changes.

merged = holes.merge_domains(("lito", "SIMPLE LITO"))
holes.add_intervals("lito", merged)

# two conversions from the same logs, at two spacings
dense = holes.as_classification_input(("lito", "SIMPLE LITO"), length=1.0)
sparse = holes.as_classification_input(("lito", "SIMPLE LITO"), length=10.0)

print(dense.tree())
print(dense.n_data, "training points,",
      sparse.n_data, "inducing points")
print("contacts:", int(np.sum(dense.values("SIMPLE LITO/boundary"))))

The dense conversion is what the model is trained on, and the sparse one is where the inducing points go. That is chapter 3’s separation made concrete: the inducing set summarizes the region, and there is no reason for it to be as dense as the data.

coordinates = np.asarray(dense.coordinates)
rock = dense.values("SIMPLE LITO/measurements_a")

figure = plt.figure(figsize=(7.5, 6))
axes = figure.add_subplot(projection="3d")

for name, colour, size in [("Waste", "0.8", 1), ("Vein", "goldenrod", 6)]:
    mask = rock == name
    axes.scatter(coordinates[mask, 0], coordinates[mask, 1],
                 coordinates[mask, 2], s=size, c=colour, label=name,
                 depthshade=False)

axes.legend(loc="upper left")
axes.set_xlabel("East")
axes.set_ylabel("North")
axes.set_zlabel("RL")
axes.view_init(elev=28, azim=-120)

figure.savefig("figures/17-vein-holes.png", dpi=150, bbox_inches="tight")

The logged vein intersections

The vein intersections trace a surface that dips and rolls: a shape a single anisotropy ellipsoid can approximate but not follow.

17.3 The stationary baseline

The model of chapter 6, in 3D. One latent field, turned into two category indicators by a Linear node, with an ellipsoid initialized from a look at the data and free to move during training. Both models in this chapter share that construction, so it is worth naming once. The stationary one trains on the whole dataset at every step, a thousand of them.

def implicit_model(root_node, data, isotropic=False):
    """A one-field implicit model on the given input node."""
    field = geoml.latent.BasicGP(root_node, size=1, isotropic=isotropic)
    indicators = geoml.latent.Linear(field, size=2)

    return geoml.models.VGPNetwork(
        data, "SIMPLE LITO",
        geoml.likelihood.CategoricalGaussianIndicator(2),
        indicators,
        options=geoml.models.GPOptions(jitter=1e-6, training_batch_size=500,
                                       verbose=False))


flat_input = geoml.latent.BasicInput(
    inducing_points=sparse,
    transform=geoml.transform.Anisotropy3D(
        100, 1.0, 0.5, azimuth=270, dip=45),
    center=True)

flat_model = implicit_model(flat_input, dense)
flat_model.set_learning_rate(2e-2)
flat_model.train_full(max_iter=1000)

17.4 The deep model: moving the ground before modelling it

Chapter 5’s argument, applied. Rather than asking one ellipsoid to describe a folded surface, let a vector field move the coordinates and model a stationary field in the moved space. GPWalk integrates the movement in a few steps, and everything downstream is unchanged: the same one-field implicit model, reading transformed coordinates – with one range instead of three. The ellipsoid still carries the anisotropy the model starts from; the walk bends the space from there, and a range per direction in the field reading it would be a second way to say the same thing, which training resolves into a stretched, overconfident field.

The deep model trains differently. It has more local optima than the stationary one, so it trains on minibatches of 500 rows (chapter 11’s train_svi), whose noise helps it out of a poor start: twenty epochs at a high learning rate to find the shape, then sixty at a lower one to settle it. set_learning_rate restarts the optimizer, which is the point of calling it between the two. Where it starts still matters: built after the stationary model has drawn from the random stream, it settled on a fragmented vein, so it gets the chapter’s seed again, which the figures below were drawn from. Trying a few seeds and keeping the one with the best held-out score (chapter 13) is the honest way to choose.

geoml.set_seed(1234)

deep_input = geoml.latent.BasicInput(
    inducing_points=sparse,
    transform=geoml.transform.Anisotropy3D(
        100, 1.0, 0.5, azimuth=270, dip=45),
    center=True)

displacement = geoml.latent.BasicGP(deep_input, size=3)
walked = geoml.latent.GPWalk(displacement, n_steps=5)

deep_model = implicit_model(walked, dense, isotropic=True)
deep_model.set_learning_rate(5e-2)
deep_model.train_svi(20)
deep_model.set_learning_rate(1e-2)
deep_model.train_svi(60)

figure, axes = plt.subplots(figsize=(7, 4.2))
axes.plot(flat_model.training_log, label="stationary (iterations)")
axes.plot(deep_model.training_log, label="deep (GPWalk, minibatches)")
axes.set_xlabel("step")
axes.set_ylabel("ELBO")
axes.legend()

figure.savefig("figures/17-elbo.png", dpi=150, bbox_inches="tight")

What the walked input buys, in ELBO

The stationary model ends at the higher bound, and that is not the verdict it looks like. A bound rewards fitting the training points, and a thousand full steps on one ellipsoid buy that hole by hole; the deep model’s bound is a minibatch estimate, noisier and lower, and it spends part of what it has on a vector field. Which vein is the better one is a question about the ground between the holes, which neither bound sees – the surfaces below are the first look at it, and chapter 13’s held-out check the honest one.

17.5 The surfaces

The deliverable is the zero level set of the vein’s indicator. Contour it out of a block model, then predict onto the resulting surface so that every triangle carries the model’s uncertainty there. The blocks start at 20 m and refine (chapter 8) cuts them down to 2.5 m wherever the vein’s boundary runs through them, so the resolution goes to the contact and nowhere else. A regular grid at 2.5 m would hold two million points for the same surface, and a coarser one draws a walked surface in steps: the walk folds the space, which puts sharper features on the lattice than a stationary field does. Both models get the same treatment.

surfaces = {}

for name, trained in [("stationary", flat_model), ("deep", deep_model)]:
    blocks = geoml.data.BlockSet3D(
        start=[24860, 15710, 1310],
        n=[15, 18, 15],
        step=[20.0, 20.0, 20.0],
        discretization=(2, 2, 2),
        max_levels=3)
    blocks = geoml.models.refine(trained, blocks)
    surface = blocks.get_contour("SIMPLE LITO/Vein/indicator_predicted", 0.0)
    trained.predict(surface)
    surfaces[name] = surface
    print("%-11s %6d blocks, %6d triangles, %8.0f m2"
          % (name, blocks.n_data, len(surface.triangles), surface.area))

The contour is taken at zero because the category indicators are log-odds. The surface where Vein stops losing to Waste is the contact, and chapter 6’s ind_skew is the same quantity read as a cut-off. Predicting onto the surface is the second call: a mesh is a spatial object like any other, so it takes a prediction, and what it carries is the model’s uncertainty about the very boundary it draws.

figure = plt.figure(figsize=(13, 5.6))

for position, name in enumerate(["stationary", "deep"], start=1):
    surface = surfaces[name]
    vertices = np.asarray(surface.coordinates)
    uncertainty = surface.values("SIMPLE LITO/uncertainty")

    axes = figure.add_subplot(1, 2, position, projection="3d")
    drawn = axes.plot_trisurf(
        vertices[:, 0], vertices[:, 1], vertices[:, 2],
        triangles=surface.triangles, cmap="cividis", linewidth=0,
        antialiased=False)

    # colour by uncertainty rather than by elevation, which is what
    # plot_trisurf would use if left to itself
    drawn.set_array(uncertainty[surface.triangles].mean(axis=1))
    drawn.set_clim(0.0, 1.0)

    figure.colorbar(drawn, ax=axes, shrink=0.55, label="uncertainty")
    axes.set_title(name)
    axes.set_xlabel("East")
    axes.set_ylabel("North")
    axes.set_zlabel("RL")
    axes.set_zlim(1300, 1600)
    axes.view_init(elev=34, azim=-118)

figure.savefig("figures/17-vein-surface.png", dpi=150,
               bbox_inches="tight")

The two modelled veins, coloured by how sure the model is

The colour is the part a CAD-drawn wireframe cannot carry. Both surfaces are dark where holes constrain them and pale where they do not, which is the map of where the next hole is worth drilling.

The difference between the two is the fold. One ellipsoid has to describe a surface whose attitude changes along strike, and it cannot: the stationary answer breaks into pieces, loses the vein between hole fences, and flares into high-uncertainty skirts at the edges of the model. The walked input lets the same kernel follow the roll, and the deep answer is a single coherent sheet that stays with the intersections and only opens up past the last hole. The uncertainty colouring is what makes the comparison fair, because it distinguishes a surface the model is committing to from one it is guessing at.

17.6 Where the walk moved the ground

The deep model’s answer rests on a node nobody measured: walked, the coordinates after the walk. predict_node stops the model’s prediction at any node of the tree and writes what that node says into a container, as predict writes what the leaves say. Here it is asked where the walk takes every point of a plan section through the vein, and the answer is set against where the input put them before the walk.

section = geoml.data.Grid3D(start=[24850, 15700, 1450], n=[31, 36, 1],
                            step=[10, 10, 10])
moved = deep_model.predict_node(walked, section, labels=["u", "v", "w"])

# where the input puts the same points: its transform, about its centre
deep_input.transform.refresh()
before = np.asarray(deep_input.transform(
    np.asarray(section.coordinates) - deep_input.center))
shift = moved.get_predictions() - before
print("the walk moves a point by %.2f ranges at most, %.2f on average"
      % (np.linalg.norm(shift, axis=1).max(),
         np.linalg.norm(shift, axis=1).mean()))

plan = np.asarray(section.coordinates)
figure, axes = plt.subplots(figsize=(6.5, 6.5))
arrows = axes.quiver(plan[:, 0], plan[:, 1], shift[:, 0], shift[:, 1],
                     np.linalg.norm(shift, axis=1), cmap="viridis")
figure.colorbar(arrows, ax=axes, shrink=0.7,
                label="displacement, in ranges")
axes.set_aspect("equal")
axes.set_xlabel("East")
axes.set_ylabel("North")
axes.set_title("The walk at RL 1450, in the transformed space")

figure.savefig("figures/17-walk.png", dpi=150, bbox_inches="tight")

Where the walk moves each point of a plan section

The arrows live in the space the input’s ellipsoid made, where one unit is one range, so they say how far the walk carries each point measured in the lengths the stationary field sees. Where they are short the walk leaves the ground alone and the ellipsoid alone describes it; where they swing, the walk is doing the folding.

moved is a LatentVariable: one part per output of the node, each holding the node’s latent_mean and latent_variance and its realizations, all on the latent scale, since a node has no likelihood to back-transform through. It is saved, subsetted and read by path like any other variable, under the node’s own name unless name= gives another (section.values(moved.name + "/u/latent_mean") is the first coordinate). A node is predicted at points only; a block’s value is an average a likelihood takes, and a node has none.

17.7 Did the fold help?

A surface that fits better is not automatically a better model. The comparison that counts is on samples the model did not see, and with 53 holes the split that respects the geometry is by hole (chapter 13).

flat_model.predict(dense)
flat_predicted = dense.values("SIMPLE LITO/predicted").copy()

deep_model.predict(dense)
deep_predicted = dense.values("SIMPLE LITO/predicted").copy()

interior = ~dense.values("SIMPLE LITO/boundary").astype(bool)
truth = dense.values("SIMPLE LITO/measurements_a")

for name, predicted in [("stationary", flat_predicted),
                        ("deep      ", deep_predicted)]:
    agree = np.mean(predicted[interior] == truth[interior])
    print("%s in-sample agreement: %.3f" % (name, agree))

These are in-sample numbers, printed with that label because the point of chapter 13 is that in-sample numbers flatter every model. Here they flatter the stationary one: it agrees with every training point and draws the vein as tubes along the holes, where the deep model gives up a few points and draws one sheet between them. A model can memorize 53 holes in more than one way, so the honest comparison runs cross_validate with the folds cut on HOLEID, which every conversion in chapter 9 carried as metadata precisely for this. The numbers above illustrate the machinery rather than settle the geology.

In the code. geoml.latent.GPWalk is the SDE node, and geoml.transform.Anisotropy3D the ellipsoid it takes the burden off. VGPNetwork.predict_node writes any node’s prediction as a geoml.data.LatentVariable. models.refine cuts a data.BlockSet3D where the contact runs and BlockSet3D.get_contour(path, value) builds the surface; meshes take predictions like any container, and Mesh3D.simplify, .smooth and the booleans of chapter 12 apply to the result. DrillholeData.merge_domains collapses the runs the log records, and as_classification_input puts the contacts in at zero support.

Further reading

The potential-field formulation is Lajaunie et al. (1997), geoML’s machine-learning reading of it is the 2017 paper, and the categorical treatment used here is the 2023 one. The deep model and this dataset are the subject of the 2025 paper, where the fold is examined properly rather than at a manual’s iteration counts.

References

Gonçalves, Í. G. et al. (2017). A machine learning approach to the potential-field method for implicit modeling of geological structures. Computers & Geosciences.

Gonçalves, Í. G. et al. (2023). Variational Gaussian processes for implicit geological modeling. Computers & Geosciences. https://linkinghub.elsevier.com/retrieve/pii/S0098300423000274

Gonçalves, Í. G. et al. (2025). Uncertainty propagation in deep Gaussian process networks. Mathematical Geosciences. https://doi.org/10.1007/s11004-025-10187-4

Lajaunie, C., Courrioux, G., & Manuel, L. (1997). Foliation fields and 3D cartography in geology: principles of a method based on potential interpolation. Mathematical Geology, 29(4), 571–584.