Roadmap: open work, and what has already been decided
What is being considered, what was tried and dropped, and what was refused without trying. The second and third parts matter as much as the first: a rejection here carries the numbers that killed the idea, so it does not come back around.
Sizes are of the work, not the value: S a session or less, M a few sessions, L a workstream with its own design record, XL research with an uncertain end. Items marked [geostat] came from a 2026-08-16 survey of what the geostatistical toolbox has that this package does not.
Code-anchored tasks live as # CLAUDE: notes in the source instead; this
file is for the cross-cutting ones. Design records for finished work are the
docs/*.md files, published under “internals” on the documentation site.
1. Modelling and inference
(The tree’s leaves as the points of contact with the likelihoods, and
independent trees: done 2026-09-05, 0.6.10 — see “Settled by
measurement” below for the numbers. VGPNetwork takes a list of leaves,
one per likelihood, or a mapping from each variable to its likelihood; the
single node is still accepted and still split, bit-identically. Leaves on
roots of their own work with no join at all, which is what a tree of
inducing points near the drillholes beside a gridded tree for geophysics
needs.)
L — The expected kernel: deep networks that stop overfitting (agreed
2026-10-08, for 0.9.0; built 2026-10-08 on branch expected-kernel,
design record docs/expected-kernel.md: gates 1-3 passed – the kernel
within 0.0008 of 4e5 draws for every kernel, a leaf within 0.005 of draws
of its parent’s posterior through every supported operation, the folded
section’s deep networks at -9.0 and -11.1 a new hole against -25.4 and
-18.9 under the old rule and -12.2 for a VGP, calibrations 1.19 and 1.31
– and the walk’s first gate only at a short reach, see below). Found in the research
project Spatial cross-validation (report/new_covariance.md,
report/dgp_propagation.tex): a GP node hands its children only its
variance at each location, the child treats any two locations as
independent and inflates its range by half the input variance where the
Gaussian kernel’s own weight is 6, so a deep model’s leaf barely feels the
hidden layer’s uncertainty. Its predictive variance read 0.002 of the
model’s own Monte Carlo predictive, the bound bought a tiny noise variance
with that, and on a folded synthetic section scored per new drillhole:
Model |
Log score per new hole |
|---|---|
geoML |
−155.2 |
geoML |
−31.2 |
VGP |
−12.15 |
expected kernel, |
−9.26 |
oracle on the unfolded coordinates |
+3.1 |
The expected kernel E[k(h(x), h(x'))] needs the variance of the
difference h(x) − h(x'), so a node passes its children its covariance
between locations; the child stays an ordinary sparse GP on the root’s
inducing points at the same O(N·U²) cost, its KL and the likelihood door
unchanged. Decided:
An option, old saves untouched:
GPOptions(propagation=), the class attribute"marginal"(what an old save falls back to) and the__init__default"joint"; under"marginal"the code is today’s.expert_propagationlikewise defaults to"independent"for new models.What travels: per output, an N×U covariance (data against inducing points, made in
propagate) and a U×U one (inducing points against each other, made inrefresh). Mixing operations apply their rule entry by entry, as to the variances today, so the covariance between outputs is dropped after aLinearas it is now. A GP node’s own posterior covariance against its inducing points isk_x (K+Λ)⁻¹ Λ.The signature:
propagatereturns a private named tuple_Moments(mean, variance, covariance), each a list over experts,covariance[size, n, m_e]orNone(a deterministic node, or marginal mode); a refresh storesinducing_points_covariance[size, m_e, m_e]beside the variance, which stays its diagonal; slots carry the same with a leading slot axis.Experts chain per expert: child expert e reads its parent’s expert e at the data and at its inducing points; moments stay per expert along the chain and are blended only where a value leaves the tree (a leaf,
predict_node)."joint"with"consensus"is refused. Slots and subsets work under"joint"in 0.9.0, the subset gate (“every expert in the subset changes nothing”) held on shallow and deep trees.Kernels: the Gaussian in closed form;
Exponential,Matern32,Matern52andRationalQuadratic, scale mixtures of Gaussians, by a fixed-node 1-D quadrature over the mixing measure.SphericalandCubicare not scale mixtures (measured 2026-10-08: a Spherical Gram matrix in R⁶ has eigenvalue −0.026, and the best non-negative Gaussian mixture misses Spherical by 0.020 and Cubic by 0.015 against 6e-7 for Matern32), and are refused under a random input with Cosine refused in every GP node of a network; the remaining kernels cover the uses.Nodes: supported under
"joint"are the inputs (GaussianInput’s location variance entering the difference with no covariance against the inducing points),SelectInput,Linear,Scale,Bias,Add,LinearCombination,Concatenate,BasicGP,MultiStructureGP,AdditiveGPandGPWalk(below); refused under a random input areUncertainInputGPandRadialTrend, and a GP on aGradientConstrainedInput(item below); aGPWalk’s field takes a mixture kernel too, the walk reading it at uncertain positions;Multiply,Exponentiation,GaussianMixture,ProductOfExpertsandStacknever feed a GP and keep their moments.Every refusal lives in
VGPNetwork’s constructor under"joint", never in a node’s, so a save under"marginal"keeps opening.Realizations: a GP node draws as now,
K_eff(x,Z)·chol_r·normals + mean– the right marginal and the explained covariance, not conditioned on the parent’s realizations (that is the item “Propagate individual realizations” below, a different model).Changed while building: the walk’s own KL stays (measured necessary, see below), and the walk carries the field’s unexplained variance.
Left out: centring (identity plus amplitude) and the node-role rule; the input connection is the recommended construction, which the manual says, and nothing enforces it.
Gates: (1) the expected kernel against 2×10⁴ joint Monte Carlo draws of the parent, max error under 0.005, on one expert, several, and three layers; (2) on the folded section, 200 new holes,
"joint"beats"marginal"and the VGP on mean log score with calibration 1–1.5; (3) chapter 5’s deep GP rerun under"joint"and recorded. Nothing ships unless (1) and (2) pass.docs/benchmarks/expected_kernel.py.GPWalkbefore the release (added 2026-10-08): the walk carries the covariances its children need, the field read at each step under the expected kernel with the uncertainty the walk has accumulated, so an uncertain walker reads a weaker field and slows down (and far from the data drifts at the field’s bias). The walk is an Euler solve ofdw/dt = amp·f(w)along one uncertain field, not fresh noise each step: the field’s residual is taken fully correlated along a point’s path, so its standard deviations add (spread~K·s·sd_f, where an SDE’s would be~sqrt(K)·s·sd_f),n_stepsrefines the solve rather than adding noise, andstep·n_steps·ampis the reach. The covariance against the start positions (where the field’s inducing points sit) is updated exactly by Stein’s lemma with the expected gradient of the expected kernel; the covariances between two moved positions, which the children need, by the expected Jacobian on both sides. Departing from 0.8’s numbers is accepted: the node’s goal is analytical non-stationary kernel learning. The fallback, if the gate refuses the analytic walk, is Q fixed field samples walked exactly. Under"joint"theprecisionparameter (the variance divided by1 + precisioneach step) is ignored and on the deprecation list (section 5); the walk’s own KL was planned to go with it and was kept, measured (below). Realization s walks a field draw of its own from the simulation stream. Gates: the walk’s moments and covariances against 10⁴ brute-force trajectories on one expert and on several; aGPWalknetwork on the folded section under"joint"against"marginal"on the 200 new holes. Measured 2026-10-08: the linearized walk matches walks along sampled fields within their noise at a reach up to a tenth of the field’s range (mean 0.03 sd, variance ratio 0.96-1.05, covariance 0.05), and falls short beyond it – variance ratio down to 0.53 at amp 4 and 0.14 at amp 8, covariance off by 0.42 and 0.83. The folded section’s walk network trains to amp 3 and scores -10.75 a new hole against -31.65 under the old rule (VGP -12.17), median -1.67, but calibration 1.93. Settled 2026-10-08 (the user’s decision to measure the fallback first, then to price the walk): fixed field draws walked exactly scored the same (-10.95 and -11.57, calibration 1.95 and 2.07) at 3-6x the cost, and left chapter 16’s confident region over unsampled ground, so the linearization was never the problem – dropped. The cause of the region was the field’s unexplained variance, which the walk now carries per point (chapter 16’s geometry and metals’ calibration back to 0.8.8’s); the calibration’s was an unpriced deformation, which the walk’s displacement KL fixed on one seed (1.97 -> 1.26, score -10.46) where priors onampchanged nothing. Replaced 2026-10-09 (the user’s order of fixes, three seeds): the walk adds no KL, the GP reading it is advised to be isotropic, and the displacement term is deprecated withprecision– see “A principled price for the walk”. The walk is accurate to a reach of a fifth of the field’s range and errs both ways beyond it. Record:docs/expected-kernel.md, “The walk”.Phases: (1) the expected kernel and its quadrature as a function, gate 1 on one expert; (2)
_Momentsthrough every node under"marginal", covariance alwaysNone, the covering tests to the bit; (3)"joint"in the network – the two matrices, per-expert chains, the refusals,GPOptions– and gate 1 on several experts and three layers; (4) slots and subsets, the subset gate; (5) gates 2 and 3; (6)GPWalkand its gates; then the changelog, the manual (chapter 5, concatenation as the recommended construction), the skill anddocs/expected-kernel.md. The release waits for the user’s word. The range bound stays at its default unless gate 2 shows it decides the score; the covariance path takes the double-wheresquare root, whose second derivatives are finite at zero distance.
M — Eight Gaussians and the second moment in BasicGP (agreed
2026-10-08, the two items below; for 0.9.0; steps 1 and 1b built
2026-10-08: kernel errors 5.1e-4, 9.8e-6 and 9.1e-6, gate 1 unchanged,
chapter 5’s deep model 262 s to 74 s at 100 iterations with the same
scores; step 2 built 2026-10-08: Girard to 1e-10, Walker within 1.8%
for all four kernels, docs/benchmarks/second_moment.py; reworked
2026-10-09 after step 4’s cost to closed form for the Gaussian kernel and
64 points of quadrature for the others, Walker still within 1.8%; step 3 built
2026-10-08: the prediction within 1% of realizations at drawn inputs,
measurement quantiles within 4-5% of the interval, the rest the mixture’s
shape). Plan of record:
Tables.
Exponential,Matern32andMatern52read an uncertain input through 8 fitted Gaussians each (rates and weights stored as constants innetwork.py, weights summing to one; refitted bydocs/benchmarks/kernel_mixtures.py), independent of range and uncertainty; the rational quadratic keeps itsscale-adaptive rule and a certain input the exact kernel. Gate: kernel error <= 1e-3 each, the expected-kernel tests (the no-uncertainty tolerance relaxed to 1e-3), positive definite, speed on chapter 5 and chapter 16. 1b: the walk’s slopes in closed form per component, replacing a forward-mode pass per dimension.Second moment. Where the inducing points are certain (a
GaussianInputroot, throughLinear/SelectInput), under"joint"only:v = 1 - tr(C L) + alphaᵀ L alpha - m²,L_ij = E[k(x, z_i) k(x, z_j)]in closed form per pair of components (one for the Gaussian, 36 for a table),MultiStructureGPover pairs of structures,AdditiveGPwithin and across dimensions; the explained variancetr(C L). Deep networks (uncertain inducing inputs, the research’s ~11%) stay out – their own item, later. Gate: againstgirard()to 1e-10 (Gaussian), against the exact mixture on Walker Lake mean and variance within 2% at every input variance for all four kernels; bit-identical with no input variance; slots as sets.The input’s spread integrated like noise (the user’s choice over realizations at drawn inputs, after
docs/benchmarks/uncertain_input_realizations.py: on a 1-D field at an input sd of 0.4 the 5%/95% quantiles miss the exact mixture by 0.224 with the expected kernel alone, 0.092 integrated like noise, 0.038 at drawn inputs whose realizations turn spiky). The realizations stay the expected kernel’s; what they leave out because the input is uncertain,Delta = tr((R Rᵀ + alpha alphaᵀ) Cov k),Cov k = L - l lᵀ– never negative – rides beside them as a per-location latent jitter: a GP node returns it beside its four outputs (an attribute on the tuple, as_Moments),_predict_rawhands it to the likelihood, andintegrated_backwardandmeasurement_samplestakesims + sqrt(Delta) etaover a few Gauss-Hermite nodesetaon top of the noise nodes (about 8x the back-transform, at uncertain inputs only); the categorical likelihoods the same in their probabilities. Training needs nothing: the quadrature over the latent variance already integrates it. Gate:predictionand the quantiles in data units against the exact mixture through a nonlinear warping; with no input variance, bit-identical.Training gate on the
GaussianInputplan’s jittered locations: held-out coverage and CRPS against the expected kernel alone and againstUncertainInputGP; time and memory at m = 100 and 300 (Lisn m²per expert under a gradient). Done 2026-10-09: the second moment beats the first in every case and tiesUncertainInputGP; a location error is still better left untold. The closed form for a Matérn table cost 100x a first-moment iteration at m = 100 and ran out of 45 GB at 300 – the user chose the hybrid (closed form for the Gaussian, quadrature for the others): 8x at both sizes, 2.1 and 3.6 GB, once the kernel is read through plain distances (2026-10-09). Measured and dropped the same day: the Cholesky route (3% of a step), the input’s linear part taken analytically (no change: the whitened nodes integrate it already), and experts for speed (none;train_by_expertsaves memory).UncertainInputGPdeprecated with a warning namingBasicGP, removed in the breaking version; changelog, design record, skill. Done 2026-10-09.
S — Fewer components for the Matérn kernels’ expected kernel (measured
2026-10-08; built as step 1 of the item above). The fixed trapezoid (52, 32 and 28 components for the
exponential, Matern32 and Matern52) is accurate to 1e-7, far below any
gate; the best positive mixture of 8 Gaussians, fitted once and stored as
constants (weights summing to one, so the diagonal stays exact), misses the
kernel by 5.5e-4, 1.5e-4 and 9.4e-5 on [0, 3] ranges, which bounds the
expected kernel’s error too (the expectation is linear in the mixture) –
3.5 to 6.5 times fewer evaluations, still positive definite by
construction. With it, the walk’s slopes in closed form (each component’s
derivative in the mean is -2 w mu / (r² (1 + 2 w v)) times itself) rather
than one forward-mode pass per dimension. Rejected on the way:
Paciorek’s form with the kernel substituted, prod (1 + kappa v)^(-1/2) R(sqrt(sum mu² / (1 + kappa v))), its inflation kappa fitted per kernel
(23.8, 10.4, 8.1): it is the expectation only for the Gaussian, and misses
it by 0.10-0.13 (exponential), 0.04-0.06 (Matern32) and 0.03-0.04
(Matern52) in one to three dimensions; positive definite for independent
inputs (Paciorek’s theorem, by congruence) and not guaranteed with
correlated ones, though no failure turned up on near-singular lattices.
M — A principled price for the walk (from the expected kernel,
2026-10-08; settled 2026-10-09: the field’s KL the only price, and the
GP reading the walk built isotropic – documented, not enforced – -6.1 a
new hole on the folded section’s three seeds against the displacement
term’s -10.7, intervals erring wide (calibration 0.45-1.14) where the
term’s erred narrow (1.14-2.24). The reader’s ranges held where they start
scored -5.0 and were the library’s for a day; the user’s reading of
chapter 17 put the trouble in the ranges differing between dimensions, not
their scale, and the starting anisotropy in the input’s transform, where
the user sets it. The fixes tried in the user’s order: a proper zero-displacement prior through the field’s
fixed scale (amp 0.3, 1, 3: -12.3, -12.3, -9.5, no scale right on every
seed), the trade-off priced (the leaf’s range prior at 10 and 50: -9.8 and
-6.8; held at 0.5, 1, 2: -5.3, -5.0, -5.4), and an uncertain amp
(linearized, its uncertainty collapsed to 0.05-0.07 on two seeds: -11.3).
Diagnosis: freezing one group at a time put the cause in the walk’s stretch
and the reader’s range being one degree of freedom. Record:
docs/expected-kernel.md, “The walk”; docs/benchmarks/walk_price.py.
What follows is the plan as it stood.) The walk’s KL under the expected kernel is the marginal rule’s
term, 1/2 sum (m_walked - m_start)² / v_walked over the inducing points:
not a KL (it treats the start as a prior and divides by the posterior’s
variance, with no trace or log-determinant), and since the displacement and
its spread both scale with amp, in effect a penalty on the field’s
signal-to-noise ratio where it moves points. It works – the folded
section’s calibration 1.97 -> 1.26, where MAP priors on amp (Gamma at mode
1, exponential) changed nothing – because it keeps the walk uncertain. In
a proper bound the walk adds no random variable of its own, so no KL; what
the point estimates miss is the uncertainty in the deformation’s scale.
The principled version: amp a random variable, a log-normal variational
posterior q(log amp) against a prior whose base model is no deformation
(an exponential on amp, the PC prior), KL(q || p) in the bound, and the
walk’s moments averaged over q – a few Gauss-Hermite nodes in log amp,
each a walk, the mean and covariance by the law of total variance over
them; the realizations each at a node. Gate: on the folded section without
the displacement term, calibration within 1-1.5 and a score at least the
heuristic’s (-10.46); chapter 16’s rock maps no worse; the cost (one walk
per node) against the term it replaces.
M — The second moment of a GP at an uncertain input (Girard’s term,
measured 2026-10-08 on Walker Lake, GaussianInput root; built
2026-10-09 as steps 2-4 of the item above, UncertainInputGP
deprecated). Under the
expected kernel BasicGP’s mean at an uncertain input is within 1-2% of
the exact mixture’s (better than UncertainInputGP’s 32 Sobol nodes, 3-9%),
but its variance misses the variance of the mean over the input – the
matched GP’s variance is 1 - E[k] (K + D)^-1 E[k] – by 18-133% for the
Gaussian and 8-89% for the Matern32 at input variances of 0.01-3 squared
ranges, where UncertainInputGP is within 1-3%. So UncertainInputGP is
not obsolete for a single node on an uncertain input. The fix that would
retire it: E[k kᵀ] (closed for the Gaussian, Girard; through the mixture
for the others) for the variance, tr((K+D)^-1 Cov k) - alphaᵀ Cov(k) alpha; in a deep network the same term is what the research measured at
~11% of the leaf’s variance.
M — Covariance between a node’s outputs (from the expected kernel,
2026-10-08). Mixing operations drop it, as they drop it for the variances
today; the closed form takes a full S×S block per pair of locations
(det(I + 6Σ/r²)^(-1/2) exp(-3 mᵀ(r²I + 6Σ)⁻¹ m)) at [n, m, S, S] and a
determinant a pair. Worth it only if a tree mixing columns under a GP
measures worse than the same tree unmixed.
M — UncertainInputGP, RadialTrend and GradientConstrainedInput
under the expected kernel (from the expected kernel, 2026-10-08). Refused
under "joint": a GP node on a GradientConstrainedInput, a random root
whose covariance between locations is not carried (its refresh also hands
on predictions at the directional rows, which no child can read);
UncertainInputGP’s Sobol mixture of marginals is what the
expected kernel replaces, and it is deprecated since 2026-10-09 (removed
with the simplification item in section 5); RadialTrend is a nonlinear function of its parent’s
mean whose variance is dropped.
L–XL — Batched experts: memory independent of the number of experts
(top priority, raised 2026-10-05). Train the product of experts on a
few experts per step, down to one, so that device memory does not grow
with the number of experts J. The research is a project of its own,
OneDrive\Claude\Research\Batched experts\: a literature review
(report/literature_review.tex, section 2), a notation table, and
Proposition 1. Proposition 1 says that sampling s experts per step, scaled
by J/s, gives an unbiased estimate of the bound when the bound splits into
independent blocks, one per expert, with only the hyperparameters shared;
it is checked by exact enumeration in
verification/expert_subsampling_unbiased.py. No published method was
found that samples experts per step together with data minibatches; the
closest is ProSpar-GP (Li & Mak, JCGS 2025), which minibatches data only.
That search is not yet complete.
What geoML does today (read 2026-10-05; the research assumed it without reading the code):
The bound does not split per expert.
BasicGP._momentspredicts every data point from every expert’s inducing set and blends them with_GPNode.get_expert_weights, soft weights normalized over all experts. A data point therefore reaches every expert, and Proposition 1 does not apply as stated. Only the KL term,kl_divergence, is a sum over experts.Per step,
refreshbuilds every expert: its covariance, Cholesky and inverse, and its smoothed covariance. That is the U_j² and U_j³ work, the memory to save. Under the defaultexpert_propagation= "consensus", a node with children also predicts every expert’s inducing set from every other, which is O(J²).The stored parameters are O(U_j) per expert (
alpha_white_i,delta_i,bias_i), so all of them can stay on the device and only the sampled experts’ computation need run. Data minibatching exists already (train_svi).
The plan of record (the user’s, 2026-10-06).
Training.
Cache each data point’s weight for each expert, in one sweep over the data: the expert weights the model already computes (
_GPNode.get_expert_weights), averaged over the latent outputs where a node has several (and over the GP nodes, where a tree has several on one root). An N by J table, refreshed once per epoch or less often.An epoch visits the experts in a random order. For expert j, a batch of data points is drawn with probability proportional to their weights for j. The batches are random, and they fall where j matters.
The batch updates the experts that are active on it. The points of expert j’s batch sit mostly in j’s ground, so the experts whose cached weight is above a small threshold there are j and a few neighbours. Only they are computed, and their local parameters (
alpha_white_i,delta_i,bias_i) are updated with each batch; the rest are truncated out of the blend on that batch.One property worth keeping. At a point the weights sum to one over the experts, so if expert j’s batch is sized by its total weight, each data point is drawn equally often over an epoch, in expectation: the epoch is a fair pass over the data. With equal batch sizes instead, each drawn point’s term is scaled by the inverse of its probability. Both to be tried. The data term is scaled to the whole data set as
train_sviscales it; how each batch counts the KL of the experts it updates is to be tested too.The global parameters (ranges, the input transform, the likelihood and its warping): test whether they can be updated at every step, or whether their gradients must be accumulated over the epoch and applied once. The accumulated gradient over a fair epoch is the full gradient’s estimate; a step per batch moves them on a gradient that sees one expert’s ground at a time.
Prediction.
The data’s cached weights are interpolated to the targets (grid nodes, blocks, points) by a simple method: the nearest data point, or an inverse-distance average of the k nearest, in the input’s transformed space. The result says which experts are active at each target.
Targets are grouped by their set of active experts, and each group predicted with only those experts. A small truncation of the weights is acceptable; its size is measured against predicting with every expert.
What any method must keep: a target’s set depends on the target alone, never on its batch, as the reproducibility contract promises; a rule for targets far from every expert, where today every weight sits at the floor and the prediction is the plain average of every expert’s prior, the k nearest say; and a block’s set is the union over its sub-blocks.
The code.
Ideally new methods on
VGPNetworkand little else: onepredict_*(the user’s words), and atrain_*beside it, since training needs an entry point too. The adaptations at the points of contact are kept surgical. The main one: the expert loops inBasicGP(refresh,_moments,kl_divergence) run over a given subset of the experts rather than all of them, set as a context flag the waypropagation_ruleis (so no signature changes). A traced function sees the subset as a Python tuple, which retraces once per distinct subset; if the subsets are few that is acceptable, and if not, the prototype measures what a tensor index costs.All experiments outside the package: a separate git worktree on its own branch (
batched-experts, fromclaude), with the scripts in itsexperiments/and nothing merged until the gates pass. The verification scripts stay in the research project.
Kept in reserve (2026-10-05, not the plan): freezing the other
experts’ contributions as stored moments, which gives the exact gradient
for one expert at a time; summing the global parameters’ contributions
over a sweep for their full gradient; making expert_propagation= "independent" the default, which leaves each expert’s inducing set to
itself at every node (measured 1.6x to 6.3x faster training as J grows
from 5 to 40); and, for prediction, the expert’s footprint in the
kernel’s space (exact for a compactly supported kernel), neighbouring
experts, coarse to fine through refine, accumulating the weighted sums
expert by expert, and a learned multi-label classifier, which is
published (Jalali & Kasneci, NeurIPS 2022 workshop, arXiv 2211.09940).
Also from the literature: selection through a sparse precision matrix
between experts (Jalali, Pawelczyk & Kasneci 2021, arXiv 2102.01496); a
gating network over sparse GP experts fitted by Cluster-Classify-Regress
(Etienam et al., Machine Learning 2024, arXiv 2006.13309); patchwork
kriging (Park & Apley, JMLR 2018); GRBCM (Liu et al., ICML 2018).
Steps, in order:
The worktree and a baseline. A synthetic case with many experts and a real one (Tom v6 or Assen): peak device memory, time per epoch and held-out scores, trained with every expert at once.
The weight table. One sweep caching the N by J weights, averaged over the latent outputs; how concentrated they are (how many experts carry, say, 99% of a point’s weight) decides how small the active sets and the truncation are.
The subset of experts at the points of contact, and its gate: with every expert in the subset, training and prediction identical to today’s to the bit.
The training loop: experts in random order, batches drawn by weight, the active experts updated per batch; batch sizes by total weight against equal sizes with the inverse-probability scaling; the global parameters stepped per batch against accumulated per epoch.
Training gates: peak memory flat in J; convergence and held-out scores against step 1’s baseline.
Prediction: the weights interpolated to the targets, the targets grouped by active set; against predicting with every expert, the error the truncation costs, measured, and memory per batch against J; then blocks and deep trees.
The novelty search finished: Spatial Statistics, JABES, Mathematical Geosciences, and the full texts of Zhang et al. (2023) and pFedGP.
Measured 2026-10-06 (prototype on branch batched-experts, worktree
geoML-batched, not merged; experiments in its experiments/ batched_experts/, results in results/*.jsonl, figures copied to the
research project). Synthetic field whose ground and data grow with J (400
rows and 150 inducing points per expert, 50-unit tiles), batches of 400,
on the GPU; Tom’s rock type (20 126 composites, a fifth of the holes held
out) as the real case.
The point of contact is
latent.expert_subset(experts), a context flagBasicGP’s loops (refresh,_moments,simulate,kl_divergence, nowexpert_kl_terms) read, with the refresh trace andpredict_rawkeyed by it and_log_liksplit into an unscaled_data_log_lik. Gate: with every expert in the subset the training log, predictions and realizations are the unchanged package’s to the bit, on shallow and deep trees under both propagation rules – single-threaded: with default threading the unchanged package itself drifts ~1e-14 between processes on a model this size, which op determinism does not remove. The methods:expert_weights,train_by_expert,predict_by_expert; tests intest_batched_experts.py(9).Weights (step 2): a point needs 1.9 / 3.1 / 3.9 experts on average for 99% of its weight at J = 4 / 16 / 64, up to 21 at 64; 2.1-2.5 on Tom, up to 9.
Training memory falls as planned – peak device memory at J = 4 / 16 / 64: every expert 73 / 276 / 1101 MB, by expert (per epoch, four visits) 94 MB at 16 and 208 at 64 (5.3x less); Tom J = 40, 907 against 392. The cost moves to the host: each active set is a traced step with gradients, never given back, ~80 MB each (5.7 GB at J = 64 against 2.0).
Global parameters must be accumulated over the epoch (the step-4 question): stepped per batch on one region at a time they wander, the weights spread (99% needs 6.9 experts at J = 16 against 2.6) and the fixed sets drop 7% of the weight; held-out rmse 0.299 against 0.137.
One visit per expert per epoch converges far too slowly: an expert takes one full step an epoch where
train_svigives it J noisy ones (J = 64: rmse 0.295 at 20 epochs against 0.088). Four visits (batches of 100) fix most of it – J = 16: rmse 0.091, CRPS 0.129 againsttrain_svi’s 0.076 / 0.139; J = 64: 0.121 / 0.145 against 0.088 / 0.135 at 783 s against 922 – sixteen are unstable (rmse back up from 0.094 to 0.113). At equal timetrain_sviis ahead on rmse at every J measured.Tom: four visits are better calibrated and call less ore – J = 10, Brier 0.072 against 0.098, balanced accuracy 0.690 against 0.769; J = 40, 0.101 against 0.120 and 0.750 against 0.770. Active sets are larger on drillholes (16.6 of 40 experts).
The active sets must be formed once and kept: re-formed with each weight table, a 16-expert run traced 76 steps and grew 6.6 GB.
Prediction memory falls ten-fold: J = 64, 180 MB against 1953 on a point grid, 145 against 1885 on blocks; every location keeps at least 99% of its weight, the prediction moving by 0.020 at most (field sd ~1), no Tom block’s call changed above 0.015%. Grouping is the cost: by exact active set 164 groups (a trace each) at J = 16 and 92 s against 3; by the home expert’s training set, falling back to the union of the leading experts’ sets, 16 groups at 16, 108 at 64 (149 s against 12), and 317-707 on Tom’s blocks between drillholes (556 s against 8). The first home rule – the leading expert’s set alone – dropped up to 40% of a location’s weight where it did not reach.
A GP reading only a GP is out of reach of spatial experts: the second GP reads the first’s output, where its experts are not local (99% of a point’s weight needs 12 of 16 experts, against 2.2 at the first layer), so by expert it moves the prediction by 0.19 on average.
Concatenating the coordinates into the second GP’s input makes it local again (the user’s proposal;
probe_deep_concat.py, J = 16, 10 epochs oftrain_svi, independent propagation): 99% of a point’s weight needs 3.2 experts at the second layer (max 8) against 12.1 (max 15) for the GP reading only the GP, 2.7 at the first. The second GP’s ranges trained to 1.8 on each coordinate and 0.69 on the latent, an expert’s tile being 3.3 units wide in the transformed space – the locality rests on the coordinate ranges staying under the expert spacing, and a longer training that stretches them would bring the collapse back, so the gate reads them. Duvenaud et al. (AISTATS 2014)’s input-connected networks are the same remedy for the same pathology. The prototype needs four changes for it: accept aConcatenateof the root andBasicGPnodes (_expert_gp_nodesrefuses it), slice the concatenated inducing points by the subset (_parent_points), form the sets from both layers’ weights (the layers share one partition, so one set serves both), and gate consensus propagation, which under a subset blends the first layer’s experts at the second layer’s inducing points from the active ones only.Novelty (
report/novelty_search_2026-10-06.mdin the research project): possibly novel as a combination; the closest are PSVGP §4.2, Hoang et al. (AAAI 2017), Yu et al. (IJCNN 2019) and ProSpar-GP, and expert-choice routing (Zhou et al. 2022) as an analogue.
Measured 2026-10-06, second round – the eight open items above,
built and measured on the same branch (train2.py, tom2.py,
predict2.py, probe_consensus.py, tables by summarize2.py; results in
results/v2.jsonl, tom2.jsonl, prediction2.jsonl,
consensus.jsonl). test_batched_experts.py holds 24 tests.
One trace serves every set (
latent.network.expert_slots): the active experts are computed in a fixed number of slots – the largest active set – with their indices a tensor, an empty slot holding a padding expert masked out of every blend, an expert’s missing inducing points padded with the identity’s rows and columns.BasicGPhas a batched twin ofrefresh,_moments,simulateand the KL under it;covariance_matrixtakes leading batch axes (x[..., :, None, :], the same on matrices). Training steps a stacked copy of every expert’salpha_white,deltaandbiaswith a masked AMSGrad on the slots’ rows, written back to the parameters before each weight sweep and at the end, a cancel included. Gate: against the trace-per-set path the bound agrees to 1e-8 and the parameters to 1e-6 on the shallow and the concatenated tree – once the rate and the betas’ powers are taken in single precision as Keras takes them (in double precision every step came out 6.7e-6 longer). J = 16: one trace against 16, 132 s against 307, host growth 305 MB against 1199, curves identical; J = 64 (epoch update): 311 s against 783, host growth 341 MB against 5656, same scores; device peak 278 MB against 208, every step padding to 19 slots. With a trace no longer per set, the sets are re-formed at every weight sweep (the slots only widen; two or three traces a run), which keeps the dropped weight under 1%.More shared steps were not the gap. A round update (a step after each round of the experts) brings the ranges where
train_svitakes them (2.05 against 2.0, J = 16) and scores worse than one step an epoch: rmse 0.111 against 0.092, CRPS 0.138 against 0.130; at J = 64 0.123 / 0.135 against 0.121 / 0.144. The default is"epoch".The instability of many visits is the learning rates’ clocks. Each expert’s optimizer decays its rate 0.999 a step of its own, and an expert steps on every batch whose set it is in – with 16 visits about 94 steps an epoch, so its rate is at 2% by epoch 40, while the shared parameters, one step an epoch, have hardly decayed: they keep moving and the frozen experts cannot follow. Neither the batch size (25 and 100 rows both rise from epoch 40) nor stale sets explain it. Decaying the experts on
train_svi’s clock does (16 visits: rmse 0.081 at epoch 60, still falling, against 0.111) – but at J = 64, four visits, that clock let a seed rise late (0.114 at 40, 0.126 at 60), and putting the shared parameters on it too freezes them before they converge, by expert needing about three timestrain_svi’s epochs (ranges stop at 1.14 against 2.0). Rmse / CRPS at epoch 60, four visits:schedule
J = 16 (3 seeds)
J = 64 (3 seeds)
steps counted (the default,
decay="steps")0.095 +- 0.005 / 0.125
0.095 +- 0.004 / 0.126 +- 0.001
experts on
train_svi’s clock0.092 +- 0.005 / 0.125
0.126 / 0.135 (one seed)
both on it (
decay="epochs")0.096 +- 0.005 / 0.127
0.119 +- 0.005 / 0.143 +- 0.001
Four visits counting steps is the default;
decay="epochs"is kept for many visits, where it ends the late rise (16 visits, J = 16: 0.090 / 0.128, still falling).Tom’s ore calls were the threshold, not the model. By expert ranks ore better and is better calibrated; balanced accuracy at 0.5 rewards
train_svifor overstating ore (mean probability 0.24-0.28 against a held-out share of 0.10), which puts more composites over the cut. At a cut at the training share of ore by expert wins. Three seeds (the schedule moves none of it by more than 0.005), training seconds from the runs made alone on the GPU:Tom
AUC
Brier
balanced at 0.5
at the ore share
mean p
train s
J = 10, every expert
0.869
0.098
0.768
0.695
0.240
119
J = 10, by expert
0.911
0.073
0.690
0.774
0.172
55
J = 40, every expert
0.817
0.120
0.770
0.649
0.281
506
J = 40, by expert
0.854
0.101
0.742
0.680
0.242
314
Prediction in slots needs no grouping rule. Each location takes its own experts (those holding 99% of its weight), and the groups are packed into as many slots as training’s largest active set – a location needing more keeps its leading ones,
left_outsaying what that drops;slots=takes a number instead,pack=Falseleaves a location’s answer depending on the location alone. Memory goes with slots times rows a batch:every-expert model
groups
slots
seconds
peak MB
changed max / mean
J = 16 points, every expert
1
1.2
493
J = 16 points, slots
11
10
1.7
231
0.020 / 0.0017
J = 64 points, every expert
1
11.3
1952
J = 64 points, slots packed
38
22
11.3
894
0.023 / 0.0020
J = 64 points, slots unpacked
1052
22
34.8
148
0.029 / 0.0047
J = 64 points, ten slots
214
10
15.2
210
0.111 / 0.0037
J = 64 blocks, every expert
1
5.7
2134
J = 64 blocks, slots packed
30
22
13.8
533
0.013 / 0.0012
J = 64 blocks, ten slots
121
10
19.5
366
0.079 / 0.0024
Tom J = 40 blocks, every expert
1
10.8
1753
Tom J = 40 blocks, slots
88
13
17.3
787
0.039, calls 0.02%
Ten slots at J = 64 leave 4-5% of the locations short of 99% of their weight, the worst at 17-21%. Tom’s blocks went from 317-707 groups and 556 s to 88 and 17 s. A model trained by expert carries larger sets (Tom J = 40: 24 slots, 1306 MB against 1909) – the cap is the lever.
Deep trees through the coordinates work.
Concatenaterefreshes under a subset and under slots,_expert_gp_nodestakes a GP reading aConcatenateof the input andBasicGPnodes, and the weight table reads every GP node, a node past the first reading what the one expert gives below it. Gates: by expert with every expert in the slots is the model to 1e-9 under both propagation rules; a GP reading only a GP is still taken, and still not local. J = 16, 60 epochs (by expert with the experts ontrain_svi’s clock, the default when it ran):concatenated tree
rmse
CRPS
2nd-layer coordinate range / tile
experts for 99%
every expert
0.119
0.164
2.3 / 1.55
3.1
by expert
0.085
0.128
1.43 / 2.35
2.1
Every expert peaks at 0.112 by epoch 20-30 and falls back as its noise shrinks; the second layer’s coordinate ranges grow past an expert’s tile there (the input transform stretching too), and stay within it by expert. Consensus propagation under a subset is an approximation (
probe_consensus.py): the second layer’s inducing inputs move by 0.014 on average from the full blend (the first layer’s sd 0.37) and by 0.57 at worst, at the sets’ edges; the prediction by 0.023 on average, 0.34 at worst, rmse 0.135 against 0.125. Deep trees by expert should propagate independently.Coverage. Wired in: progress and cancel (one event a batch, the working copy written back on a cancel),
options.training_tolerance,cross_validate(method="by_expert", expert_options=),refine(by_expert=True), andpredict_by_expert(where=), which resumes fromunpredicted(). Mesh sets never call the model and needed nothing – the list above was wrong to name them. Still refused: directional data, leaves on several roots, GP nodes other thanBasicGP(each needs a slot path of its own). One bug found on the way: a trace of one kind left its symbolic state on the nodes and the next trace of the other kind collected it;_graph_statenow takes only tensors of the graph being traced.Replication (three seeds; training seconds from the runs made alone on the GPU):
synthetic, held-out
rmse
CRPS
train s
J = 16, every expert, 60 epochs
0.080 +- 0.003
0.132 +- 0.006
168
J = 16, by expert, 60 epochs
0.095 +- 0.005
0.125 +- 0.004
130
J = 64, every expert, 20 epochs
0.088 +- 0.003
0.135 +- 0.001
922
J = 64, by expert, 60 epochs
0.095 +- 0.004
0.126 +- 0.001
1013-1070
At equal time (~1000 s at J = 64) by expert is behind on rmse and ahead on CRPS – ahead on both until ~650 s (figure
round2_heldout_by_time_64.png) – with a quarter of the device memory (264-333 MB against 1075-1109); on Tom it is ahead on everything but balanced accuracy at 0.5.
Measured 2026-10-06, third round – the network’s nodes by expert, and
a fixed total of inducing points split among different numbers of experts
on Tom. test_batched_experts.py holds 69 tests. The split ran from a
frozen copy of the second round’s commit, so the node work could go on
beside it.
Every node but five now trains and predicts by expert. Refused, by the user’s choice:
AdditiveGP,UncertainInputGP,GradientConstrainedInput– and with it directional data, which only it reaches,BasicGP’s directional prediction being commented out –RadialTrendandGaussianMixture. Taken:MultiStructureGP,GaussianInput, several inputs (a list of leaves, or aStackjoining trees), and below or above the GP nodesLinear,SelectInput,GPWalk,Bias,Scale,Add,LinearCombinationand aConcatenateof any of them;Multiply,ProductOfExpertsandExponentiationhand no inducing points on, so they sit above the GP nodes only, as they always have.How. Under slots every node that hands inducing points on holds them as one tensor flattened over the slots, so the nodes working row by row (
Linear,SelectInput,Bias,Scale) need nothing of their own;Concatenate,LinearCombinationandAddalign their parents’ points expert by expert (_aligned_points), a node computed from the input alone holding every expert’s and one downstream of a GP node the active ones;GPWalkwalks the active experts’ points and its KL is a term per expert, shared out as a GP node’s is;MultiStructureGP’s covariance takes leading axes. Several inputs: subsets and slots per input (expert_subsetandexpert_slotstake a mapping), the experts numbered across the inputs and visited together, each batch an active set per input, its data term divided by the number of inputs (a row’s weights sum to one per input), the KL shares read off the overlap across inputs; prediction carries the weights in each input’s own transformed space and packs the groups input by input.Gates. Each of 20 networks – nine below a GP node, eight above,
GaussianInput, two inputs, aStackof them – predicts by expert with every expert in the slots as the model does (prediction to 1e-7, realizations 1e-6), and trains by expert in at most two traces with every GP node’s experts moving; on the walk, a sum below a GP node and two inputs, the slots train as a trace per set does (bound to 1e-8). The 19 test files covering the nodes touched pass (767 s), the catalogue’s every-node tests included.One bug. Two trees may number their nodes alike, and the working copy was keyed by name: two inputs collided. It is keyed by the nodes’ ids.
A fixed total split (Tom, 20 epochs, seed 1; training seconds and peak device MB; by expert four visits, steps counted):
total
experts
each
every expert: AUC / Brier / s / MB
by expert: AUC / Brier / s / MB
1500
5
300
0.890 / 0.083 / 111 / 339
0.926 / 0.066 / 59 / 537
1500
10
150
0.868 / 0.098 / 118 / 236
0.912 / 0.072 / 49 / 248
1500
20
75
0.848 / 0.105 / 181 / 185
0.904 / 0.076 / 62 / 123
1500
40
37
0.846 / 0.113 / 328 / 156
0.884 / 0.083 / 108 / 63
6000
10
600
0.850 / 0.102 / 770 / 2220
0.889 / 0.091 / 531 / 2065
6000
20
300
0.815 / 0.112 / 422 / 1329
0.883 / 0.092 / 388 / 1018
6000
40
150
0.818 / 0.119 / 446 / 919
0.857 / 0.101 / 308 / 448
Fewer, larger experts score better by both methods at both totals, and by expert is ahead on AUC and Brier at every split. Every expert’s time grows with the number of experts at 1500 points (111 s at 5, 328 s at 40 – the 2026-08-06 finding again) and is lowest in the middle at 6000; by expert’s memory falls with the number of experts, but at 5 experts it peaks above every expert’s (537 MB against 339): a batch holds N / (J x visits) rows, 800 here. The larger total scores worse at equal experts (10 experts: 0.889 against 0.912 by expert) – 20 epochs may not be enough for 6000 points, or Tom’s composites do not support them.
Measured 2026-10-06, fourth round – a partition of the rows, the
user’s design: each epoch the experts, in a random order, draw their share
of the rows still unused, N / (J x visits), by their weight and without
replacement, so that every row is read once an epoch and a row a dense
expert’s quota leaves behind falls to a later expert; only the batch’s own
expert steps on it, its KL counted whole in its own batches, the data term
the batch’s plain sum; each batch’s active sets come from the rows it drew;
the shared parameters step once an epoch or per batch
(train_by_expert(sampling="partition"), VGPNetwork._partition). Gates:
every row once an epoch, on one input and on two; at fixed parameters an
epoch adds up to the bound to 1e-9; only the batch’s own expert moves; the
slots train as a trace per set does. Seed 1, the earlier runs alongside:
case |
design |
epochs |
seconds |
held-out |
|---|---|---|---|---|
J = 16 |
every expert |
60 |
168 |
rmse 0.076, CRPS 0.139 |
J = 16 |
with replacement, 4 visits |
60 |
130 |
0.092, 0.130 |
J = 16 |
partition, 1 visit, shared once an epoch |
200 |
243 |
0.157, 0.150 |
J = 16 |
partition, 1 visit, shared per batch |
200 |
310 |
0.178, 0.164 |
J = 16 |
partition, 4 visits |
60 |
162 |
0.106, 0.136 |
J = 16 |
partition, 16 visits |
60 |
476 |
0.079, 0.127 |
J = 64 |
every expert |
20 |
922 |
0.088, 0.135 |
J = 64 |
with replacement, 4 visits |
60 |
1013 |
0.097, 0.126 |
J = 64 |
partition, 4 visits |
60 |
1349 |
0.112, 0.133 |
J = 64 |
partition, 16 visits |
15 |
793 |
0.129, 0.148 |
Tom J = 10 |
every expert (GPU) |
20 |
118 |
AUC 0.868, Brier 0.098 |
Tom J = 10 |
with replacement, 4 visits (CPU) |
20 |
102 |
0.911, 0.073 |
Tom J = 10 |
partition, 4 visits (CPU) |
20 |
102 |
0.841, 0.084 |
Tom J = 10 |
partition, 16 visits (CPU) |
20 |
185 |
0.892, 0.078 |
Tom J = 40 |
every expert |
20 |
446 |
0.818, 0.119 |
Tom J = 40 |
with replacement, 4 visits |
20 |
308 |
0.857, 0.101 |
Tom J = 40 |
partition, 4 visits |
20 |
307 |
0.790, 0.114 |
Tom J = 40 |
partition, 16 visits |
10 |
478 |
0.810, 0.109 |
Per epoch the partition learns well, once an expert gets enough steps: at J = 16 with 16 visits it is the best by expert on rmse, and still falling – the instability of many visits with replacement gone, since an expert steps on its own batches only, 16 steps an epoch rather than about 94. One visit is far too slow, an expert stepping once an epoch.
Per second it loses everywhere measured. The leftover rows spread the late batches over the field, so their active sets grow – at most 37 of 64 experts at 4 visits and 25 at 16, against 19 with replacement, Tom J = 40 a mean of 13 to 14 and at most 29 – and every slot pays for the widest; with many visits the batches are small (25 rows) and a step’s fixed cost dominates. The late batches belong less to their expert: in the last epoch their rows’ weight for it 0.73 against 0.80 for the first quarter at J = 16, 0.54 against 0.68 on Tom J = 40.
Shared parameters per batch are worse here too (rmse 0.178 against 0.157).
Sampling with replacement stays the default; the partition is an option.
Quotas by weight change the batches and nothing else (measured 2026-10-07,
quotas="weight": each expert draws its share of the weight the rows carry rather than an equal share, the remainders to the largest fractions; the gates hold for both). Same seed, same epochs as above:case
visits
equal: seconds, score
weight: seconds, score
rows a batch
widest set, equal / weight
late own weight, equal / weight
J = 16
4
162, 0.106 / 0.136
157, 0.106 / 0.136
92-108
14 / 13
0.73 / 0.73
J = 16
16
476, 0.079 / 0.127
473, 0.079 / 0.127
23-27
12 / 11
0.78 / 0.83
J = 64
4
1349, 0.112 / 0.133
1398, 0.112 / 0.133
88-111
37 / 36
0.66 / 0.69
J = 64
16
793, 0.129 / 0.148
735, 0.129 / 0.148
22-28
25 / 23
0.75 / 0.80
Tom J = 10, CPU
4
102, 0.841 / 0.084
101, 0.841 / 0.084
350-453
10 / 10
0.54 / 0.56
Tom J = 10, CPU
16
185, 0.892 / 0.078
203, 0.892 / 0.078
87-111
10 / 10
0.67 / 0.69
Tom J = 40
4
307, 0.790 / 0.114
341, 0.790 / 0.113
69-137
29 / 34
0.54 / 0.59
Tom J = 40
16
478, 0.810 / 0.109
517, 0.807 / 0.109
17-35
29 / 32
0.59 / 0.66
Scores are rmse / CRPS on the synthetic field and AUC / Brier on Tom. On the synthetic field the experts carry nearly equal weight, so the quotas hardly move; on Tom they span a factor of two. The late batches belong more to their own expert, by up to 0.07, but the widest set, which the cost follows, does not narrow – on Tom it widens – and every score is the equal quotas’ to the third decimal. Time moves by -7% to +11%, the varying batch sizes taking two more traces. The leftover rows are not a shortfall in the quotas: an expert drawing by weight takes rows from its tails and leaves part of its core to its neighbours, whatever its quota, and the last batches take what remains wherever it lies.
Rows choosing their expert (measured 2026-10-07,
sampling="assignment"): every row draws one expert from its own weights, so a row lands in an expert’s batch with exactly the probability sampling with replacement gives it, and every row is read once; an expert’s rows are split into round(rows / target) batches, at least one, the target N / (J x visits), so a crowded expert steps more often (3 to 6 times an epoch at 4 visits, 10 to 21 at 16 on Tom J = 40); its KL is shared among its batches. The count of batches changes from epoch to epoch, so the share of the rest’s KL and of the priors a batch carries is a step argument now, divided by as/ per_epochwas, the other paths unchanged to the bit. Gates: every row once, the batches within 1.5 x the target, each expert’s KL shares adding to one, a row landing with its weight over 4000 draws, the epoch adding up to the bound, the own expert alone moving, slots as by set.case
visits
assignment: s, score
partition, equal: s, score
widest set, assignment / partition
own weight, first / last quarter
J = 16
4
148, 0.106 / 0.136
162, 0.106 / 0.136
11 / 14
0.82 / 0.81
J = 16
16
430, 0.080 / 0.127
476, 0.079 / 0.127
10 / 12
0.85 / 0.85
J = 64
4
1031, 0.111 / 0.132
1349, 0.112 / 0.133
24 / 37
0.78 / 0.78
J = 64
16
704, 0.129 / 0.148
793, 0.129 / 0.148
23 / 25
0.84 / 0.84
Tom J = 10, CPU
4
86, 0.843 / 0.083
102, 0.841 / 0.084
9 / 10
0.60 / 0.64
Tom J = 10, CPU
16
205, 0.892 / 0.078
185, 0.892 / 0.078
10 / 10
0.69 / 0.70
Tom J = 40
4
295, 0.791 / 0.113
307, 0.790 / 0.114
28 / 29
0.68 / 0.68
Tom J = 40
16
483, 0.807 / 0.108
478, 0.810 / 0.109
29 / 29
0.69 / 0.68
It does what it was built for: no leftovers, the last batches as much their expert’s as the first, the widest set narrower on the synthetic field (24 against 37 at J = 64), and the fastest of the three splits there (1031 s against 1349). On Tom the widest set stays at 28-29, as with replacement (25): the field’s own clusters straddle experts. The scores do not move. Three ways of splitting the rows, one score to the third decimal in every case, and still short of sampling with replacement at the same epochs (Tom J = 10 at 4 visits: 0.843 against 0.911; J = 16: 0.106 against 0.092). Since the assignment’s batches hold the rows replacement’s draws do, what is left between them is who steps: with replacement every active expert steps on every batch, about 51 steps an expert an epoch on Tom J = 40 at 4 visits (160 batches, 12.8 active each), against 3 to 6 here. The rule that only the batch’s own expert steps, not the row split, is what holds the partition back; the next test is the assignment with every active expert stepping on the batch’s plain sum, each expert’s KL shared by the weight it carries in each batch so an epoch still counts it once.
Every active expert stepping on the assigned rows (measured 2026-10-07,
sampling="assignment", stepping="active"): the rows as above, every expert in a batch’s active sets stepping on its plain sum, each expert’s KL shared among the epoch’s batches it is active on by the weight it carries in each, so an epoch still adds up to the bound (gate as before, and every active expert moving on the first batch). Seed 1, scores at the last epoch:case
visits
active stepping: s, score
own only: s, score
with replacement, 4 visits: s, score
J = 16
4
144, 0.090 / 0.129
148, 0.106 / 0.136
130, 0.092 / 0.130
J = 16
16
446, 0.101 / 0.131
430, 0.080 / 0.127
422 at 16 visits, 0.111 / 0.134
J = 64
4
1031, 0.094 / 0.126
1031, 0.111 / 0.132
1013, 0.097 / 0.126
J = 64
16
708, 0.105 / 0.137
704, 0.129 / 0.148
–
Tom J = 10, CPU
4
85, 0.907 / 0.073
86, 0.843 / 0.083
102, 0.911 / 0.073
Tom J = 10, CPU
16
188, 0.909 / 0.073
205, 0.892 / 0.078
–
Tom J = 40
4
299, 0.856 / 0.100
295, 0.791 / 0.113
308, 0.857 / 0.101
Tom J = 40
16
479, 0.857 / 0.099
483, 0.807 / 0.108
–
At 4 visits it matches sampling with replacement in all four cases – ahead on the synthetic field, level on Tom – in about the same time, confirming that the own-expert rule was what held the partition back. An expert now steps 12 to 87 times an epoch at 4 visits on Tom J = 40 and J = 64, crowded ones the most. At 16 visits the many-visits drift of replacement returns (J = 16: 0.090 at epoch 40, 0.101 at 60; replacement 0.111), which is item 1 below. The widest set is that of the assignment (23 at J = 64 against 19-21 with replacement), and peak device memory a little above replacement’s (411 MB against 333 at J = 64, 460 against 448 on Tom J = 40). What it adds over replacement: every row read once an epoch, an epoch’s batches adding up to the bound, no inverse- probability scale, crowded experts taking more batches by construction. One seed: whether it should replace sampling with replacement as the default needs the replication the second round gave replacement.
Replicated over three seeds (2026-10-07; seeds 1-3, 4 visits, both designs rerun on the GPU under the current code, scores of the prediction by expert):
case
with replacement: mean, s, MB
assignment, every active expert: mean, s, MB
assignment minus replacement, per seed
J = 16 (rmse / CRPS)
0.094 +- 0.005 / 0.125 +- 0.004, 133, 118
0.093 +- 0.005 / 0.125 +- 0.005, 145, 135
rmse -0.0022, -0.0014, -0.0021
J = 64
0.095 +- 0.004 / 0.126 +- 0.001, 927, 325
0.092 +- 0.004 / 0.126 +- 0.001, 987, 381
rmse -0.0029, -0.0021, -0.0027
Tom J = 40 (AUC / Brier)
0.854 +- 0.003 / 0.101 +- 0.001, 308, 447
0.856 +- 0.001 / 0.100 +- 0.001, 310, 463
AUC -0.0014, +0.0007, +0.0056
The assignment is ahead on rmse in all six synthetic pairs, by about 0.002, and level or ahead on CRPS in all six; on Tom level or ahead on Brier in all three and on AUC in two. It costs 6-9% more time on the synthetic field and none on Tom, and 4-17% more device memory, its widest set being the larger. (One replacement run on Tom, seed 2, shared the GPU with another training job and took 496 s; its score is unaffected and the time above leaves it out.) The seed-1 reruns check the default path: sampling with replacement gives the second round’s numbers under the current code – 0.09191149854906935 against 0.09191149854907349 at J = 16, 0.09664179726490743 against 0.09664179726491132 at J = 64, Tom identical – the difference being the known drift between processes under default threading. The assignment with every active expert stepping is
train_by_expert’s default since 2026-10-07 (sampling="assignment", stepping="active"); sampling with replacement and the partition remain options.
Open, in the order they matter:
A learning-rate schedule tied to progress, for the experts and the shared parameters alike (item 3): counted per step, many visits freeze the experts while the shared parameters move on; on
train_svi’s clock, the shared parameters freeze before they converge. A natural-gradient step held constant rises at 16 visits too (2026-10-09, “Natural-gradient steps for the experts” under Settled by measurement), so the decaying clocks are not the whole cause.Slots sized to memory, not to the largest set: prediction memory goes with slots times rows a batch, so choosing the batch from a memory budget would let the packed groups keep every location’s 99%.
The rmse gap on the synthetic field (0.015 at J = 16, 0.007 at 64) while CRPS is better by expert: unexplained.
Batches sized to the experts: training by expert draws N / (J x visits) rows a batch, so few experts make large batches and a peak above
train_svi’s (5 experts on Tom: 537 MB against 339). Batch sizes by each expert’s total weight, against equal sizes with the inverse-probability scaling, were in the plan of record and never run with replacement; under the partition they were run and changed nothing (fourth round).The five refused nodes:
AdditiveGP,UncertainInputGP,GradientConstrainedInput(and directional data),RadialTrend,GaussianMixture.
M–L — Propagate individual realizations through the tree (requested
2026-09-08). What happens today: moments at every node. _GPNode.propagate
takes the parent’s mean and variance and evaluates the expected kernel over
that variance (covariance_matrix(x, ip, x_var, ip_var)), so a parent’s
uncertainty is integrated analytically, and the node’s simulate draws
fresh normals about the mean at the parent’s mean — a parent’s
realizations never enter a GP node. The pathwise ride the 0.6.9 protocol
built is over the operation nodes only (Linear, LinearCombination,
Stack, …); GPWalk steps by the walker’s mean. Across leaves, the
realizations are paired only through a shared parent’s own draw (stateless,
so realization k of a shared parent is the same in every leaf) and
otherwise unrelated: no leaf reads another’s realization. Two wants, worth
separating. (1) Within a tree, through a GP node: the child evaluated
at each parent realization x^(k) with the plain kernel (x_var=None),
one draw each — the doubly stochastic route (Salimbeni & Deisenroth 2017)
beside the moment-matching one the bound is trained with (Damianou &
Lawrence 2013; the expected kernel of Titsias & Lawrence 2010). Cost:
n_sim kernel evaluations of [n, m] where there is one, looped over
realizations so memory stays at one; the whitened chol_r @ rnd is shared.
Prediction-only first — training keeps the moment bound, so the ensemble’s
spread then differs from the variance the bound optimized; measure coverage
and CRPS both ways on a deep model, held-out. Training by sampling is a
different model with a different bound. (2) Across leaves, the case
asked for: assays controlled by lithology should respect each
realization’s boundary — realization k of the grade conditioned on
realization k of the rock. Needs (a) an edge from the rock’s leaf into
the grade’s tree, which the network never has today — the same missing edge
and per-realization gate the “Mixtures of GP nodes” item names as its
second design; (b) a per-realization gate: the rock realization’s label
(the likelihood’s rule, ind_skew’s best category, applied today to the
leaf’s sims inside _CategoricalLikelihood) selecting, per realization,
which regime’s field the grade draws from; (c) the sweep carrying the
rock’s realizations to the grade leaf — _predict_raw predicts the leaves
one by one on the same stateless seed, so a shared parent already pairs
exactly, and a gate node would pair the same way once the rock’s sims are
stamped as sweep state. This is the geostatistical cascade — domains
simulated, then grades within each realization’s domains — made joint.
Gate: a synthetic two-regime field with a boundary; the paired ensemble’s
tonnage-above-cut-off distribution against the truth, versus today’s
unpaired ensemble and the hard-domained workflow. Both (1) and (2)
presupposed the sibling-normals fix, done 2026-09-10 (settled below).
M–L — Merging subcompositions (requested 2026-09-10). Some
compositional data sets are missing data in a structured way: the same
elements unassayed in a large share of the samples, as a campaign that ran
a shorter suite leaves them. _prepare_composition marks a row missing
entirely when any part is missing, so one composition over every element
throws those samples away, and modelling the sparse elements as a separate
variable gives up the closure. The proposal: two compositions. The
master holds the elements every sample has, the partially missing ones
aggregated into its rest (rest=True already does that for whatever is
not listed). The subcomposition holds the partially missing elements as
shares of the master’s rest, trained only on the samples that carry them.
Correlations between the two sets of latent variables may link them. After
prediction the two are joined realization by realization: the master’s
rest r in each realization is split into that realization’s
subcomposition proportions q, part i becoming r·q_i of the whole,
and the prediction, the quantiles and both variances are taken over the
joined realizations – never the product of the two predictions, which
drops whatever correlation links r and q.
What exists: two compositional variables on one container, each with its
own likelihood and warping, on two leaves of one tree (0.6.10). A shared
parent is what correlates them, and it is also what pairs their
realizations: realization k of a shared parent is the same in every leaf
(the item above), while two separate GP leaves draw independently since
the node-keyed draws. A sample without the partially missing elements is a
missing row for the subcomposition’s likelihood, which training already
skips. container.derive already applies a function once per realization,
walking the realizations in bands.
What it needs: (1) the subcomposition’s own rest – what the master’s rest
holds beyond the partially missing elements – without which the split
would hand the whole rest to them. Building it means dividing those
elements by the master’s rest in fractions of the whole, with the crowding
problem _prepare_composition already settles for a rest (samples where
they account for all of it or more). One constructor taking both groups
from one table is the natural door, units carried as 0.6.10 carries them.
(2) The join, into one CompositionalVariable holding every part in its
own unit – derive would give loose continuous variables, one per part.
(3) Block support: a block’s stored realizations are averages over its
sub-blocks, and the average of a product is not the product of the
averages, so on a block model the join belongs inside the prediction, per
sub-block, before _aggregate; on points the stored realizations suffice.
Gate: Jura’s seven metals with two hidden over a spatially contiguous share of the samples, as a shorter campaign would leave them. The joined model against one composition over the complete rows only, with the full-data model as the ceiling: rmse, CRPS and coverage on the hidden elements, the common elements no worse, and every joined realization summing to the whole.
M–L — [geostat] Censored observations (a Tobit likelihood). Plan
proposed and parked. An assay reported as <0.01 is substituted with half
the detection limit by universal practice, and that biases exactly the low
tail every cut-off calculation reads. The contribution is
log Φ((warped(dl) − μ)/σ) — the latent quadrature log_lik already takes,
integrating a CDF instead of a density; interval-censored is the two-term
version. Structurally it is partial missingness, so warping.elementwise
already says when it is admissible. What it needs: a censoring mask that
reaches the likelihood, a censored role in drillhole.py, and a decision
on what predict_measurements reports below the limit. The open design
question is the user’s fan-out alternative — K rows per censored
observation combined by log-sum-exp — against the direct term.
The mechanism already exists, half-built. _Variable.training_input(idx)
is the one channel by which a variable hands the likelihood something per
row at training time. Its payload today is dead: the only producer is
RockTypeVariable, whose is_boundary both categorical log_liks take in
their signature and have never read. Its door is live —
DerivedVariable overrides it to refuse a derived variable at the training
door. So a censoring mask is not a new mechanism but the first real payload
this one carries. Two things to fix when it becomes load-bearing:
train_svi passes empty dicts instead of training_input(idx) (a
commented-out attempt sits beside it), and training_input() is called once
for the whole data set in train_full, so the per-batch indexing has never
been exercised.
M — Survey error as a location variance from the drillhole. The use
case for GaussianInput on coordinates: as_point_data returning a
GaussianData whose variance grows down the hole from a declared survey
accuracy, by minimum-curvature error propagation of dip and azimuth. Without
it there is no honest source of coordinate variance. The primary stated use
— high-dimensional inputs with missing entries — also has no container
helper; the benchmark builds both by hand.
L — [geostat] Locally varying anisotropy, as a transform. Every
transform is a globally constant ellipsoid, so the package cannot say that
the structure turns. The literature does LVA with graph distances because
kriging has nowhere else to put the orientation; that route is
non-differentiable and its metric is not Euclidean, so the induced
covariance is not positive definite in general. geoML has a better route it
is not using: StructuralField already fits an orientation field, so an
AnisotropyField transform reading local orientation from it is LVA
end-to-end differentiable, trained by the same ELBO as everything else.
Folded stratigraphy and vein swarms are where kriging visibly fails. Open:
fit the field and freeze it, or train it jointly and risk orientation and
range trading against each other unidentifiably. Measure the frozen version
first — it is also the fallback.
L — [geostat] Truncated plurigaussian. The lithotype rule encodes
which domains may touch which, which is real geological knowledge
CategoricalGaussianIndicator cannot express — it treats categories
symmetrically. The maths fits: the likelihood is an orthant probability over
two correlated latent Gaussians, and the Sobol machinery already integrates
things of that shape. The hard part is the interface for the rule diagram,
not the integral. Weigh against what the indicator formulation already buys
and PGS has no equivalent of: contacts as the zero level set of ind_skew,
which the block refinement and the surface extraction both read. Reopens the
shelved transition-counts diagnostic in §3, which shares the rule.
L — Mixtures of GP nodes for multimodal distributions. Nothing can say “this field is drawn from one of K regimes”: every composition stays unimodal-Gaussian in the latent, and multimodality can only come from the warping, which is a global marginal reshaping rather than a spatial selection. Two gating designs, and the second is the interesting one: a free spatial gate, or the gate read from a modelled rock type, which is the classic domain-then-grade workflow made joint and soft, so domain uncertainty finally propagates into the grade instead of being frozen by a hard contour. To settle first: the ELBO of a mixture latent, the simulation path (draw the gate per realization so realizations are single-regime and the multimodality lives across the ensemble), and the new kind of edge the second design needs — a gate reading another variable’s latent couples two likelihood heads, which the network never does today. Gate: a deposit with genuinely regime-split grades, scored held-out against the hard-domained workflow; the soft gate must beat the hard cut to earn its complexity.
Started 2026-09-25 (0.8.0): the free gate is latent.GaussianMixture,
internal in the catalogue. Settled in design: a soft blend per realization,
y = Σ softmax(w)_k c_k, no temperature and no hard mode; K weights; the
moments from fixed Sobol points over the weights, taken independent of the
components; a leaf that is not Gaussian trains its continuous likelihood on
the realizations (gaussian on every node), a likelihood with no samples
path moment-matching with a warning. Measured
(docs/benchmarks/gaussian_mixture_gates.py, 1-D sinusoids):
Split at a boundary: passes, RMSE 0.161 / CRPS 0.043 against one GP’s 0.192 / 0.078, switch at 4.79 against the boundary at 5 — but only with components smoother than the jump. On the GP’s own root one component fits everything (share 1.00). A
BasicGPhas unit prior variance, so the node carries its ownamplitudeon the weights (decided 2026-09-25; throughScaleinstead, 0.153 / 0.046). It trains to its ceiling of 100: the switch wants to be a step. Drawn (docs/benchmarks/figures/gaussian_mixture_gate1.png), the components are not “the left regime and the right”: one smooth curve follows the data left of the boundary and again past 7.5, the other only bridges [5, 7].Against the construction in use (chapter 5’s deep GP: the input and an inner two-column GP concatenated under an outer GP, root range 2): RMSE 0.151 / CRPS 0.049 with the default kernels, 0.162 / 0.052 with the chapter’s spherical outer kernel. It follows the jump as closely as the mixture, a better RMSE than its 0.161, but overshoots it (1.3 against 1.0 just before, ringing after) inside a band too narrow to cover the miss, so the mixture’s CRPS (0.043) is the better. Neither is bimodal on the crossing curves. The deep GP’s bound was still oscillating at 1000 iterations (step spread 0.34 against the mixture’s 0.002). So on this gate the mixture’s gain over the construction in use is calibration, not accuracy — too thin to make the node public on its own.
Curves crossing throughout: fails. The mixture blends the two into their mean, no realization near either; the expected log-likelihood rewards agreeing with each sample, and the weights never learn to switch sample by sample.
The training draws are the same every iteration (
seed=options.seedon every step): the Monte Carlo bound is a fixed-sample average, biased rather than noisy (item “Fresh training draws” below).
Open: the real-deposit gate against hard domains (likely Jura) before the node is public; Monte Carlo paths for the categorical likelihoods, so a rock-type leaf that is not Gaussian can train on its realizations; and the gate read from a rock-type leaf, where the indicator rule (the largest latent) and the mixture’s softmax read one latent through two different links.
M — A mixture of likelihoods: overlapping mixtures of GPs (agreed
2026-09-25, redesigned 2026-10-01 with a warping per component; done
2026-10-01, public from 0.8.6: every gate below passed, phase 2 included).
Lázaro-Gredilla, Van Vaerenbergh & Lawrence (2012), Overlapping Mixtures of
Gaussian Processes for the data association problem, Pattern Recognition
45(4). M global latent functions; each sample comes from exactly one,
chosen by its value rather than its place, so the components cross and
overlap anywhere and the prediction is a mixture – multimodal where they
separate. This is what GaussianMixture cannot do: it blends values in
the latent, so a realization is bimodal only where its gate is sharp, which
is why it failed the crossing gate; this mixes densities in the
likelihood, sample by sample.
The design, settled:
likelihood.LikelihoodMixture(components), beside today’sMixture(noise scales around one latent value, one warping), which keeps its name and path; the docstrings say how the two differ. Experimental, and internal in the catalogue until the gates below pass.A component is any continuous likelihood instance, with its own family, noise and warping –
[Gaussian(BoxCox(...)), Gaussian(ZScore(1))]– today’sMixtureincluded. A shared warping is the same object handed to every component (persistence keeps it shared, and its Jacobian cancels out of the sum). Every component takes the variable’s width P, any P; a mismatch is refused at construction.Component k reads its own slice of the leaf, in list order; the leaf is the sum of the components’ latent widths (a
PCAlink can make one narrower than P). A user wanting independent components builds the leaf withStack.The bound, per row:
log Σ_k π_k exp(E_q[log p_k(y | f_k)]), each component’s density in data space with its own warping’s Jacobian – the term a shared warping lets factor out. Quadrature per component where its warping is elementwise, Monte Carlo on realizations otherwise.Shares: fixed
π, trained (phase 1). Spatially varying shares read from further latent columns through a softmax are phase 2, measured on Tom East with the share columns on the same tree as a rock-type likelihood onCode_Simple.Realizations divided among the components in fixed numbers, in proportion to
πby largest remainder and interleaved, so any first k hold the components in about the right proportions; the labels stored as a node fact on the variable (one list, the same everywhere), a resumed prediction refused if they would differ. Each realization goes through its own component’s noise and warping, so the plain ensemble is the mixture and quantiles, cut-off shares, block support, measurement samples and cross-validation need nothing new.Columns: the mixture’s usual ones;
responsibilities/<k>– the posterior where measured, the prior share elsewhere – written by the outer mixture only (an inner noiseMixture’s are not, in phase 1); andcomponent_prediction/<k>, each component’s prediction in data units. The latent moments per component are whatpredict_nodeanswers.No reaching for
lik.warping: the model, the diagram and the plots ask the likelihood; warped metadata and the transformed pairs are skipped for a mixture, which has no one warped space.Symmetry broken by initialization: the data clustered into M groups (seeded from the package generator), each component’s warping initialized on its group,
πstarted at the group sizes – so latent zero means a different value in each component. Clustering on values splits crossing curves at the crossing; the crossing gate says whether training mends it, random responsibilities (the paper’s) in reserve.
Gates, before it is made public (docs/benchmarks/gaussian_mixture_gates.py,
beside GaussianMixture’s, and a Tom East script reading a local copy the
repository never holds):
gate |
passes when |
|---|---|
crossing sinusoids |
realizations near both curves; ≥ 90% of samples’ largest responsibility on their true curve |
split sinusoids |
held-out rmse and CRPS within 5% of one GP |
population skew |
separate warpings beat one shared warping on held-out CRPS; ≥ 80% largest responsibility on the true population |
Tom East (Ag, Pb, Zn as a vector; folds by hole) |
held-out CRPS no worse than |
A near miss is reported, not met by moving the threshold.
Phase 1 measured, 2026-10-01 (the class and the model’s hooks built; the stored columns, labels fact, catalogue and diagram not yet):
gate |
result |
verdict |
|---|---|---|
crossing |
every location bimodal where the curves are apart, 49% / 49% of the realizations near each; the largest responsibility on the sample’s own curve 63% under one global naming, 97.5% (100% apart) asked locally – a component passes from one curve to the other at a crossing, where nothing tells them apart |
passes, on the local reading |
split |
rmse 0.369, CRPS 0.114 against one GP’s 0.192, 0.078; shares 0.08 / 0.92 |
fails: fixed shares put the second component into every location – what spatially varying shares (phase 2) are for |
population skew |
separate warpings CRPS 0.561, agreement 96%; one shared warping 0.575, 93% |
passes, narrowly (2.4% on CRPS) |
Tom East |
CRPS Ag / Pb / Zn 49.4 / 3.67 / 3.10, goodness 0.87 / 0.82 / 0.81; one Gaussian through the recommended chain 68.3 / 3.96 / 4.17 (its rmse exploding, 5568 on Ag), through the components’ own plain |
passes |
The first run of these gates started the components from each column’s normal scores, and the shared-warping arm came back at 54% and at 91% from one seed: on one column normal scores are symmetric whatever the data, so two groups always met at the median, the splits either side of it tied, and the clustering broke the tie differently from run to run. The start is a Yeo-Johnson power transform per column now, and every number above is from that start, repeated exactly three times. Decided 2026-10-01: the crossing gate is read locally (accepted), and the class stays internal until phase 2 passes the split gate.
Phase 2, agreed 2026-10-01: shares that change from place to place.
LikelihoodMixture(components, shares="fixed" | "latent"); under"latent"the leaf carries K share columns after the populations’, their softmax the shares, scaled by a trainedamplitude(a variance, asGaussianMixture’s) – none at first, added when the split gate asked for it: a share GP of prior variance one could not pass from one population to the next sharply enough, and the realizations divided at the boundary – and moved by a trainedbiasper population, what the shares return to away from the data (added 2026-10-01 at the user’s request: Tom East’s ore is not half the ground), started byinitializeon the clustering’s group sizes. A share GP node may feed a rock-type likelihood before it is concatenated into the mixture’s leaf.Realizations: realization s keeps its one interleaved point
u_s, and at each location takes the population whose interval of that realization’s own cumulative shares holds it. It changes population where its share field crosses its point – a smooth boundary, different in every realization, carrying the boundary’s uncertainty – and at each location the fraction of realizations in a population is the expected share, so the plain ensemble stays the mixture and no reader changes. Fixed shares are the special case (phase 1 unchanged). This reverses the 2026-09-25 rule (a whole realization in one population, the realizations split into equal groups, every reader weighting them by the local shares): under shares that vary, that rule turned every reader of realizations into a weighted one, and a tonnage read off one realization could not hold one orebody. Rejected also: the point read against the mean shares, which nests every realization’s boundary inside one field.Training: per row, the average over share realizations of
log Σ_k softmax_k(g) exp(term_k)– on realizations, the path non-Gaussian leaves already take, the fixed training draws a known bias (“Fresh training draws” below).Storage: each realization’s population at each location, small integers under
<variable>/population/<i>, on the variable (a vector’s mixture is over the row), absent where no mixture models the variable; coarsening leaves it missing. Built on a refactor first: the realization axis was handled by the namesimulationsin about eight places (carrying, coarsening, subsetting, Zarr both ways, the export, the path, the realization walk), so a declared list of realization stores replaces the name, held by a test before the population store exists.Gates: the split gate (rmse and CRPS within 5% of one GP); crossing and skew rerun under latent shares; Tom East with folds by hole, three arms – fixed shares, latent shares from the metals alone, latent shares tied to
Code_Simple– scored by out-of-fold CRPS per metal and by the expected shares’ out-of-fold agreement withCode_Simple. Internal until every gate passes; figures with the results.
Phase 2 measured, 2026-10-01. Tom East’s folds are built against the assayed intervals themselves (160 / 160 / 160 / 159 / 159). Built against every logged interval, most of them in holes with no assay, they mimicked distances so long that one fold held out 407 of the 798 and the rock type’s own out-of-fold prediction agreed with the log at 0.609.
gate |
no amplitude |
with amplitude |
populations and shares on separate GPs |
verdict |
|---|---|---|---|---|
split |
rmse 0.221, CRPS 0.046 (one GP 0.192, 0.078) |
rmse 0.166, CRPS 0.034, amplitude 26 |
rmse 0.149, CRPS 0.032, amplitude 27 |
passes |
crossing |
local agreement 97.5% (100% apart) |
the same, amplitude 0.035 |
– |
unchanged |
skew |
CRPS 0.567, agreement 96% |
CRPS 0.558, 96%, amplitude 0.033 |
– |
unchanged |
The amplitude trains large where the shares must jump and near zero where
they should be flat, which is what lets one construction serve both. On the
split, the populations and the shares are two BasicGPs of two outputs
each on the gate’s grid of 45 inducing points, so each trains its own range.
Tom East, out of fold over the new folds. Every arm reads one root: 500
k-means centroids of the assayed intervals divided into five experts with
10% overlap (110 points each), through Anisotropy3D(100, 0.75, 0.5, 345, 15, 70); the populations (six outputs) and the shares (two) on separate
BasicGPs. Cross-validation drops the held-out data only – a fold model
rebuilt from the save holds the same 550 points to the bit, since only the
data argument is swapped.
arm |
CRPS Ag / Pb / Zn |
goodness |
expected shares agreeing with |
|---|---|---|---|
one Gaussian, recommended chain |
84.6 / 4.25 / 3.48 (rmse on Ag 4e5) |
0.83 / 0.56 / 0.69 |
– |
one Gaussian, |
46.0 / 3.77 / 3.45 |
0.71 / 0.64 / 0.55 |
– |
fixed shares |
46.7 / 3.32 / 2.89 |
0.78 / 0.81 / 0.79 |
0.525 (constant; chance 0.525) |
latent, metals alone |
47.3 / 3.44 / 2.85, amplitude 12.5 |
0.63 / 0.66 / 0.72 |
0.595 |
latent, tied to |
47.1 / 3.31 / 2.81, amplitude 6.8 |
0.73 / 0.77 / 0.79 |
0.609; the rock type’s own 0.604 |
Every mixture beats the recommended chain on every metal, so the CRPS half
of the gate passes; the plain BoxCox -> ZScore likelihood is the best on
Ag, by 1.5% over the best mixture, and the worst on Pb and Zn. The
agreement half was “well above chance”, and decided 2026-10-01: the bar
is the rock type’s own out-of-fold prediction – the logged rock type is
barely predictable between holes here, and a share field cannot know more
about it than a likelihood trained on it. Against it the shares tied to the
rock type pass, 0.609 against 0.604; they follow that prediction at
0.742. In sample the shares agree at 0.94 in every arm. Every phase 2
gate passes.
With a bias per share (same configuration; the baselines and fixed shares unchanged):
arm |
CRPS Ag / Pb / Zn |
goodness |
agreement |
shares away from the data |
|---|---|---|---|---|
latent, metals alone |
45.6 / 3.28 / 2.71, amplitude 11.6 |
0.66 / 0.68 / 0.75 |
0.713 |
0.978 / 0.022 |
latent, tied to |
46.8 / 3.29 / 2.81, amplitude 7.0 |
0.73 / 0.77 / 0.79 |
0.619; the rock type’s own 0.605 |
0.537 / 0.463 |
and the split gate rmse 0.146, CRPS 0.031, its bias at 0.96 / 0.04, the
crossing and skew cases unchanged with their biases near equal shares. The
shares from the metals alone now return to the lower population away from
the data – waste as the ground’s background – and are the best arm on every
metal, Ag included, and the best predictor of the logged rock type out of
fold, above the rock-type likelihood’s own 0.605. Tied to the rock type the
bias barely moves: the share columns are the rock-type likelihood’s latent,
and CategoricalGaussianIndicator has no bias of its own, so away from the
data it says even odds and pulls the shares there. Goodness is lower for
the metals-alone arm (0.66 to 0.75 against fixed shares’ 0.78 to 0.81):
its intervals are narrower than the held-out data support.
With a bias on the rock type too (CategoricalGaussianIndicator(2, bias=True), added 2026-10-01 at the user’s request, optional because a
save stores its parameters by position): CRPS 46.0 / 3.25 / 2.79, the
shares’ agreement 0.607 against the rock type’s own 0.594, the shares
returning to 0.677 / 0.323 and following the rock type at 0.932. The rock
type’s own bias trains small, +0.12 / -0.13: the likelihood sees only the
798 assayed intervals, where Tom East is 52.5%, while across all 4585
logged intervals it is 11.0% and across the 3787 never assayed 2.2% –
the assays were taken where the ore is.
The rock type trained on every logged interval (4585, the metals missing where unassayed; the 23 holes with no assay in the fold of the nearest assayed hole, so the assayed folds are unchanged; the inducing points still the assayed intervals’): the rock type’s bias trains to +1.17 / -1.17 and the shares return to 0.999 / 0.001 – the waste background. CRPS 45.8 / 3.30 / 2.83, goodness 0.69 / 0.71 / 0.74; the shares’ agreement on the assayed intervals 0.637 against the rock type’s own 0.551, which fell from 0.594: read on the assayed intervals, half of them ore, a model that has learned the ground is mostly waste calls more of them waste. The bar was set on a sample the assays chose, and is fair only between arms that saw the same rock data. Training took 638 s against 159.
Scored instead on every logged interval, out of fold, by balanced accuracy (the mean of the two categories’ recall – calling everything waste is right 89% of the time there, and scores 0.5):
accuracy |
recall Waste |
recall Tom East |
balanced |
|
|---|---|---|---|---|
the rock type’s own call, all logged |
0.893 |
0.976 |
0.219 |
0.598 |
the expected shares, all logged |
0.874 |
0.913 |
0.555 |
0.734 |
the rock type’s own call, assayed |
0.551 |
0.897 |
0.239 |
0.568 |
the expected shares, assayed |
0.637 |
0.712 |
0.568 |
0.640 |
The shares find two and a half times the ore the rock type does between
holes, for a few points of waste recall: the metals at neighbouring holes
tell them where the ore runs, which the logged rock type alone cannot.
The rock type’s own call is barely better than calling everything waste.
Mapped (likelihood_mixture_figures.py latent), the shares tied to it now
draw one continuous lens dipping steeply to the northwest inside a waste
background, where they drew separate pockets at even odds between holes.
The first run under these folds, on 150 k-means points in one expert
through Isotropic(80) with populations and shares on one GP, gave fixed
shares 44.1 / 3.26 / 2.81, latent 49.3 / 3.46 / 3.06 at 0.564 agreement,
tied to the rock type 46.4 / 3.36 / 2.94 at 0.570 against the rock type’s
own 0.603 – a near miss, which the anisotropy and the separate share GP
closed; the plain chain was 58.6 / 5.58 / 8.46 there, so the configuration
helped the single likelihood most.
L — A likelihood subsystem (raised 2026-10-01, deferred). Likelihoods
composed as warpings are chained: a small protocol every component
implements (the latent columns it reads, its log density in data space at
quadrature nodes or realizations, its back-transform with the noise
integrated out, a measurement draw, initialize) and combinators over it –
the mixture above, today’s Mixture as a configuration of it, and
physics-driven likelihoods whose mean is a known function of several
latent columns (a forward model), trained on realizations through the path
non-Gaussian leaves use. To settle when taken up: the protocol, the naming
of the family (Mixture against LikelihoodMixture), and a first forward
model.
S — Fresh training draws (found 2026-09-25, kept on the list by the
user). Every training step passes seed=options.seed, so a likelihood that
trains by Monte Carlo — a warping that mixes, and since 0.8.0 any leaf that
is not Gaussian — optimizes one fixed set of training_samples
realizations: a sample-average bound, biased rather than noisy, which the
model can partly fit. On gate 1 of GaussianMixture, 20 draws reach a
bound of 36.2 and 50 draws 26.7, the scores against the truth better at 50
(RMSE 0.141 / CRPS 0.037 against 0.161 / 0.043). Fix: a seed keyed by the
iteration (the phase’s count), so a run split into chunks still replays
one call bit for bit. Gate: the same two numbers, and the chunked-training
test in test_early_stopping.py still exact.
XL — Change of support from the theory of sampling. From the author’s
paper draft, which is the specification. A window of volume v has grade
g ~ Beta(μ s(v), (1−μ) s(v)): the mean pinned to the block grade at every
support, all support dependence in the concentration. What the package
needs: the support must become model-visible (today the sample length is
deliberately metadata); a BetaSupport likelihood, genuinely non-Gaussian
with a link, whose noise is not additive and not integrated out by the
existing machinery; and a decision on how it composes with the sub-block
aggregation. It is an analytical change of support — predicting at block
support means evaluating at s(V_block) rather than fanning out
sub-blocks. A doctrine inversion worth flagging: compositing to equal length
homogenizes support, which is exactly the signal the texture parameters
need, so heterogeneous support stops being a nuisance and becomes data.
Support and censoring want the same channel. Decide once, for both,
whether they ride the data object (the GaussianData precedent, which also
reaches prediction) or the variable channel (training-only, needs nothing
new).
S — Directional data ignored under a variable named as a string
(found by reading, 2026-09-15, not run). VGPNetwork.__init__ pairs the
directional measurements with the variables by iterating the raw
variables argument, so variables="Rock" walks the letters, looks up
"R", finds nothing and trains on no directional measurement at all. A
list or the mapping spelling is unaffected. Iterate the normalized names.
S — ProjectionTo1D projects onto positive directions only
(found 2026-09-25, reading why the catalogue should not offer it). Its
directions is a PositiveParameter in [0.001, 1], so a projection can
point into the positive orthant and nowhere else – in 2-D, north-east but
never north-west. A signed unit vector (a UnitColumnNormParameter, as the
dynamic anisotropies hold their weights) would let it point anywhere; the
catalogue marks it experimental until then, and RandomProjections with
it, nothing in it training.
S — A GP node that fails to build is left among its parent’s children
(found by reading, 2026-09-15). _FunctionalLatentVariable.__init__
appends the node to parent.children before _GPNode.__init__ can raise
BrokenPropagationError, so a caller that catches the error and goes on
holds a parent with a child that does not exist. Register the child last.
2. Fitting and initialization
The theme is measured rather than assumed: training does not leave the basin it starts in, so where a start can be computed rather than guessed, it should be.
M — Gradient-free training experiments. Closed against three conditions in 2026-09-03 (see below) and kept here only for the part that survived: the memory saving is real and available today, since taking the ranges off the tape cuts peak GPU memory 3–4× and the step 1.5×.
3. Diagnostics and validation
M — [geostat] Transition/adjacency counts for categoricals. Shelved:
return to it only after the truncated-plurigaussian item, since the two
share the lithotype rule and that decision settles what this figure must
read. Which categories touch which, from the drillholes against the same
counts read from the model’s realizations. It answers what the confusion
matrix cannot see — does the model create contacts the geology forbids? —
and it is the empirical half of the plurigaussian question. Design settled:
both tables come from the same point sequence, ordered by the HOLEID and
DEPTH metadata; and because a model that reproduces every sequence at the
holes can still break the rules away from data, a second reading through the
volume is owed. Report three tables — data, model at the holes, model in the
volume — as row-normalized frequencies, with raw counts kept for the
forbidden cells.
M — [geostat] Resource classification from the ensemble. Shelved until there is a way to define mining volumes. The criterion practitioners want is the relative error of a production-volume aggregate, read off the simulations, and every input exists. Design settled: the function reads one thing, a partition of blocks into aggregates as a metadata column with weights, produced three ways — a panel size, a metadata column the user already has (a schedule naming what a quarter actually mines), or a list of solids turned into that column by the fraction each block sits inside. Read it after calibration: the ladder measured 0.86 coverage at nominal 0.90 on Jura, and a relative error off overconfident intervals flatters the deposit by exactly that.
S — A Mixture’s measurement samples bisect over the whole batch
(measured 2026-09-09). Rotating the noise nodes per location made the
mixture quantile’s sixty bisections run over (n_nodes, n, size, n_sim)
rather than over n_nodes values: 18 s against 0.1 for a 20 000-row batch
at the defaults on the CPU, 2.6 against 1.7 on the GPU. Only the
Mixture likelihood pays it, only through predict_measurements. If it
matters: bisect once on the unrotated node grid per component and
interpolate the rotated u through the monotone quantile, or halve the
iteration count (a bracket the width of the widest component reaches
1e-14 in about fifty).
S–M — noise_variance off an eight-node quadrature for an exponential-
tailed noise through a convex link (measured 2026-09-10). The second
moment integrated_backward writes comes off the same eight Gauss–Hermite
nodes as the value, mapped through the noise law’s quantile. For a
Gaussian law it is exact to 0.3% against a 200-node reference; for the
epsilon-insensitive (Laplace, once epsilon trains to zero) through Jura’s
spline chain it reads +19%/−20% of the exact law (Cu at the median and
the 90th percentile latent) and 0.63× under a Box-Cox link, the outermost
node pair carrying 10–50% of the value. The quantity itself is the
problem: with a log link the Laplace tail’s second moment has a pole at
2·sigma_log / c_rate = 1, and Cu’s fit sat at 0.956 — ±5% of c_rate,
under a nat of bound, moves the data-unit variance between 2.6 and 9.6
times the sill. Two things to do: (a) a diagnostic — compute 2·sigma/c
per column after training and warn above ~0.8 that the measurement
variance is dominated by extrapolated tail and noise_variance
unreliable; (b) more or better-placed nodes for the exponential-tailed
laws (the Sobol path already uses 64), gated on the 200-node reference.
Context: docs/benchmarks/jura_noise_footing.py and the record.
M–L — Cheaper cross-validation (requested 2026-09-08). Since 2026-09-08 the driver costs one refit per fold and nothing else, on the full 20k-row Tom v6 model eleven minutes a fold. The requirement, the author’s (2026-09-10): the answer must be general, serving any likelihood and any network configuration.
Leave-expert-out (the author’s, 2026-09-10). Remove one
expert at a time from a trained multi-expert model, predict the data with
no retraining, and weigh each point’s leave-one-expert-out predictions by
the trained expert weights; a single-expert model is small enough for
cross_validate. First look on Walker V, docs/benchmarks/leave_expert_out.py
(grid experts at step 26, range 50, ZScore -> Spline, 500 iterations, one
seed), rmse against the samples:
experts |
in-sample |
leave-out, weighted |
leave-out, home expert |
refit on home-expert folds |
spatial folds (today) |
truth, exhaustive grid |
|---|---|---|---|---|---|---|
4 |
193 |
317 |
367 |
336 |
223 |
180 |
9 |
182 |
301 |
442 |
348 |
222 |
171 |
16 |
181 |
276 |
447 |
376 |
222 |
172 |
Leave-out took 1–3 s, against 50–350 s for the refit and 50–110 s for
today’s route. Three findings. (1) The literal reset is not a removal: an
expert set to the fresh-init values kept 12–67% of its weight on its home
points, and with delta at its upper bound up to 15%; exact removal is a
mask on the weights before they are normalized. (2) Removing an expert
removes capacity, not data: the experts are trained jointly and
co-adapt, so the survivors were never taught to cover the removed
expert’s ground. The home-expert score sits 9–27% above an honest refit on
the same folds, and at points three or more experts share it is 2.3–2.6
times the in-sample error, not near it – the overlap leak one would fear
is not what happens. (3) The larger error is geometric: an expert’s region
is a far larger hole than the prediction target has, so even the honest
refit on expert-shaped folds reads 1.9–2.2 times the true error, where
today’s spatial folds read 24–30% over it. Shelved 2026-09-10, by the
author’s decision, for missing the requirement above: it needs a
multi-expert model, and it shares the flaw measured below for the
inducing-point filters – a datum’s information sits in the kept
neighbours’ trained state, which no removal reaches. Were it taken up
again, its gate stood at the same arms on Jura with experts (its 100
held-out points as the truth) and two more Walker seeds, adding CRPS and
the coverage a conformal cut from each arm’s PITs achieves on the truth
set, killed if leave-out stayed more than 20% from the truth on most
layouts; and its build at a non-trainable mask on the multi-expert root,
read wherever the weights are normalized (both get_expert_weights sites
in BasicGP and in MultiStructureGP, the consensus cross-prediction
included, so one trace serves every expert), with models.leave_expert_out
returning what cross_validate returns.
Then: neutralizing the fold’s inducing points through delta (the
author’s, 2026-09-10), measured in docs/benchmarks/neutralized_sites.py.
As stated it cannot work: the posterior mean k_x K^-1 L a + b never
reads delta, so the held-out prediction stays at its in-sample value and
only the variance moves. Removing the points properly, as
pseudo-observations whose targets reproduce the trained mean, reads 17–23%
above the honest refit; dropping them and kriging from the kept means reads
in-sample; a one-step ring overshoots either way. A datum’s information is
spread by its kriging weights over every inducing point within reach, so
no assignment of inducing points to folds removes it cleanly – the flaw
leave-expert-out has too, which makes its gate above likely moot. Putting
the diagonal on the prior instead (cov = covariance_matrix(ip, ip) + jitter in BasicGP.refresh, the trained state kept) is not well defined:
the whitened mean is a sequence of innovations in the Cholesky order, so
changing some points’ prior changes what every later coordinate means.
The same trained model and folds read 243 and 238 (one expert, nine) in
the stored order, 238 and 217 with the order reversed, and 203 and 205
with the fold’s points last – which is exactly the kriging arm, since
that order keeps the kept points’ posterior means. A score that moves when
the inducing points are relabelled cannot be trusted, even where one order
lands near the refit. Keeping the inducing means while the prior changes
(the author’s three steps: m = L a, the diagonal added, a' = chol(K')^-1 m) removes the order and lands on the kriging arm exactly,
since the mean at x becomes k_x K'^-1 m: 203 and 205, 4–7% under the
converged refit; with the one-step ring 283 and 272, 27–29% over it. Every
held-out sample sits within 0.35 kernel ranges of a kept inducing point
(median; 0.7 at most), whose posterior mean already carries what the
sample said. docs/benchmarks/filtered_prior.py writes two readings
into BasicGP.refresh explicitly, each checked against BasicGP at zero
filter and against its own closed-form limit at a filter of 1e6. The
author’s definition – after the three steps, K’ replaces K in every
formula, K' + D included, nothing else adjusted – reproduces the
wrapper’s mean-kept arm (the same latent variances to three decimals, rmse
within 0.1), so that arm had computed it; its limit is the kriging of the
kept inducing means with the variance 1 - k_k (K_kk + D_kk)^-1 k_k. The
other reading keeps the trained posterior q(u) = N(m, S) whole, S computed
under the trained prior, and lands at in-sample: 202–206 with one expert,
182–185 with nine. Across k = 4, 5 and 10 the two read 202–207 and
182–205 where the converged refit reads 219–235 and 213–247, and neither
responds to the fold size. Filtering inducing points, whichever way it is
written, predicts from a posterior fitted with the fold’s measurements.
The author’s projected prior (2026-09-10) is singular as written, both as
K + Delta - K (K + Delta)^-1 K and as K + Delta - K_.k K_kk^-1 K_k.: its
kept rows and columns are exactly zero, since K - K (K + Delta)^-1 K = K (K + Delta)^-1 Delta and Delta is zero there (4e-16 against K’s own 1 on
Walker’s layout), so it cannot stand in for K. With K_kk kept on that block
(projected in filtered_prior.py) it reads 203.1–203.3 with one expert,
the kriging limit again, and with nine it falls with the filter from 423 at
Delta = 1 to the limit by 1000, crossing the refit at a filter that moves
with the fold size: 214.7, 210.4 and 203.3 at Delta = 100 for k = 4, 5 and
10, where the refit reads 246.8, 213.0 and 214.4. Short of the limit the
prior no longer agrees with the kernel’s cross-covariance: the latent
variance at the held-out rows collapses to 0.000–0.003 at Delta = 10 and
below, and the expert weights follow it
(filtered_prior_*_projected*.txt).
Zeroing the filtered points’ means as well changes nothing,
since the prior diagonal has already taken their weight; zeroing them
under the stored prior instead pins the fold’s inducing values to the
prior mean, reads 265–389, and needs whitened values past the ±10 bound.
Across k = 4, 5 and 10 (neutralized_sites_*_k*.txt) the nearest-location
rule barely moves – 202–207 with one expert, 201–205 with nine – while
the converged refit moves with the fold size, 219–235 and 213–247: the kept
inducing points sit beside every held-out location whatever the fold, so
the rule is blind to the one thing cross-validation measures. The row
solve follows the refit at every k with one expert (within 0.4%) and sits
6–13% under it with nine (3–9% with the fold’s delta at the bound), the
gap largest at k = 4. The same
trick on the data’s noise diagonal does work: a held-out row at infinite
noise is a row left out, and for a Gaussian leaf with the hyperparameters,
warping and noise frozen the bound is exactly quadratic in the whitened
means and biases, so the fold optimum is one ridge solve over the training
rows (item (1) of the shelf below, reached from this side). rmse against
the samples, Walker V, one seed:
layout |
in-sample |
|
sites |
krige |
rows |
rows, fold’s |
refit-200 |
refit-1000 |
truth |
|---|---|---|---|---|---|---|---|---|---|
one expert |
202.3 |
203.3 |
268.4 |
203.9 |
218.6 |
218.9 |
225.2 |
219.0 |
187.4 |
nine experts |
181.9 |
203.6 |
259.7 |
204.1 |
198.6 |
207.3 |
221.8 |
213.0 |
170.7 |
The row solve took 2–4 s for five folds against 83–305 s for the
1000-iteration refit. With one expert it matches the converged refit to
0.2%; with nine it sits 3–7% under it, the spread’s role in the expert
weights being what the refit re-solves and the solve does not (the
refit’s own score moved 4% between 200 and 1000 iterations, so part of
the gap may be the refit’s). Adam’s 500-iteration state was itself 2–6%
short of the exact optimum on all rows. Shelved 2026-09-10, by the
author’s decision, for missing the requirement above: the solve is exact
only for Gaussian or multivariate Gaussian leaves under a frozen warping on
a single layer – another likelihood needs Newton passes, each a solve like
this one, and a deep tree’s interior keeps E2’s memory – and with several
experts delta has to be refit beside it. Were it taken up again, its gate
stood at CRPS and the coverage of a conformal cut from its PITs, Jura’s
held-out set, more seeds, and a refit run to convergence for the experts.
Shelved 2026-09-10, by the author’s decision, for something simpler and
more general. The candidates a four-angle review produced; the structural
claims were checked against the code, no saving was measured. (1)
Closed-form refit for Gaussian leaves: with the hyperparameters frozen the
bound is exactly quadratic in the whitened means and biases (the mean is
linear in them, the variance and the expert weights depend on delta
alone, the KL is quadratic, the 64-node quadrature of a Gaussian
log-density is exact), so the refit is one ridge solve per column and
every fold comes from one pass, the full-data statistics minus the
block’s; delta’s optimum never sees the measured values, only where the
samples sit, so a frozen all-data delta leaks the held-out locations.
(2) Score only some folds of the partition. (3) An honest ridge start for
the fold’s leaves. (4) Newton passes for non-Gaussian leaves – the
rock-type likelihood’s objective is the log of a probability under q, not
an expected log-density, so its site weights must be the second derivative
in the mean, not -2 dl/dvar, which goes negative on misclassified rows.
(5) Measure the refit’s budget and stop on the gradient norm. (6) Refit
only the experts near the fold, with compact folds – nothing to gain on
Walker, Jura or Tom v6, which are too few ranges across. (7) Newton-CG over
the whole network, interior included – a small gradient certifies a
stationary point, not the basin a fresh fit would reach. (8) Hoist the
frozen root out of the training step. (9) All folds in one traced loop.
With them go the older candidates this item carried: importance-sampling
leave-out on blocks, the EP-style cavity, and the closed-form leaf under a
frozen interior (E2’s verdict stands).
S–M — Categorical scores in cross_validate (suggested 2026-09-11).
The driver’s score table holds continuous variables only: a categorical
one is predicted into the out-of-fold container like the rest and then
given no rows, its docstring telling the reader to subset the container by
fold and call the variable’s own compute_metrics. That table reads the
labels and the probabilities the container already holds – no measurement
samples, so none of the streaming the continuous scores need – and since
2026-09-11 carries kappa, precision and recall, the quantity and allocation
split and the Brier and log scores beside balanced accuracy, Jaccard and
Matthews. Wanted: those rows per fold and pooled, from the driver. To
settle: the shape, the continuous table being one row per variable,
component and fold and the categorical one a score by category, and
whether decluster= passes through.
S — Scores for ordered categories (suggested 2026-09-11, with the
categorical scores). Every categorical score is of one category against
the rest, which suits unordered rocks and is blind to order: for an
OrderedRockType, calling the unit next door and calling one three units
away cost the same. Two scores see the order: Cohen’s weighted kappa
(Cohen 1968), linear weights giving partial credit for a neighbour, and the
ranked probability score (Epstein 1969), the proper score over the
cumulative probabilities, which is to an ordered category what CRPS is to a
grade. Both read the whole classification at once, and the author chose one
column per category for the kappa (2026-09-11), so where an overall score
lives in the table is the open question.
S–M — Batched prediction from a latent node, into a container
(requested 2026-09-05; plan agreed and built 2026-09-28, 0.8.5;
test_predict_node.py. Found in the building: a prediction resumed from
unpredicted() matches a whole one to rounding, 5e-15 at the last rows of
a batch, not to the bit as the model’s predict does – the same draws,
the batch’s shape moving the arithmetic).
A node’s predict(x, x_var, n_sim, seed) returns the raw four-tuple and
nothing else: no batching, no refresh, no container to land in, so the
values inside a tree – where a GPWalk moved the coordinates, what a
shared parent says before two leaves diverge, what a Linear trend
contributes – were reachable only by hand (chapter 17’s benchmark read the
walked coordinates through interpolate in hand-cut chunks). Decided:
The door is on the model:
VGPNetwork.predict_node(node, container, n_sim=None, name=None, labels=None, where=None), run on_over_batchesso the refresh, progress, cancel,where=(a mask or a stored filter) andn_sim=Nonearepredict’s own. Any node above the input, the leaves included; an input node is refused (the transform answers that), as is a node outside the model’s tree, and on aProjectedVGPif it does not work there. The model’spredictis unchanged.The result is a
LatentVariableofsizecolumns, named after the node unlessname=, its parts labelled bylabels=or numbered; each holdslatent_meanandlatent_variance(the propagated moments) and the simulations – no prediction column, no measurements, likelihood, unit or back-transform. Declared like every variable, so paths, frame, pyvista, Zarr, subsetting and carrying come free;latent_meanis the predicted marker. The docstring says the realizations carry only the variance the inducing points explain, and that on a nonlinear node the moments are approximations. An existing variable of the same name and another class, size or simulation count is refused; a model refuses it as a training target, as it refuses aDerivedVariable.Realization s is the draw the model used on the way to the first leaf that reaches the node: the seed shifts of
Add,LinearCombination,MultiplyandProductOfExperts(parent i getsseed[0] + i) replayed along that path, so realization 7 of a trend is the trend inside realization 7 of the output – through operation nodes; a GP above another reads its moments, not its draws. The gate: the node’s realizations, carried through the operations above, give the leaf’s.Point support only:
Blocks3D,BlockSet3D,RotatedBlockSet3Drefused, aGrid3Dover the same box suggested – the sub-block average is a likelihood’s, and a node has none.Catalogue:
predict_nodein the workflow,LatentVariableinvariable_typeswith its columns’ roles and scales. The store format stays 2; an older geoML meeting the class fails naming it.Docs: a section of chapter 17 on the walked coordinates; no figures.
M — Latent variables as a model’s input (split from the item above,
2026-09-28). A LatentVariable may enter a new model as a GaussianInput
– its latent_mean the coordinates, its latent_variance the location
variance – but the door is not settled because the use is wider than one
variable: several latent variables joined into one input, and latent
variables from different models joined. To settle: the door (a container
method building a GaussianData, or GaussianInput taking variables),
how variables from different containers are matched by location, and
whether the full variance or the explained part goes in (the full one is
the honest uncertainty of where a location sits).
M — Integrated gradients for explainability. Attribution of a prediction to its inputs, computed as a quadrature sum of gradients along a straight path from a baseline — everything here is differentiable with respect to the coordinates, so the machinery is nearly free. Most useful where the network takes more than coordinates. To settle: what to attribute (the latent mean, the back-transformed prediction, a category’s probability — each a different function, and the nonlinear warping means latent- and value-space attributions differ), and the baseline, which is the method’s crux and is not obvious for spatial inputs. Attribution of the uncertainty is a separate and possibly more interesting question for drillhole planning.
S — refit="leaves" leaves a GradientConstrainedInput as it was
(found by reading, 2026-09-15). models._terminal_gp_nodes treats it as a
stateless root, although it carries alpha_white, delta and bias of
its own, so a leaves refit over a gradient-constrained model keeps what
that node learned from the held-out rows. Count it among the GP nodes.
4. Data, I/O and interchange
(Mesh sets: done 2026-09-11, 0.6.10 — see “Settled by measurement”
below for the numbers. MeshSet contours a block model or a grid at every
cut-off, or once per category, for the prediction and every realization,
and holds the bodies as one mapping with the reports a set can make.)
(Mesh operations through Manifold: booleans done 2026-09-10, 0.6.10 —
see “Settled by measurement” below for the numbers. Solid3D’s union,
intersection and difference go to manifold3d itself and are exact; the
rest of what Manifold offers was measured and replaces nothing of
geoML’s.)
S — Investigate what get_contacts makes of an unlogged interval
(asked 2026-09-25). as_point_data(contacts=True) makes no contact where
a class meets an unlogged stretch or a gap, which is what GeoScape’s “To
points” does. get_contacts merges runs with two missing values counting
as one run, so a class meeting an interval logged with no category makes a
contact with one side empty – and as_classification_input inherits it.
Whether that is ever wanted, and whether the two should agree, is the
question; nothing has been measured.
S — Surface I/O residue. OBJ/PLY/STL both ways, the vendor formats, and any attribute travelling with the geometry. Nothing has demanded them yet.
S — RotatedBlocks3D has no geoh5 door. Built 2026-09-11 as what
BlockSet3D.as_blocks3d returns for a rotated set. A geoh5 BlockModel
carries one rotation, about the vertical, so a to_geoh5 would write a
model turned in azimuth only, dip and rake refused as the octree writer
refuses them, with its origin at the turned lattice corner –
write_grid_blocks takes the corner from the centres’ minimum, which is
right only unturned. Blocks3D.from_geoh5 could then return a rotated
BlockModel as a RotatedBlocks3D rather than today’s
RotatedBlockSet3D(max_levels=0); that changes a return type, which is
why it waits for a user.
(get_contour(close=) meeting its own cap edge-on and rounding the box’s
edges: done 2026-09-14, 0.6.10. Found building mesh sets on Assen
(2026-09-11): FeO_total at 0.7 kept a layer one cell thick against the top
of the lattice and met the cap along one edge four triangles shared, the
welded fallback came back open at 44 edges, and a body closed against the
box rounded its edges by half a boundary block (5% of a slab-filled 80 m
box left uncovered); a set retried such shells a hair off their level,
thirteen attempts and twenty minutes a shell on Tom v6. The split of
touching edges (2026-09-13) took the retried Assen shells from 16 to 8; the
rest were the cap folding flat onto itself, drawn as it was through lattice
points valued exactly at the level. Now the ghosts carry copies of the
blocks they mirror, the surface closes a cell past them, and Manifold cuts
the body at the box: the slabs tile their box, a ball against the box
reads within 1.40% of Monte Carlo at worst where it read 2.61%, and a
census of every Assen realization at its own level finds none of 250
shells needing a retry. Not rerun on the Tom v6 model.)
(simplify keeping half its promise: done 2026-09-14, 0.6.10,
docs/benchmarks/simplify_both_ways.py. The cause suspected was the one:
the quadric pre-pass came back open on both Assen shells, was taken at 1
and 2 m for being within half the budget, and every cut started from it,
so both came back whole; a pre-pass that is not the mesh’s kind is dropped
now, and BIF and Hematite simplify at 0.5, 1 and 2 m to 12 522-38 856
triangles, within budget both ways – the reverse is measured too – in 3
to 7 s. The 0.72 m once read between BIF’s vertices and its simplified
shell was 35 vertices no triangle uses, which its store carried.)
M — A categorical realization’s bodies leave a gap where three meet.
Found building mesh sets on the Assen rocks (2026-09-11,
docs/mesh-sets.md): each category contoured on its own field, taken block
by block, three realizations’ six bodies overlapped by about 1.1% of the
model and left 0.75-0.84% uncovered. The overlap is settled
(2026-09-14): a realization’s categories come from one cut and one paint
of its draws, each field read off the draws’ corner means
(BlockSet3D._contour_fields), overlap 0.0000% of the model, 54 s a
realization instead of 72. What is left is a gap of 0.09%, where three
categories meet inside one cell and each field’s marching-cubes piece cuts
off only the corners it wins; a winner’s margin taken against its
neighbours’ winners closed a tenth of it on a plain lattice, so the cure
is the cell’s, not the edge crossings’. What would close it is a
multi-material contour – each cell split by the argmax of its corner
values, each interface drawn once and handed to both sides – which VTK
does not offer on scalar fields (its SurfaceNets works on labels, losing
the sub-cell placement). The prediction’s own bodies leave 0.053% and
overlap by 0.008%, contoured on the likelihood’s per-block fields one at a
time; they would take the same cure.
(One contour of a big block model costing 4 to 8 GB: more than halved
2026-09-13, 0.6.10, in the same time and with every mesh identical to the
bit – see the changelog. The corner tables of the cut and the paint are
built a corner at a time with 32-bit ranks, the first level of the cut is
kept on the set, and the paint’s fills go in chunks; on Assen the peaks
fell 55-70% (docs/benchmarks/contour_stages.py). A mesh set’s pool is
sized by what its prediction’s contours measured, so it now takes more
workers on its own. Not done, and not needed yet: keeping the table to the
blocks that can be marked, one ring past any whose corners straddle the
level, which would leave the first level’s the only full one. Measure the
Tom v6 model before taking it up.)
(Mesh set workers scaling 2.2 times on eight: explained 2026-09-14,
docs/benchmarks/mesh_set_workers.py. None of the three candidates: the
parent’s writes took 3-15 s of a 100-290 s pool, the 114 MB a realization
sends back nothing measurable, and VTK runs one thread here. A forked
worker runs Manifold on one thread, the parent having started its thread
pool (a boolean 3.8 s on one thread against 1.1 s on eighteen, which burn
five times the CPU), so a task is one core’s work – 93 s, where one
process takes 45 s on two. And the machine saturates: 8, 12 and 24 workers
give 12.0, 10.8 and 11.2 s a realization, each task’s CPU time growing with
the workers for the same work (93, 125, 256 s), every stage alike – a
16-core, two-channel Ryzen 9 7950X out of memory bandwidth, then out of
cores. Steps 3 and 4’s leaner contours took eight workers from 27 s a
realization to 12.8 s (3.6 times one process); OpenBLAS held to one thread
a worker saves 38% of each task’s CPU, 80 s of 173 having been spinning,
for 5% of the time. Past that the only lever is less memory traffic a
contour.)
(A mesh set’s summary showing only what the limits left: done 2026-09-14, 0.6.10 – with limits present, printing a set shows each shell’s volume before them beside what is left, a set reopened from its store too, where the Tom v6 sets an uncertainty limit had emptied read “volume 0” at every cut-off.)
(A contour through blocks without a value getting NaN vertices: done
2026-09-15, 0.6.12. Every fourth block of a small ball model unpredicted
put 138 of 548 vertices at NaN in a Solid3D of NaN volume, and a region
left out whole did the same wherever the surface reached it – the
contiguous one once reported clean had simply not been reached. A
valueless block is now read across from its corners where valued blocks
have them all, and left whole rather than cut; anything else is absent
ground, where an open contour stops and a closed one closes, within a
hundredth of a cell. Mesh3D refuses a point that is not a finite
number.)
S–M — Refine on the average, not on every realization (requested
2026-09-29; built 2026-09-29, 0.8.5: 2633 blocks against 582 on the
gate’s case, the empty half left at 32 coarse blocks instead of 1439, the
volume on the wrong side of the cut-off 625 m3 against 500;
docs/variable-block-models.md). needs_splitting marks a block by divided
(likelihood._divided): the share of realizations whose sub-blocks fall
on both sides of a cut-off, each realization judged on its own. Where the
data do not constrain the model, every realization is rough on its own
account and straddles somewhere, so the unconstrained ground is refined as
hard as the contacts are – the blocks multiply where there is least to
resolve. Wanted: judge the split on the average alone – the sub-blocks of
the prediction (the mean over realizations, per sub-block), a block cut
where those fall on both sides of the cut-off, a category’s on the mean
ind_skew. This reverses _divided’s own argument (that a block is
divided only if one realization holds two answers, so that model doubt
never licenses a cut): the mean is smooth where the realizations disagree,
which is the point. Only the continuous likelihoods change – a category’s
ind_skew is read off the probabilities, already expectations. Plan
agreed 2026-09-29: _divided judges the per-sub-block mean over
realizations (the prediction at sub-block support, the field a contour is
drawn on), so divided is redefined as a 0/1 flag, its catalogue role a
value on flag; tolerance on refine and needs_splitting is kept
and deprecated – a warning when passed, removed later – since any value
under one now gives the same answer. The gate, red first: a small 3-D set
with data in one half and inducing points in both, the unconstrained half
left nearly unrefined, the contact still refined, the refined contour
within a stated tolerance of a fine grid’s; measured before and after, and
recorded in docs/variable-block-models.md.
L — Import a BlockSet3D from CSV. There is no way to read a block
model somebody else made. The hard part is not parsing: BlockSet3D is a
strict octree, and most vendor sub-blocked models satisfy none of its three
invariants, so a general sub-blocked model cannot be represented exactly.
The first decision is what to do with one that does not fit — refuse it,
snap it onto the lattice (changing volumes), or fall back to PointData
carrying block size as metadata. Inferring the lattice from centroids and
sizes is mechanical: the base cell is the elementwise minimum, and every
distinct size must be base × discretization**k per axis, which is a
stronger test than a common divisor and exactly what the lattice needs.
Fullness is the other snag, since exports usually hold only the blocks
inside a wireframe; filling the gaps with unpredicted blocks and a filter
matches the always-full-plus-filters design. Import-time checks for overlaps
(via octree addresses, no lattice painting) and gaps are both required.
Test on a real Micromine export before building. Only if real files
demand it, phase two is a lattice-free FreeBlocks3D with per-block sizes
as a real attribute — block-support prediction and tonnage still work,
refinement and contour-cutting do not.
M — Save a container into the store it was opened from. Refused since
0.6.13, because to_zarr emptied the store before copying the arrays it
was reading from it, and wrote back NaN. What a notebook wants after
open, a prediction and to_zarr(same path) is an in-place write: the
arrays already in the store stay, the new variables’ arrays are added, the
removed ones deleted and the root attribute rewritten – what
MeshSet.to_zarr already does for its description alone. The same for a
model loaded and saved over its own store.
5. Housekeeping, docs and release
(The tag’s full CI job dying at 93%: done 2026-09-15, 0.6.12, and
passed on the v0.6.12 tag in 37 minutes, the first full job to pass on
a tag since v0.6.9’s. On the v0.6.10 and v0.6.11 tags the suite ran clean
to 93-95% and was cancelled with no test failing; as one pytest process
it peaks at 23.1 GB, and the runner has 16. The job runs one process per
test file now: under a 14 GB cap every file passed, the heaviest peaking
at 6.1 GB.)
L — Simplify after the expected kernel, breaking old saves (agreed 2026-10-08, after 0.9.0; a major version). 0.9.0 keeps every save opening by carrying two propagation rules side by side. Once the expected kernel has been in use for a release, drop what it made obsolete, and say in the changelog that saves from before cannot be read (or ship a converter that refits):
GPOptions(propagation=)and marginal propagation: the Paciorek inflation incovariance_matrix,inducing_points_varianceas a separate diagonal, and theNonecovariance branch in every node.GPOptions(expert_propagation=)and the consensus rule (the O(E²) cross-prediction inBasicGP.refreshand its slot twin).Spherical,CubicandCosineas kernels of a GP node, and the refusals that guard them.Possibly the
kernelargument itself, nodes described by their smoothness instead (the Matérn family’s order, the Gaussian as its limit), which every remaining kernel is a point of.UncertainInputGP, onceBasicGPtakes the second moment at an uncertain input (the item in section 1): measured 2026-10-08, the expected kernel alone misses the variance there by up to 133%, whereUncertainInputGPis within 3%.Deprecated in 0.9.0 (ignored under
"joint", removed here):GPWalk’sprecisionparameter and the variance shrinking it drives, and the walk’s displacement term,1/2 sum (moved - start)² / spread²over the walked inducing points – replaced under"joint"by an isotropic GP reading the walk, documented (measured 2026-10-09: -6.1 a new hole on the folded section against -10.7). Measure nothing new for it: it removes code whose replacement the 0.9.0 gates passed.
M — Keep the package skill true at every release (agreed 2026-09-26;
built 2026-09-26, 0.8.2: --show/--list/--json, sync.py, the
skill rewritten, test_skill.py and test_skill_release.py).
Found: two skills both named geo-ml — the research skill outside the
repository (research line, notation, LaTeX, style, and a section 5 on the
package) and the package skill in plugins/geoml/skills/ — whose package
text had drifted apart, no script generating one from the other though this
file’s Version note said one did, and every release bumping only the
version line: at 0.8.0 the package skill still named the shims removed in
0.7.0 and never mentioned the parametric warpings, leaves, MeshSet, the
catalogue, progress or unpredicted. Settled:
Names: the package skill is
geoml, the research skillgeoml-research, so the two never share one in a session.One source: the package skill holds all package knowledge. The research skill’s section 5 shrinks to a pointer and about ten lines (the object model in a paragraph, the install line, where the manual and the reference live), since it is also used where the plugin is not installed.
Prose only for what the catalogue cannot say: the object model, the workflow, the recommendations, the gotchas, the geostatistical intuitions. The class lists and argument details go; a recommendation names its classes and sends the reader to the catalogue for arguments. Internal classes are named nowhere, experimental ones only as such.
The catalogue queried, not copied:
python -m geoml.cataloguegains--show NAME(short or dotted; an ambiguous short name lists the candidates),--list CATEGORYand--json, writing the whole file as now when given a path. The skill has the agent query the installed version for any signature or bound, and falls back to the site’s reference pages where geoML is not installed. A snapshot was rejected: stale the moment the user installs another version, and the whole catalogue is 282 KB (about 70k tokens).The manual replaces the notebooks: the 17 chapters, run at every release by
test_manual.py, copied intoreferences/as Markdown with their figure links rewritten to the site (the figures are 5.7 MB); the eight notebooks, which predate the path notation and nothing tests, go.notebook-style.mdstays.Checks: a name check in CI’s structural job (every dotted name and every class or function the skill mentions resolves and is not internal) and a code check at release (every Python block in the skill runs).
The release step, written into CLAUDE.md’s Version note: bump the version; run
plugins/geoml/sync.py(the chapters copied, links rewritten); review the skill’s prose against the changelog’s top section; both checks pass.
(The tag-triggered manual CI job: verified 2026-09-14. At v0.6.9 it was
killed at 22 minutes of a 90-minute budget with no pytest output, the OOM
signature of a 16 GB runner accumulating TensorFlow, matplotlib and
pyvista. With test_manual.py running one subprocess per chapter, it
passed on the v0.6.10 tag in 28 minutes. If it ever dies the same way
again, the next lever is not a longer timeout.)
(Chapter 13’s variogram verdict, read against its figure: done
2026-09-15, 0.6.12, docs/benchmarks/walker_zero_shift.py. The model
did fit its noise high, and the zeros were why: Box-Cox’s default shift of
a millionth put Walker’s 22 zero samples so far down the logarithm that
they pulled the exponent from 0.58 to 0.42, and the steeper inverse
widened the noise where the grade is high, noise and ground together 1.19
of the data’s declustered variance. Shifted by one, 0.98, and the fan
follows the true variogram within 7% from the second lag on; denser
inducing points, longer training and an exponential kernel mended none of
it. Every chapter that builds Walker’s V shifts it now, 4, 5, 7, 11, 13
and 15, and 13 and 15 read their figures anew; the shortest lag’s
comparison with the truth is measured now, 26 900 true against 46 600 in
the declustered data. Chapter 16’s Jura holds no zeros, its smallest
value 0.135, and keeps the default. Chapter 5 described its chain as
chapter 4’s “with a Scale in front” and credited “the softplus in the
chain” with its positivity, neither of which it held; corrected with it.)
S — Chapters 16 and 17 no longer reproduce their figures byte for byte (found 2026-09-15, at the 0.6.12 release run; chapter 16 fixed 2026-09-15, 0.7.0). Chapter 16 changed from run to run: two runs of one checkout printed rmse 0.765 / 2.645 / … / 32.830 and 0.768 / 2.646 / … / 32.831, its six figures moved by up to a fifth of their pixels, invisibly, and one of three runs landed back on the committed figures. Chapter 17 comes back the same in three runs, on 0.6.12’s code and on 0.6.11’s, but not as committed on 2026-08-19, at under 0.6% of its pixels. Both reproduced exactly at the 0.6.11 release the day before, so this release’s code was not the cause.
The cause was RobustPCA, whose FastMCD drew its starting subsets from
NumPy’s global generator – which set_seed does not reach. The note
above says it was seeded from the package RNG; that was assumed from
Rotation’s FastICA, which is, and never checked. Chapter 16’s chain
holds a RobustPCA and chapter 17’s does not, which is exactly the split
between the chapter that moved every run and the chapter that does not.
Seeded (warping.py, random_state from _rnd.rng()), four processes
under different hash seeds build that model to the same bits and three
train it to the same bound. Two orders that changed with the process went
with it – _Operation.get_unique_parents iterated a set, which is the
order VGPNetwork._nodes sums the KL in, and get_unfixed_variables
likewise – neither having moved a number, both able to.
Chapter 16 was re-run on the fix and reproduced all six committed figures byte for byte, printing the rmse of the higher of the two runs, 0.768 / 2.646 / 8.492 / 6.093 / 38.130 / 32.831, and §16.5’s prose still holds.
Chapter 17’s own 0.6% is not this: no RobustPCA, no Rotation, and
three runs agreed with each other before the fix. Two more agree after it,
identically, 0.554% of 17-elbo.png and 0.119% of 17-vein-surface.png
from the committed pair and nothing else touched – a changed number, not a
nondeterminism, its committed figures predating 0.6.12 by three weeks. Its
prose quotes none of the numbers it prints. Re-commit those two figures
and the item closes. Committed with 0.7.0 on 2026-09-16, after a third
identical run inside the release suite; closed.
The contract they broke is written down now:
docs/source/reference/reproducibility.md, pinned by five tests in
test_seed.py, the last of them training and predicting in another
process.
(Chapter 16’s figures: verified 2026-09-05 — rerun through the manual’s own runner after the leaves change, the seeded chapter reproduced every committed figure byte for byte, and the §16.5 prose matches what the model prints: Portlandian at a balanced accuracy of 0.5 and a Jaccard of zero.)
(S — Retire the 0.6.0 deprecation shims in 0.7.0: done 2026-09-16.
The ten one-line modules and the lazy __getattr__ that resolved them are
gone. The persistence check was re-run first, and against the code rather
than a reading of it: a store records the path of a Parametric or
_ModelOptions class only, anything else refusing to save, and walking
every subclass of both finds them in six modules – kernels,
latent.network, likelihood, models, transform, warping – none a
shim, while the ten shims’ targets define none. So no save, old or new, can
name a removed path. test_removed_paths.py pins the removal and, more
usefully, that every recordable class resolves from the path it records.)
S — latent/network.py stays out of the pyright list. About 28 of the
file’s diagnostics read inducing_points as possibly-None, and they cannot
be fixed by declaring them: None is load-bearing in the finished state.
Declaring tuple took 33 diagnostics to 67 and was reverted. Do not repeat
that attempt.
S — One checked file is not clean: warping.py (found 2026-09-11,
re-measured 2026-09-15). The 251 errors this item used to list are gone.
Pyright 1.1.411 over the [tool.pyright] list, run from the repository
root in the geoml conda env, now reports one:
warping.py:1294 (1281 before 0.8.0’s docstrings) "NoReturn" is not iterable, on
_ContinuousFlow.initialize’s x, _ = self.forward(x) – the base class’s
forward raises, so the checker reads the call as returning nothing and
the unpacking as impossible. It is on HEAD’s version of the file too, so it
predates the 0.7.0 work. Whatever moved (a library version, the checker’s
own inference) took data/drillhole.py’s 107, data/geoh5.py’s 36 and the
rest with it, which is why the old census is not worth chasing. Declare the
return on the flow base, or annotate the call; either is minutes.
6. What GeoScape needs
GeoScape is a UI that calls geoML: it draws networks, writes the Scripts
that build and run them, and reads what they leave behind. Its repository
(C:\Repos\geoscape, same owner) keeps what it needs from geoML in
docs/geoml-requirements.md, revised 2026-09-15 against 0.6.11, and the
catalogue’s format in docs/geoml-catalogue.md. “GeoScape’s item N” below
is that list’s numbering, and M3 and M4 its milestones. Every item on it
is met as of 0.8.0; what is left below is the paperwork outside the code.
Three were met before the list was worked through – one leaf per
likelihood (item 2) and names replayed by a save (item 5), both in 0.6.10,
and geoml.__version__ at run time (item 7) – then seven in 0.6.13,
items 1, 3, 8, 13 and 15 in 0.7.0, and the eighteen the list gained on
2026-09-16 (16-33) in 0.8.0, item 26 turning out not to be a bug, and
the three added 2026-09-27 (34-36) in 0.8.3 – item 34 a masked subset of
any container opened from Zarr, not only cross_validate’s. Of the two
added 2026-09-28, both in 0.8.4: item 38, read-only opens; and item 37,
which asked GPOptions.expert_propagation’s docstring to say the option
affects any network with experts. Measured, it does not – a
LinearCombination or Add of GP nodes over 3 and 20 experts trains to
the same log and predicts the same means to the bit under both rules, in
the same time – so the docstring now says an operation over GP nodes is
still one layer. The done-notes below hold what each cost and what it taught; GeoScape’s
requirements document is marked item by item since 0.8.0.
(Items 16-33: done 2026-09-25, 0.8.0. The catalogue is format 2: a
description on every entry, read off the docstring between its summary
and its first section, and forty docstrings written to have one; every
bound a constructor clips to declared as a constraint, a test holding each
declaration to the built parameter and the constructor warning when it
clips; network: false on Constant and Cosine; the multivariate
likelihoods internal; Spline public, GeoScape passing backbone="rq"
itself since the default must stay "cubic" for old saves; PCA’s width
out a param rule with a fallback; contour_rule on the two
categorical likelihoods; a containers section, every public container
listed or left out with a reason, each built and predicted into by a test;
each variable type’s columns with a role and a scale. In the code:
predict(where=) takes a stored filter’s name, n_sim=None takes the
target’s count and a different one is refused under where (one
realization used to be copied into every stored column without a word);
combine merges by distance; a categorical’s measurement columns share its
order of labels; as_point_data(contacts=True); and predict into
measured data writes each measurement’s PIT and its warped value as
metadata. Two bugs came out of it: BlockSet3D.unpredicted never
cleared its split flags, so a fully predicted set read as unpredicted
throughout (0.7.0’s union), and cross_validate scored a composition
declared in units against its assays as fractions of the whole, every PIT
0.)
What GeoScape builds on, where a change must be flagged to it rather than
made quietly: dotted class paths importable forever, already policy;
constructor arguments as the whole persisted story, since the editor
exposes nothing else; the Fourier-feature classes kept internal; batch
invariance, a location’s realizations independent of the call that
computed them given the same fit, seed and count
(test_predicting_only_the_new_blocks_gives_the_same_answer), which a
per-location residual draw would break; and node names seeding the draws,
GeoScape never renaming a node once created.
(The catalogue, GeoScape’s item 1: done 2026-09-15, for 0.7.0,
geoml/catalogue.py, design record docs/catalogue.md. 104 classes and
21 functions – the last two being geoml.progress and unpredicted,
added with items 13 and 15 so that GeoScape can discover the calls it needs
for them – every class declaring itself in a block where its module
ends, and 238 tests building each declaration against its class. GeoScape’s
spec was revised with the answers read off the code, four amendments and a
nullable field. The fixes its tests demanded went with it: Identity()
and Periodic() saved at last, the shared Identity() defaults replaced by
None, Concatenate asking its parents before claiming to pass inducing
points on, operations refusing no parents, GPWalk a non-GP and
MultiStructureGP a single structure, and the __all__ gaps closed.
NormalizeWithBoundingBox still cannot be saved, a BoundingBox not being
encodable, and is catalogued internal.)
(GeoScape’s items 4, 6, 9, 10, 11, 12 and 14: done 2026-09-15, for
0.6.13; the changelog has the detail. A target whose cut-offs differ from
the model’s takes the model’s, in its own unit and with a warning – the
user chose re-keying over raising – and set_cutoffs drops the shares of
the cut-offs it removes. The stopping rule’s trail and train_svi’s
shuffle belong to the phase, so chunks reproduce one call bit for bit, and
converged tells a Script when to stop calling. Every store writer keeps
the root attributes not starting with geoml, and none writes over a store
something still reads from, which had been silent data loss for an opened
container, a loaded model and a mesh-set realization. The cross-validation
container round-trips, tested, its score columns documented; chapters 8
and 10 say at or below, and a category’s share is the share inside it, so
GeoScape flips grades only. docs/source/reference/stores.md documents
the stores, the mesh set’s attribute gaining groups, the layout pinned
by test_the_store_is_laid_out_as_its_reference_page_says.)
(S — The reproducibility contract, written down (GeoScape’s item 8):
done 2026-09-15, 0.7.0. docs/source/reference/reproducibility.md says
what comes back the same – the parameters, the log, the predictions, the
realizations and the measurement samples, in any process; a location’s
realizations whatever the batching; a node’s draws, keyed by its name; a
training split into chunks; a mesh set contoured on workers; the catalogue
– and what does not: another machine, another version, a GPU, another
release. The one knob is set_seed before anything is built. Finding out
which was which is what closed §5’s chapter-16 item: RobustPCA left
FastMCD reading NumPy’s global generator, and two orders came off a set.
Five tests in test_seed.py, the last training and predicting in a second
process under a different hash seed and comparing every number as hex.)
(M — A progress hook (GeoScape’s item 13): done 2026-09-15, 0.7.0.
geoml.progress(callback), a context manager, one Progress(task, done, total, unit, bound, within) per unit finished – train_full per
iteration, train_svi per batch, predict per batch, refine per pass,
cross_validate per fold, a mesh set per prediction body and per
realization. The user chose the context manager over a per-call argument
(D4), and cancel is the callback raising. A ContextVar rather than an
argument because the calls nest; within names the enclosing tasks, so a
refinement’s predictions are told from a bare one. Logging was rejected:
Handler.handleError swallows a handler’s exception, so a cancel could not
travel back. Two things the build taught. A generator must not set a
ContextVar – its body runs in the caller’s context, so the mark leaks at
every yield and outlives an abandoned generator – which is why
_over_batches reports through emit and not reporting. And a
refinement’s passes are not bounded by max_levels: a block still at
level 0 can be marked by any later pass as unbalanced reaches it, so
refine reports no total. A test caught the wrong cap.)
(M — A mesh set openable mid-build, and unpredicted() everywhere
(GeoScape’s item 15): done 2026-09-15, 0.7.0. The user chose resume over
discard (D5). The set’s description goes into the store before the first
realization and is rewritten after each one, complete: false and only the
rows actually filled (_attrs(keep=), the measure tables being allocated
for every realization up front); open reads a partial store, warns, and
reports complete. Store format 2, format 1 still read since it was
always a finished set. unpredicted() is on _SpatialData now, each
variable class declaring its marker column (_PREDICTED_MARKER) because a
rock type has an entropy and a vector variable an uncertainty where a
grade has a prediction; without a variable a location counts as
unpredicted where any of them misses it. BlockSet3D keeps its override
and unions the two notions. The gate: predicting what unpredicted() names
after a cancel gives, to the last bit, what predicting the lot gives.)
(M — Chunks that split the realization axis (GeoScape’s item 3, M3,
performance): done 2026-09-15, 0.7.0. Past 32 realizations the trailing
axis is split too, ten columns a chunk. Measured cold – page cache dropped
– on a (2 000 000, 100) float64 store, 1.49 GB, at 100, 25 and 10
columns a chunk: reading one realization 0.76, 0.13 and 0.06 s, and the
reductions no worse for it, a quantile pass 1.34, 1.09 and 1.26 s and a
pass in row bands 2.53, 1.19 and 1.07 s, ten columns a chunk being ten
times the rows and so fewer, longer reads. Ten wins on every measure, so
there is no trade-off left to tune:
docs/benchmarks/realization_chunks.py. A band still holds whole rows,
reading each of its column chunks; row_quantiles/row_cdf gather the
realization axis first (_whole_rows), one pass over the same bytes, since
a block of a column-chunked dask array holds part of a row. Stores written
earlier keep their chunks.)
Outside the code, from the same list: a contributor licence agreement
before the first outside contribution is merged; and title in writing, with
no institutional claim, before any geoML code ships inside something
RockAnalytica distributes – the desktop helper (M7), a self-hosted tier,
or a geoscape-cli that imports geoML. Version 1 and the cloud need
neither, Scripts being text and portal services not distribution.
Settled by measurement
These were tried. The numbers are why they are, or are not, in the package.
Natural-gradient steps for the experts – rejected (2026-10-09; Level 2
of report/design_dual_parameter_geoml.md in the research project
OneDrive\Claude\Research\Batched experts\, record in its
report/results_2026-10-09_natural_gradient.md, prototype in
experiments/natural_gradient/). The idea: replace train_by_expert’s
per-expert AMSGrad on alpha_white and delta with a natural-gradient
step in the site natural parameters of the family BasicGP already uses
(S = (K^-1 + diag r)^-1, r = 1/delta), taken on the stored
coordinates, warmed up and then held as Salimbeni et al. (2018) do, in the
hope that a step that never decays would end the late rise of item 1
above. On the whole data the direction is right (the bound’s slope along
it 55 237 against 55 239 predicted) and gains 15 times the plain
gradient’s best step (90 nats against 6 after 10 epochs). But the bound
takes steps up to rho = 0.03-0.1 only, whether one expert moves or all
sixteen, and loses 850-25 000 nats at rho = 1, the conjugate case’s
one-step optimum: the blend is not conjugate. Batched, a step divides the
batch’s gradient by the expert’s KL share (about 1/20 at 4 visits), so the
stable rho is the whole-data limit times that share: 0.001 trained, 0.002
and above diverged. At that step an expert moves about 2% of the way to
what its batch implies and falls behind. J = 16 synthetic field, seed 1,
60 epochs, rmse / CRPS: at 4 visits AMSGrad 0.088 / 0.128 against 0.134 /
0.143; at 16 visits 0.096 / 0.130 against 0.132 / 0.142 (rho = 0.0005);
19% more time per epoch. The late rise stayed: with no clock to decay,
rmse rose from epoch 40 to 60 by 0.010 and 0.020 (rho = 0.0005, 0.001)
against AMSGrad’s 0.008, so the experts’ decaying rates are not the whole
of item 1 (one seed). Adam normalizes each coordinate’s step, which keeps
a batch’s extrapolation to the whole epoch from compounding; a scale per
inducing point rather than per expert would need per-datum sites, the
note’s Level 3, whose full-covariance family is the FullGP measured
below as no gain held out. The note’s Level 1 (storing a site mean in
place of alpha_white) only changes the coordinates Adam sees, where
whitening is the better-conditioned choice; it goes with Level 2. Stepped
in r itself, r went negative from rho = 0.1; in log r it stays
positive.
Experts that keep to their neighbours – done (raised 2026-10-02,
measured and replaced 2026-10-08, 0.8.8). inducing.experts lent an
expert points far from its own cluster. Both suspects were real: the cap
at n/k in _balanced_labels, placing points one at a time, filled the
nearby clusters first and sent the last points to whichever cluster had
room – whole cores of 25 assembled from leftovers at 20 experts – and
borrowing by the core’s Mahalanobis distance from its centre, the
ellipsoid of a cluster along a drillhole, reached past the neighbours down
the hole’s line. Measured (docs/benchmarks/expert_overlap.py, which keeps
the old algorithm) on 500 k-means points of drillholes with uneven density
and on Tom East’s assayed holes, against three compact algorithms with a
+-10% size band and borrowing by Euclidean distance to the nearest core
member – trading points after the cap, a size-bounded k-means assignment
solved as a transport problem, and recursive bisection along the
principal axis. Worst over the experts, at 20 on Tom East: a core member
6.22 median radii from its centre and an isolated one 21.8 core spacings
from its nearest fellow, against 2.64 / 6.51 trading, 2.17 / 2.86
transport and 3.58 / 18.0 bisection; borrowed points’ median gap 2.13
spacings against about one for all three. The held-out score of a rock
model (three seeds, which barely differ since a layout is one per
algorithm) did not decide it: transport best at 5 experts (AUC 0.896
against 0.875), worst at 20 (0.799 against 0.825), trading and bisection
between. The figures showed transport’s cores tiling the field and
following the holes, and the user chose on geometry. experts is now
k-means with the transport assignment (balance=0.1), each point’s 8
nearest centres as its candidates – 6000 points in 64 experts 45 s to
10 s, the same assignment wherever that is feasible, the full problem
where not. Borrowing is spread over the neighbours (the user’s rule):
up to ceil(overlap x own) points, one from each neighbour a round, that
neighbour’s nearest to the core, nearest neighbour first; a neighbour is a
cluster some member faces as its nearest point outside, or that faces it.
Borrowing by nearness alone left most touching experts sharing nothing –
on the drillhole case at 20 experts, 28 of 44 touching pairs at overlap
0.1, 17 at 0.2, 6 still at 0.5 – since every point came from the one or
two nearest neighbours. Spread, 12 of 43 facing pairs at 0.1, where an
expert of 25 points borrows 3 against up to six neighbours, and none at
0.2 or more: the rule reaches every neighbour once an expert is large
enough for its overlap. Tests in test_experts.py: the band, even
borrowing, every facing neighbour shared where the budget covers them,
and compact cores along the drillhole case, which the old algorithm
fails. On the way, prediction by expert was found drawing other normals
for a GP of two outputs wherever experts differ in size – a slot drew one
array at the padded size where simulate draws each expert’s at its own,
and a draw fills its array in order – hidden while experts were equal;
the slots now gather each expert’s own draw.
Mesh sets — done (2026-09-11, 0.6.10). geoml/data/meshsets.py;
design record docs/mesh-sets.md, measurements
docs/benchmarks/mesh_sets.py. The gate the item set, on the Assen block
model (908 237 blocks, 25 realizations, eight workers): the prediction’s
four FeO_total shells cross each other by 1.8e-15 m³ as contoured and no
realization’s by more than 7e-15; simplified to 1 m and nested again, the
nesting took nothing back – so repair stays off by default. A
realization costs 59 s in one process at four cut-offs, 27 s each on eight
workers; the Fe set took 800 s whole, the six rocks 1024 s, the largest
worker 6.8 and 9.1 GB. What the set measured: the prediction’s shell at
0.85 holds 1.42 Mm³ where the realizations hold 1.65 / 2.18 / 3.00
(P10/P50/P90), smaller than 23 of the 25, while at 0.6, below the median
grade, it is larger than three in four – the smoothing a mean field does
at either tail – and every realization holds more BIF than the prediction,
by a third at the median. The rock set’s prediction bodies overlap by 0.008%
of the model and leave 0.1% uncovered, mostly the box’s rounded edges; a
realization’s overlap by 1.1% and leave 0.8% (an open item in §4). Found
and settled on the way: a band between two shells closed against the same
face touched itself, which geoML’s welding read as a Mesh3D – fixed in
the booleans (_separated); and a contour meeting its own cap edge-on,
which a set now retries a hair off its level, recorded as nudge (7 of
the 104 Fe meshes needed it, one at 1e-4 of the span, and 8 of the 156 rock
bodies, of which one did not close even so). The item as filed asked for tonnage from the
realizations’ own bands too: that is realization_table, opt-in, since it
asks the blocks near every band of every realization about their
sub-blocks.
Solid booleans through manifold3d — done (2026-09-10, 0.6.10).
docs/benchmarks/manifold_booleans.py and manifold_features.py, on the
six Assen rock shells (325k–681k triangles each, at mine coordinates). The
bodies go to manifold3d welded, in double precision and in the pair’s
local frame, and a status other than NoError is raised rather than
returned empty: the 15 pair intersections, a union and two differences
took 15 s against the signed-distance grid’s 141 s, with no crash in 54
isolated calls, every answer a consistent Solid3D, and the block shells
that crashed VTK’s filter pass in-process. The rocks meet along films
thinner than the grid’s 1.1–1.75 m step, and its 14 non-empty
intersections read 5 to 5231 times the exact volume, or next to nothing
(0.0002 m³ against 1.70); the union and the differences agreed to
0.01–0.44%. Not the pyvista-manifold accessor the item asked for:
it casts to float32, and gave inconsistent meshes in 4 of 18 jobs in the
local frame and 15 of 18 at mine coordinates, so its pyvista 0.48 floor is
not needed either. Nothing else of Manifold’s replaces geoML’s own:
Manifold.simplify is 20–60 times faster but strayed past its tolerance
on real shells (at 0.5 m, 0.88–0.97 m out and 1.1–2.7 m back; at 2 m up to
5.4 m out and 14.6 m back, and an inconsistent mesh on Hematite);
decompose finds split’s pieces but costs as much once they are handed
back, and takes solids only; level_set wants a function rather than
samples and ran 5–8 times slower than flying edges, for half the volume
error at twice the triangles. Measuring the films exposed signed_volume
summing about the world origin, fixed the same day (a millimetre film 23%
off at a northing of 7,000 km). What the comparison found open in
simplify is an item of its own in §4.
A vector variable’s components never received their latent moments —
fixed (2026-09-10, 0.6.10). VectorVariable.update now forwards mean
and variance when the model says the latent columns are the components’
own (elementwise=, from the likelihood’s warping); a rotation or a
projection leaves them NaN rather than mislabel a mixed column, and a
composition’s parts stay None by design. test_plots.py, both ways.
Sibling GP nodes drew the same whitened normals — fixed (2026-09-10,
0.6.10). Measured 0.995 cross-leaf correlation of the latent realizations
on Jura, 0.000 with the seed offset; the node’s name, numbered within the
tree and replayed by a save, is now folded into the seed at the one draw
function; under the Sobol rule it also permutes the realization order per
node, since two linear-matrix scrambles of one sequence stayed paired at
0.37; under 0.2 after, both rules, replay kept, test_leaves.py. The experts of one node still share their normals
across the overlap, deliberately.
Jura’s metals: the likelihood is the lever, not the link (2026-09-10).
The parametric links were the first recommendation for copper’s fan at
three times the data under the epsilon-insensitive likelihood; measured,
none of them fixes it (Box-Cox 3.2, Yeo-Johnson 2.2, robust ZScore 2.7),
and trained to 1200 iterations the excess spreads to six metals
(1.3–2.6×). A Laplace tail through a log-like link has a second moment
with a pole at 2·sigma_log/c_rate = 1; copper sat at 0.956. The
multivariate Gaussian on the same chain puts every metal within 0.97–1.41,
goodness 0.86 → 0.96, rmse equal, CRPS within 1%, confirmed on the true
held-out set — chosen against a bound 200 nats lower. The two-scale
mixture’s tight fan is under-dispersion (goodness below the baseline at
64 draws); not recommended. Three skeptics; record in
docs/cross-validation.md, docs/benchmarks/jura_noise_footing.py.
Measurement samples on rotated nodes — done (2026-09-09, 0.6.10).
The strata’s midpoints carried 35-47% of EpsilonInsensitive’s noise
variance on Jura’s Pb, Cu and Cr at the default 32 nodes and put one
noise value on every location of a column. Rotated modulo one by a
uniform per location, component and realization from the model’s seed:
the samples’ warped-space variance at 0.985-1.014 of the law’s on every
metal and on Walker, their variogram on the lifted ground fan (Zn
0.97-1.00 by lag, Walker 0.999-1.001) where it sat 1.3-2.8x below,
locations decorrelated, batch invariance kept. test_measurement_samples.py.
The variogram figure’s noise lift is exact; the gap it shows is the
model’s (2026-09-09). The fan is the ground realizations (noise
integrated out) raised by the pair-averaged noise_variance; against an
honest Monte Carlo measurement fan (noise drawn independently per location
from the fitted likelihood, back-transformed) the lifted fan sits at
0.993-1.002 on Walker and 0.95-1.03 on every Jura metal under Gaussian and
epsilon-insensitive likelihoods with a spline warping, within Monte Carlo
noise at every lag, three measurers each audited and re-run by a skeptic
at another seed. Nor is in-sample conditioning the gap: the same fans on
the out-of-fold container move by 0.02-0.05. What remains is the model’s
own total variance (Walker: noise 0.88 of the sill plus ground 0.48, a
fresh measurement scattered 1.36 times the data). Record in
docs/cross-validation.md.
The leaf-only refit leaks, and more than the warm start (2026-09-09).
refit="leaves" — re-initialize and refit the variational state of the
terminal GP nodes only, the interior kept as all the data taught it, the
author’s proposal that the interior encodes the spatial pattern and the
conditioning to data happens at the leaves. On chapter 16’s Jura tree
(the displacement field the interior), five spatial folds, three seeds:
rmse/sd 0.80 against the scratch gold’s 0.99 and the warm start’s 0.92, rock
out-of-fold accuracy 0.91 against 0.83 (in-sample 0.97). The same refit
from an interior trained on the fold’s rows alone scores 1.00 — the gold.
The field remembers where the held-out holes put the contacts, and
freezing it keeps that memory intact where warm training lets it drift.
Kept as a diagnostic, not a score; E2 in docs/cross-validation.md.
Independent trees work, and the bookkeeping join is gone (2026-09-05).
Two leaves on roots of their own — a BasicGP on 40 k-means inducing
points for the rock type, another on 120 for the seven metals, no join
anywhere — against the same two leaves on one shared root of 120, on Jura
held out, three seeds, 600 iterations: the metals identical (rmse/sd 0.949
against 0.950, crps/sd 0.477 against 0.478), the rock five points of
accuracy worse (0.630 against 0.680) on its own coarser tree, the bound
lower by the same token (−6167 against −6122), and the two-tree model a
third faster to train (33 s against 44 s). So a tree costs what its own
inducing set costs and affects only the variable it carries, which is the
property the drillhole-tree-plus-geophysics-tree use needs. Two facts from
the way there: a terminal Stack already joined two trees (it propagates
no inducing points, so nothing can sit on it), while a terminal
Concatenate over two roots raises, its root being None; and the old
single-node path survives the refactor to the last bit — twelve iterations
of the manual’s Jura model, same bound before and after.
A full-covariance variational family is not the answer to interval
tightening — a richer family makes it worse (2026-09-02). A FullGP with
a full whitened Cholesky per output reached a higher bound than BasicGP at
every size of the Jura ladder, and its held-out intervals came out tighter
still, coverage lower (0.87 → 0.80 at 625 in four experts), rmse worse once
divided into experts. It was also 4× faster to train at 1225 points. So a
better family is cheap and buys nothing here: the saturation is the bound’s
own optimum on this data. Prototype deleted.
The intervals keep tightening past 625 inducing points, but toward an asymptote — and on Walker it is not a defect (2026-08-19). On Jura, predicted-sd/data-sd falls per doubling by 0.045, 0.044, 0.033, 0.020, 0.010, flattening toward 0.75 with coverage settling near 0.86. Over the extended ladder Jura’s accuracy is no longer flat — rmse/sd 0.937 at 169 inducing points to 0.967 at 2401, monotone and far beyond the seed spread — so past roughly the data count this data set shows mild genuine overfitting. None of it appears on Walker, where rmse/sd improves across the whole ladder and coverage never crosses nominal.
Gradient-free training, against three conditions (2026-09-03). The memory saving holds, for the ranges only: off the tape they cut peak GPU memory 152 → 40 MiB and the step 302 → 202 ms on a 16-expert model. Better optima do not: the bound’s profile along the range is unimodal on Jura and a flat plateau on Walker, with Adam sitting on the optimum in both — no hidden basin for a search to find. The better held-out optima sit off the bound’s peak on both, which a bound-driven search cannot see. Ruled out along the way: evolution strategies and SPSA over everything, whose gradient-estimate variance grows with a dimension that here is thousands to millions.
The Isotropic range floor does not bind (2026-09-03). The pinning that
raised the question was an artefact of a profile that froze the kernel’s own
ranges. With them free the range never sits on the floor, and every
held-out score is equal or a hair better at the current floor than at any
lower one.
Variogram-based kernel initialization — rejected (2026-08-19, the author’s call): “too much work for something this library exists to replace.” The ELBO is the fitting surface; the experimental variogram stays a check, never a fitting target, not even as a start.
MAF, min/max autocorrelation factors — shelved (2026-09-03) “until I’m proven wrong”. A warping acts on the outputs alone and never sees the coordinates, while MAF’s lag-h side presumes a stationary cross-covariance; the latent network need not be stationary and in this package’s spirit is not expected to be, so a spatial assumption baked into an output transform is the wrong layer for it. Reopen only with a measurement showing an MAF start beating the existing ones on a non-stationary case.
Flow warpings are correct but not superior (2026-09-01). Both the continuous normalizing flow and the tensor-product variant are right on every test; the CP flow is the best Jura arm on rmse, neither wins Macpass against the marginal chain, and they cost 4–10×. The standing recommendation stays one initialized marginal transform.
OMF as the interchange route — dropped (2026-08-12). The package on PyPI is from 2019; v2, the only spec with sub-blocked models, has been at an alpha since 2021. Forking it or launching a new format was raised and rejected by analysis: a format’s value is its readers, a fork moves no vendor’s roadmap, and the maintenance tax lands on the hours the modelling needs. CSV is the only universal route for sub-blocked models.
Refused without measuring
Each of these is refused on a structural mismatch, which is a weaker kind of no than a number. Revisit if the premise changes.
MPS / training images. No likelihood, no parameters, conditioning by pattern search. Importing it means carrying a second, incompatible inference story beside the ELBO. This package’s answer to curvilinear structure is a deep GP or a flow, inside the objective it already has.
Graph-based LVA distances. Non-differentiable, and the shortest-path metric is not Euclidean, so the induced covariance is not positive definite in general. Superseded by the
AnisotropyFieldroute above.Hermite polynomial anamorphosis. The spline warping is better conditioned and exactly invertible; Hermite adds tail oscillation and a truncation order to tune, for nothing needed here.
IRF-k / generalized covariances. The formalism exists to avoid estimating a trend one cannot afford to estimate, which is not the situation here; a mean function or a
Linearnode is more direct.QKNA. A diagnostic for a kriging neighbourhood search that does not exist here. The one piece worth stealing, the slope of regression, is what
prediction_scatteralready draws.
Already here under another name
Written down because the survey kept rediscovering them, and because the
mapping is the manual’s vocabulary bridge. Kriging and cokriging → the
posterior, LinearCombination, MultiStructureGP. Normal score →
ZScore/Spline. Sequential Gaussian simulation, turning bands, FFT-MA →
posterior simulation. Indicator kriging → CategoricalGaussianIndicator.
Logratio → CenteredLogRatio/ScaledSimplex. Capping and top-cuts → the
Mixture likelihood, which names the bad sample instead of truncating
everyone. Spatial bootstrap → the ensemble, directly. Cell declustering →
math.geometry.declustering_weights. Uniform conditioning and the
discrete Gaussian model need a specific note: the sub-block simulation
route is strictly stronger, needing no permanence-of-distribution
hypothesis, so the change-of-support likelihood above is the remaining
piece and DGM is not a reason to import anything.