From evidence to targets — a transparent prospectivity map¶
Take-home notebook · Workshop 2 · IC-TWEP 2026 · QuestFeed¶
Machine-learning prospectivity models need many known deposits to learn from. Across much of North Africa and the Middle East, the public record of mineral occurrences is thin — so a knowledge-driven model, where a geologist decides which evidence matters and how much, is often the honest choice. This notebook builds one for an orogenic gold district and then asks the essential question: does the map rank known mining areas highly?
Default district: In Ouzzal, Hoggar (Algeria) — gold workings along the East Ouzzalian shear zone, including the Amesmessa and Tirek areas.
Evidence used
| Evidence | Why it matters for orogenic gold | Data |
|---|---|---|
| Al-OH alteration (sericite) | fluids altered the rock around gold-bearing veins | ASTER 2000–2008 |
| Ferric iron | weathered sulphides leave iron-oxide stains | Sentinel-2 |
| Proximity to structures | shear zones channel the fluids | Copernicus DEM lineaments |
| Structural direction | the main shear zones here strike roughly north–south | Copernicus DEM lineaments |
Validation: mapped mining areas from the global mining polygons (Maus et al., 2022).
# @title 1 · Choose the district and build a common 30 m grid
DISTRICT = "In Ouzzal (Hoggar) — Algeria" # @param ["Mahd adh Dhahab — Saudi Arabia", "Bou Azzer — Morocco", "Sohar–Lasail — Oman", "In Ouzzal (Hoggar) — Algeria"]
D = {d["label"]: d for d in fetch_json("districts.json")}[DISTRICT]
p = f"districts/{D['id']}"
aster, a_tr, a_crs = fetch_raster(f"{p}/aster_indices.tif") # grid of reference: ASTER 30 m, UTM
H, W = aster.shape[1:]
def onto(rel, band, resampling=Resampling.average):
arr, tr, crs = fetch_raster(rel)
out = np.full((H, W), np.nan, np.float32)
reproject(arr[band].astype(np.float32), out, src_transform=tr, src_crs=crs, src_nodata=np.nan,
dst_transform=a_tr, dst_crs=a_crs, dst_nodata=np.nan, resampling=resampling)
return out
aloh = aster[0].astype(np.float32)
ferric = onto(f"{p}/s2_indices.tif", 0)
items = list(Client.open("https://earth-search.aws.element84.com/v1").search(collections=["cop-dem-glo-30"], bbox=D["bbox"]).items())
from odc.geo.geobox import GeoBox
gbox = GeoBox((H, W), a_tr, a_crs)
dem = load(items, bands=["data"], geobox=gbox, resampling="bilinear")["data"].isel(time=0).values.astype(np.float32)
known = fetch_vector(f"{p}/known_sites.geojson").to_crs(a_crs)
mined = features.rasterize([(g, 1) for g in known.geometry], out_shape=(H, W), transform=a_tr, fill=0).astype(bool)
valid = np.isfinite(aloh) & np.isfinite(ferric) & np.isfinite(dem)
print(f"{D['name']}: grid {W} × {H} at 30 m · {len(known)} mapped mining areas ({mined.sum() * 0.0009:.1f} km²)")
In Ouzzal (Amesmessa–Tirek), Hoggar: grid 2087 × 3328 at 30 m · 48 mapped mining areas (7.1 km²)
# @title 2 · Structures from the elevation model
def hillshade(z, az, alt=30, cell=30):
gy, gx = np.gradient(z, cell)
slope, aspect = np.arctan(np.hypot(gx, gy)), np.arctan2(-gx, gy)
a, e = np.radians(360 - az + 90), np.radians(alt)
return np.clip(np.sin(e) * np.cos(slope) + np.cos(e) * np.sin(slope) * np.cos(a - aspect), 0, 1)
z = np.nan_to_num(dem, nan=np.nanmedian(dem))
lines = []
for az in (0, 45, 90, 135):
lines += probabilistic_hough_line(canny(hillshade(z, az), sigma=2.5, low_threshold=0.05, high_threshold=0.15),
threshold=15, line_length=20, line_gap=3, rng=0)
L = np.array(lines, float)
strike = (np.degrees(np.arctan2(L[:, 1, 0] - L[:, 0, 0], L[:, 0, 1] - L[:, 1, 1])) + 360) % 180
def raster_lines(sel):
m = np.zeros((H, W), bool)
for (x0, y0), (x1, y1) in L[sel].astype(int):
n = max(abs(x1 - x0), abs(y1 - y0)) + 1
xs, ys = np.linspace(x0, x1, n).astype(int), np.linspace(y0, y1, n).astype(int)
ok = (xs >= 0) & (xs < W) & (ys >= 0) & (ys < H)
m[ys[ok], xs[ok]] = True
return m
all_lin = raster_lines(np.ones(len(L), bool))
ns = (strike < 20) | (strike > 160) # roughly north–south structures
ns_lin = raster_lines(ns)
d_all = ndimage.distance_transform_edt(~all_lin) * 0.03 # km
d_ns = ndimage.distance_transform_edt(~ns_lin) * 0.03
print(f"{len(L):,} lineament segments; {ns.sum():,} strike within 20° of north–south")
428 lineament segments; 279 strike within 20° of north–south
Fuzzy evidence and combination¶
Each evidence layer is rescaled to a fuzzy membership between 0 (no support) and 1 (full support): alteration indices rise linearly from their median to their 98th percentile; proximity falls linearly from 1 at a structure to 0 at 2 km. The layers are then combined with the fuzzy gamma operator (γ = 0.9), a standard choice in mineral-prospectivity mapping that rewards places where several lines of evidence coincide.
Change WEIGHTS to express your own geological judgement — that is the point of a knowledge-driven model.
# @title 3 · Build the favourability map
def rising(x):
v = x[valid]
lo, hi = np.percentile(v, 50), np.percentile(v, 98)
return np.clip((x - lo) / (hi - lo), 0, 1)
def near(d, km=2.0):
return np.clip(1 - d / km, 0, 1)
WEIGHTS = {"Al-OH alteration": 1.0, "ferric iron": 0.6, "near any structure": 0.6, "near N–S structure": 1.0} # edit
mu = {"Al-OH alteration": rising(aloh), "ferric iron": rising(ferric),
"near any structure": near(d_all), "near N–S structure": near(d_ns)}
w = {k: v / max(WEIGHTS.values()) for k, v in WEIGHTS.items()}
layers = [np.clip(mu[k] * w[k], 1e-6, 1) for k in mu]
prod = np.prod(layers, axis=0)
alg_sum = 1 - np.prod([1 - l for l in layers], axis=0)
GAMMA = 0.9
fav = (alg_sum ** GAMMA) * (prod ** (1 - GAMMA))
fav[~valid] = np.nan
shade = hillshade(z, 315)
p80, p995 = np.nanpercentile(fav, [80, 99.5])
fig, ax = plt.subplots(1, 2, figsize=(15, 6.8))
for a in ax:
a.imshow(shade, cmap="gray", vmin=0, vmax=1)
im = ax[0].imshow(np.where(fav >= p80, fav, np.nan), cmap="inferno", vmin=p80, vmax=p995, alpha=0.9)
ax[0].contour(mined.astype(float), levels=[0.5], colors="cyan", linewidths=0.9)
ax[0].set_title("Most favourable 20 %\n(cyan = mapped mining areas)"); ax[0].axis("off")
fig.colorbar(im, ax=ax[0], shrink=0.7, label="favourability")
top10 = fav >= np.nanpercentile(fav, 90)
ax[1].imshow(np.where(top10, 1, np.nan), cmap=ListedColormap(["#F59E0B"]), interpolation="nearest", alpha=0.9)
ax[1].contour(mined.astype(float), levels=[0.5], colors="#0E7C86", linewidths=0.9)
ax[1].set_title("Top 10 % most favourable ground\n(teal = mapped mining areas)"); ax[1].axis("off")
plt.tight_layout(); plt.show()
Honest validation: a success-rate curve¶
Rank every pixel from most to least favourable. Walking down that ranking, how quickly do we capture the known mining areas? A random map captures them in proportion to area (the diagonal); a useful map captures them much faster. The area under the curve summarises it: 0.5 = no better than random, 1.0 = perfect.
Caveat: mapped mining areas are not the same as deposits — artisanal workings, for example, follow accessible and often alluvial gold — so treat this as a sanity check, not a measure of discovery power.
# @title 4 · Success-rate curve
trapezoid = getattr(np, "trapezoid", None) or getattr(np, "trapz") # name differs across NumPy versions
f = fav[valid]; t = mined[valid]
if t.sum() < 30:
print("Too few mapped mining areas in this district for a meaningful validation — the map is shown for illustration only.")
else:
order = np.argsort(-f)
captured = np.cumsum(t[order]) / t.sum()
area = np.arange(1, len(f) + 1) / len(f)
auc = trapezoid(captured, area)
k10 = captured[int(0.1 * len(f))]
fig = go.Figure()
fig.add_trace(go.Scatter(x=100 * area[::200], y=100 * captured[::200], name="favourability map", line=dict(color="#B45309", width=3)))
fig.add_trace(go.Scatter(x=[0, 100], y=[0, 100], name="random", line=dict(color="#9CA3AF", dash="dash")))
fig.update_layout(title=f"Success rate — AUC {auc:.2f}; the top 10 % of the ranking captures {100 * k10:.0f} % of mapped mining ground",
xaxis_title="% of district, from most favourable", yaxis_title="% of mapped mining ground captured",
height=440, **LAYOUT)
fig.show()
# Which evidence carries the result? Drop one layer at a time and re-score.
rows = []
for k in mu:
keep = [np.clip(mu[j] * w[j], 1e-6, 1) for j in mu if j != k]
fk = ((1 - np.prod([1 - l for l in keep], axis=0)) ** GAMMA) * (np.prod(keep, axis=0) ** (1 - GAMMA))
ff = fk[valid]; o = np.argsort(-ff)
rows.append({"left out": k, "AUC without it": round(float(trapezoid(np.cumsum(t[o]) / t.sum(), area)), 3)})
display(pd.DataFrame(rows).set_index("left out"))
key = min(rows, key=lambda r: r["AUC without it"])
print(f"Most important evidence here: {key['left out']} — without it the AUC falls from {auc:.2f} to {key['AUC without it']:.2f}.")
hurt = [r["left out"] for r in rows if r["AUC without it"] > auc + 0.01]
if hurt:
print("Not helping in this district: " + ", ".join(hurt) + " — the AUC rises when it is left out, so consider "
"lowering its weight in WEIGHTS (and ask why: different deposit style, cover, or weathering).")
| AUC without it | |
|---|---|
| left out | |
| Al-OH alteration | 0.615 |
| ferric iron | 0.788 |
| near any structure | 0.711 |
| near N–S structure | 0.683 |
Most important evidence here: Al-OH alteration — without it the AUC falls from 0.73 to 0.61. Not helping in this district: ferric iron — the AUC rises when it is left out, so consider lowering its weight in WEIGHTS (and ask why: different deposit style, cover, or weathering).
What the map does — and does not — tell you¶
- It ranks ground by how much evidence coincides; it does not prove that gold is present.
- Weights are a geologist's judgement; changing them changes the map. Record and justify them.
- With more and better data — national occurrence databases, geochemistry, airborne geophysics — the same framework grows into a data-driven model with spatial cross-validation and explanations.
Data: ASTER L1T (NASA/METI) · Sentinel-2 (contains modified Copernicus Sentinel data, ESA) · Copernicus DEM GLO-30 (© DLR e.V. and © Airbus Defence and Space GmbH, provided under COPERNICUS by the European Union and ESA) · Global-scale mining polygons v2 (Maus et al. 2022, CC BY-SA 4.0). Method: fuzzy-logic prospectivity mapping (Bonham-Carter, 1994).
QuestFeed · IC-TWEP 2026 take-home materials · questfeed.com