Skip to content

Commit 641689b

Browse files
Three-class Material Source strength GP
The BOxCrete dataset spans three physically distinct material sets, not two: Set 1 mortars (Amrize 1L / Class C fly ash, no coarse aggregate), Set 2 concretes (Heidelberg 1L / Class C), and Set 3 concretes (Amrize 1L / Class F). The deployed model previously treated Material Source as a continuous ARD coordinate, which forces a single lengthscale to span both the Set 1 <-> Set 2 and the Set 1+2 <-> Set 3 gaps -- ill-defined when the axis is really an unordered categorical. Model - Material Source becomes a 3-level categorical. The source-aware branch is now ScaleKernel(CategoricalKernel(source) * Matern(rest)), a Bonilla et al. (2008) intrinsic-coregionalization factorisation: one shared Matern shape plus a single learned cross-class correlation rho = exp(-1/ell). Full kernel is blind_matern + source_branch + additive_rbf_time, wrapped in TimeGatedKernel (gate tau = 0.10) so the prior variance at t=0 is exactly zero and f(x, 0) = 0 holds structurally, without day-zero anchor pseudo-observations. - Training minimises a combined block-LOO + marginal-likelihood objective (boxcrete/block_loo.py). Blocks are unique compositions, so the loss measures held-out-composition curve prediction -- what the explorer and the BO loop actually consume. The MLL term contributes the -0.5 log|K| complexity penalty that block-LOO structurally lacks, keeping curves smooth between training ages. - ComposedLengthscalePrior lets the within-group shrinkage prior (Cement/FA/Slag tied, Fine/Coarse Aggregate tied) compose with the per-dim lognormal baseline. Data and naming - boxcrete/mix_naming.py decodes the 3 classes to canonical mix names from composition alone, backed by _mix_naming_table.csv: M1..M69 (69 Set 1 mortars), C1..C27 (27 Set 2 concretes), C28..C80 (53 Set 3 concretes), 149 mixes total. - data/boxcrete_data.csv carries the Set 3 rows and the 3-class Material Source encoding. Clay-using mortars M75/76/77 are dropped. - docs/materials_background.md documents the chemistry behind each class. Explorer - docs/ui.mjs exposes Material Source as a 3-way selector; gp.mjs and gp_v2_fast.mjs implement the categorical branch for JS-Python parity. - Regenerated docs/model/* artifacts (strength_model.pt, test_vectors, compositions, mix_analyses) via experiments/regenerate_all_artifacts.sh. - docs/model/mix_analyses.json is re-authored for the 3-class catalog. The renumbering left the previous narratives keyed to a catalog order that no longer exists (only 31 of 144 entries still index-aligned, and 70 changed Material Source label), so they could not be carried over. Each of the 149 entries now names its canonical mix, states its verified mix-design figures, and interprets its behaviour against sibling mixes in the same designed series. The entries deliberately do not restate measured strength points, embodied carbon, or Pareto status: the explorer already overlays observations on the strength curve, gives GWP/cost/W-B their own readouts, and shows Pareto membership as a pill, so repeating them only padded every entry. regenerate_all_artifacts.sh no longer invokes docs/generate_mix_analyses.py, whose templated fallback output would overwrite the authored prose -- this restores the contract that script's own docstring already specified. Explorer performance - predictStrengthCurveV2 gains a `meanOnly` option that skips the Cholesky/dtrsm variance solve and the [n x nTimes] K allocation. The mean is already accumulated during the kernel build, so output is bit-identical; only discarded work is removed. Routed the two callers that render no uncertainty band -- the dashed preview curve and the scatter position marker -- through it. Preview at 48 points: 5.94 ms -> 2.86 ms (2.1x). - drawStrengthCurve's `isInteracting` now also covers preview activity. Previously, while the preview curve animated at 60 fps the main curve stayed on the 64-point grid because neither a slider nor a composition animation was in flight, making preview-settling the most expensive frame in the app. Behaviour at rest is unchanged (showPreview is false, so the expression evaluates exactly as before and the curve is still sampled at 64 points). - Measured per frame, n_train=670, WASM active, desktop arm64: scatter hover / preview settling 14.08 ms -> 6.91 ms (51% less); animated transition 10.07 ms -> 6.91 ms (31% less); sustained slider drag is unaffected (4.13 -> 4.04 ms) because the preview does not run during a drag. Tests - test_composed_prior.py, test_mix_naming.py cover the new modules. - test_kernel_layout.py and test_lengthscale_identifiability.py updated for the categorical branch. - Full suite: 270 passing, 100% line coverage on boxcrete/.
1 parent 197add7 commit 641689b

38 files changed

Lines changed: 4148 additions & 2246 deletions

boxcrete/_mix_naming_table.csv

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
legacy_int_name,canonical_name,material_class,source_evidence
2+
M1,M1,0,boxcrete_all direct (v6)
3+
M2,M2,0,boxcrete_all direct (v6)
4+
M3,M3,0,boxcrete_all direct (v6)
5+
M4,M4,0,boxcrete_all direct (v6)
6+
M5,M5,0,boxcrete_all direct (v6)
7+
M6,M6,0,boxcrete_all direct (v6)
8+
M7,M7,0,boxcrete_all direct (v6)
9+
M8,M8,0,boxcrete_all direct (v6)
10+
M9,M9,0,boxcrete_all direct (v6)
11+
M10,M10,0,boxcrete_all direct (v6)
12+
M11,M11,0,boxcrete_all direct (v6)
13+
M12,M12,0,boxcrete_all direct (v6)
14+
M13,M13,0,boxcrete_all direct (v6)
15+
M14,M14,0,boxcrete_all direct (v6)
16+
M15,M15,0,boxcrete_all direct (v6)
17+
M16,M16,0,boxcrete_all direct (v6)
18+
M17,M17,0,boxcrete_all direct (v6)
19+
M18,M18,0,boxcrete_all direct (v6)
20+
M19,M19,0,boxcrete_all direct (v6)
21+
M20,M20,0,boxcrete_all direct (v6)
22+
M21,M21,0,boxcrete_all direct (v6)
23+
M22,M22,0,boxcrete_all direct (v6)
24+
M23,M23,0,boxcrete_all direct (v6)
25+
M24,M24,0,boxcrete_all direct (v6)
26+
M25,M25,0,boxcrete_all direct (v6)
27+
M26,M26,0,boxcrete_all direct (v6)
28+
M27,M27,0,boxcrete_all direct (v6)
29+
M28,M28,0,boxcrete_all direct (v6)
30+
M29,M29,0,boxcrete_all direct (v6)
31+
M30,M30,0,boxcrete_all direct (v6)
32+
M31,M31,0,boxcrete_all direct (v6)
33+
M32,M32,0,boxcrete_all direct (v6)
34+
M33,M33,0,boxcrete_all direct (v6)
35+
M34,M34,0,boxcrete_all direct (v6)
36+
M35,M35,0,boxcrete_all direct (v6)
37+
M36,M36,0,boxcrete_all direct (v6)
38+
M37,M37,0,boxcrete_all direct (v6)
39+
M38,M38,0,boxcrete_all direct (v6)
40+
M39,M39,0,boxcrete_all direct (v6)
41+
M40,M40,0,boxcrete_all direct (v6)
42+
M41,M41,0,boxcrete_all direct (v6)
43+
M42,M42,0,boxcrete_all direct (v6)
44+
M43,M43,0,boxcrete_all direct (v6)
45+
M44,M44,0,boxcrete_all direct (v6)
46+
M45,M45,0,boxcrete_all direct (v6)
47+
M46,M46,0,boxcrete_all direct (v6)
48+
M47,M47,0,boxcrete_all direct (v6)
49+
M48,M48,0,boxcrete_all direct (v6)
50+
M49,M49,0,boxcrete_all direct (v6)
51+
M50,M50,0,boxcrete_all direct (v6)
52+
M51,M51,0,boxcrete_all direct (v6)
53+
M52,M52,0,boxcrete_all direct (v6)
54+
M53,M53,0,boxcrete_all direct (v6)
55+
M54,M54,0,boxcrete_all direct (v6)
56+
M55,M55,0,boxcrete_all direct (v6)
57+
M56,M56,0,boxcrete_all direct (v6)
58+
M57,M57,0,boxcrete_all direct (v6)
59+
M58,M58,0,boxcrete_all direct (v6)
60+
M59,M59,0,boxcrete_all direct (v6)
61+
M60,M60,0,boxcrete_all direct (v6)
62+
M61,M61,0,boxcrete_all direct (v6)
63+
M62,M62,0,boxcrete_all direct (v6)
64+
M63,M63,0,boxcrete_all direct (v6)
65+
M64,M64,0,boxcrete_all direct (v6)
66+
M65,M65,0,boxcrete_all direct (v6)
67+
M66,M66,0,boxcrete_all direct (v6)
68+
M67,M67,0,boxcrete_all direct (v6)
69+
M68,M68,0,boxcrete_all direct (v6)
70+
M69,M69,0,boxcrete_all direct (v6)
71+
C1,C1,1,boxcrete_all direct (v6)
72+
C2,C2,1,boxcrete_all direct (v6)
73+
C3,C3,1,boxcrete_all direct (v6)
74+
C4,C4,1,boxcrete_all direct (v6)
75+
C5,C5,1,boxcrete_all direct (v6)
76+
C6,C6,1,boxcrete_all direct (v6)
77+
C7,C7,1,boxcrete_all direct (v6)
78+
C8,C8,1,boxcrete_all direct (v6)
79+
C9,C9,1,boxcrete_all direct (v6)
80+
C10,C10,1,boxcrete_all direct (v6)
81+
C11,C11,1,boxcrete_all direct (v6)
82+
C12,C12,1,boxcrete_all direct (v6)
83+
C13,C13,1,boxcrete_all direct (v6)
84+
C14,C14,1,boxcrete_all direct (v6)
85+
C15,C15,1,boxcrete_all direct (v6)
86+
C16,C16,1,boxcrete_all direct (v6)
87+
C17,C17,1,boxcrete_all direct (v6)
88+
C18,C18,1,boxcrete_all direct (v6)
89+
C19,C19,1,boxcrete_all direct (v6)
90+
C20,C20,1,boxcrete_all direct (v6)
91+
C21,C21,1,boxcrete_all direct (v6)
92+
C22,C22,1,boxcrete_all direct (v6)
93+
C23,C23,1,boxcrete_all direct (v6)
94+
C24,C24,1,boxcrete_all direct (v6)
95+
C25,C25,1,boxcrete_all direct (v6)
96+
C26,C26,1,boxcrete_all direct (v6)
97+
C27,C27,1,boxcrete_all direct (v6)
98+
C28,C28,2,boxcrete_all direct (v6)
99+
C29,C29,2,boxcrete_all direct (v6)
100+
C30,C30,2,boxcrete_all direct (v6)
101+
C31,C31,2,boxcrete_all direct (v6)
102+
C32,C32,2,boxcrete_all direct (v6)
103+
C33,C33,2,boxcrete_all direct (v6)
104+
C34,C34,2,boxcrete_all direct (v6)
105+
C35,C35,2,boxcrete_all direct (v6)
106+
C36,C36,2,boxcrete_all direct (v6)
107+
C37,C37,2,boxcrete_all direct (v6)
108+
C38,C38,2,boxcrete_all direct (v6)
109+
C39,C39,2,boxcrete_all direct (v6)
110+
C40,C40,2,boxcrete_all direct (v6)
111+
C41,C41,2,boxcrete_all direct (v6)
112+
C42,C42,2,boxcrete_all direct (v6)
113+
C43,C43,2,boxcrete_all direct (v6)
114+
C44,C44,2,boxcrete_all direct (v6)
115+
C45,C45,2,boxcrete_all direct (v6)
116+
C46,C46,2,boxcrete_all direct (v6)
117+
C47,C47,2,boxcrete_all direct (v6)
118+
C48,C48,2,boxcrete_all direct (v6)
119+
C49,C49,2,boxcrete_all direct (v6)
120+
C50,C50,2,boxcrete_all direct (v6)
121+
C51,C51,2,boxcrete_all direct (v6)
122+
C52,C52,2,boxcrete_all direct (v6)
123+
C53,C53,2,boxcrete_all direct (v6)
124+
C54,C54,2,boxcrete_all direct (v6)
125+
C55,C55,2,boxcrete_all direct (v6)
126+
C56,C56,2,boxcrete_all direct (v6)
127+
C57,C57,2,boxcrete_all direct (v6)
128+
C58,C58,2,boxcrete_all direct (v6)
129+
C59,C59,2,boxcrete_all direct (v6)
130+
C60,C60,2,boxcrete_all direct (v6)
131+
C61,C61,2,boxcrete_all direct (v6)
132+
C62,C62,2,boxcrete_all direct (v6)
133+
C63,C63,2,boxcrete_all direct (v6)
134+
C64,C64,2,boxcrete_all direct (v6)
135+
C65,C65,2,boxcrete_all direct (v6)
136+
C66,C66,2,boxcrete_all direct (v6)
137+
C67,C67,2,boxcrete_all direct (v6)
138+
C68,C68,2,boxcrete_all direct (v6)
139+
C69,C69,2,boxcrete_all direct (v6)
140+
C70,C70,2,boxcrete_all direct (v6)
141+
C71,C71,2,boxcrete_all direct (v6)
142+
C72,C72,2,boxcrete_all direct (v6)
143+
C73,C73,2,boxcrete_all direct (v6)
144+
C74,C74,2,boxcrete_all direct (v6)
145+
C75,C75,2,boxcrete_all direct (v6)
146+
C76,C76,2,boxcrete_all direct (v6)
147+
C77,C77,2,boxcrete_all direct (v6)
148+
C78,C78,2,boxcrete_all direct (v6)
149+
C79,C79,2,boxcrete_all direct (v6)
150+
C80,C80,2,boxcrete_all direct (v6)

boxcrete/block_loo.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
"""Block-leave-one-out (block-LOO) training objective for the strength GP.
7+
8+
The deployed strength model is trained by minimising the **block-LOO
9+
predictive negative-log-likelihood** rather than the marginal likelihood
10+
(MLL). Each block is one unique composition (rows sharing the leading
11+
composition dims), so block-LOO measures how well the model predicts a
12+
*held-out composition's* strength curve — the quantity the explorer and
13+
Bayesian-optimisation loop actually rely on. Training directly on this
14+
objective (from initialisation, no MLL warm-up) gave the best and most
15+
stable held-out RMSE in the design-exploration benchmark.
16+
17+
Closed form (Sundararajan-Keerthi block-inverse identity): with
18+
``K = K_kernel + sigma^2 I``, ``alpha = K^{-1}(y - mu_prior)`` and, for a
19+
block ``B``,
20+
21+
residual_B = (K^{-1}_BB)^{-1} @ alpha_B
22+
var_B = diag((K^{-1}_BB)^{-1}) # includes aleatoric noise
23+
24+
so the per-row loss is the Gaussian NLL ``0.5*(log 2pi + log var + r^2/var)``.
25+
Day-zero anchor pseudo-rows (indices ``[n_real:]``) join their
26+
composition's block — holding a composition out also holds out its anchor
27+
(no leak) — but are not scored.
28+
29+
The deployed model is trained on a **single combined objective** that
30+
blends this block-LOO loss with a fraction of the marginal likelihood
31+
(see :func:`train_block_loo`): the MLL term supplies the ``-0.5 log|K|``
32+
complexity penalty that block-LOO structurally lacks, keeping the strength
33+
curves smooth/monotone between the training ages at negligible block-LOO
34+
cost.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import math
40+
41+
import torch
42+
from botorch.models import SingleTaskGP
43+
from gpytorch.utils.cholesky import psd_safe_cholesky
44+
45+
__all__ = ["block_loo_loss", "train_block_loo"]
46+
47+
48+
def _model_prior_log_prob(model: SingleTaskGP) -> torch.Tensor:
49+
"""Sum of ``log p(theta)`` over every prior registered on the model.
50+
51+
Including this term makes block-LOO training maximise the posterior
52+
``log p(theta | y)`` rather than the bare predictive likelihood, which
53+
keeps the lengthscale-estimation problem well-posed (the within-group
54+
shrinkage prior stays active). ``named_priors()`` yields
55+
``(name, parent_module, prior, closure, setting_closure)``; the closure
56+
takes the parent module (the kernel the prior was registered on).
57+
"""
58+
total = torch.zeros((), dtype=torch.double)
59+
for _name, parent_module, prior, closure, _setting in model.named_priors():
60+
contrib = prior.log_prob(closure(parent_module)).sum()
61+
total = total.to(contrib) + contrib
62+
return total
63+
64+
65+
def block_loo_loss(
66+
model: SingleTaskGP,
67+
n_real: int,
68+
*,
69+
n_composition_dims: int = 9,
70+
) -> torch.Tensor:
71+
"""Differentiable mean block-LOO negative-log-likelihood per real row.
72+
73+
Args:
74+
model: a ``SingleTaskGP`` whose hyperparameters require grad.
75+
n_real: number of real rows; anchor pseudo-rows live at
76+
``[n_real:]`` and are held out with their block but not scored.
77+
n_composition_dims: number of leading input dims that define a
78+
block (default 9 = composition + temperature).
79+
80+
Returns a scalar tensor (minimise it to improve held-out calibration).
81+
"""
82+
train_X = model.train_inputs[0]
83+
train_Y = model.train_targets
84+
prior = model(train_X)
85+
noisy = model.likelihood(prior, train_X)
86+
K = noisy.lazy_covariance_matrix.to_dense()
87+
88+
n = K.shape[-1]
89+
L = psd_safe_cholesky(K)
90+
Y_t = train_Y.unsqueeze(-1) if train_Y.dim() == 1 else train_Y
91+
residuals = Y_t - prior.mean.unsqueeze(-1)
92+
alpha = torch.cholesky_solve(residuals, L).squeeze(-1)
93+
K_inv = torch.cholesky_solve(torch.eye(n, dtype=K.dtype, device=K.device), L)
94+
95+
fingerprints = train_X[..., :n_composition_dims]
96+
unique_fp, inverse = torch.unique(fingerprints, dim=0, return_inverse=True)
97+
98+
log_two_pi = math.log(2.0 * math.pi)
99+
total_nll = torch.zeros((), dtype=K.dtype, device=K.device)
100+
n_scored = 0
101+
for g in range(unique_fp.shape[0]):
102+
idx = (inverse == g).nonzero(as_tuple=True)[0]
103+
K_inv_BB_inv = torch.linalg.inv(K_inv[idx][:, idx])
104+
block_residual = K_inv_BB_inv @ alpha[idx]
105+
block_var = torch.diagonal(K_inv_BB_inv).clamp_min(1e-12)
106+
block_nll = 0.5 * (
107+
log_two_pi + torch.log(block_var) + (block_residual**2) / block_var
108+
)
109+
real_mask = idx < n_real
110+
total_nll = total_nll + block_nll[real_mask].sum()
111+
n_scored += int(real_mask.sum())
112+
113+
nll_per_row = total_nll / n_scored
114+
return nll_per_row - _model_prior_log_prob(model) / n_scored
115+
116+
117+
def train_block_loo(
118+
model: SingleTaskGP,
119+
n_real: int,
120+
*,
121+
mll_weight: float = 0.5,
122+
max_iter: int = 150,
123+
lr: float = 0.1,
124+
) -> float:
125+
"""Train the model's hyperparameters from their current initialisation
126+
on a single combined objective, minimised with LBFGS::
127+
128+
L(theta) = (1 - mll_weight) * block_loo_NLL(theta)
129+
+ mll_weight * MLL_NLL(theta)
130+
131+
The block-LOO term optimises held-out predictive calibration (the
132+
deployment metric), but is scored only at the training ages, so on its
133+
own it leaves the predictive mean free to *oscillate between* those ages
134+
(non-monotone strength curves). The marginal-likelihood (MLL) term adds
135+
back the ``-0.5 log|K|`` Occam/complexity penalty that block-LOO
136+
structurally lacks; a modest ``mll_weight`` restores smooth, monotone
137+
curves at negligible block-LOO cost. Trained from a freshly-constructed
138+
model (no MLL warm-up) this is a genuine single-objective fit, not a
139+
two-stage MLL-then-refine. Returns the final (pure) block-LOO loss.
140+
"""
141+
from gpytorch.mlls import ExactMarginalLogLikelihood
142+
143+
model.train()
144+
mll = ExactMarginalLogLikelihood(model.likelihood, model)
145+
params = [p for p in model.parameters() if p.requires_grad]
146+
optimizer = torch.optim.LBFGS(
147+
params,
148+
lr=lr,
149+
max_iter=max_iter,
150+
tolerance_grad=1e-6,
151+
tolerance_change=1e-9,
152+
line_search_fn="strong_wolfe",
153+
)
154+
155+
def closure():
156+
optimizer.zero_grad()
157+
output = model(*model.train_inputs)
158+
mll_nll = -mll(output, model.train_targets)
159+
loss = (1.0 - mll_weight) * block_loo_loss(model, n_real) + mll_weight * mll_nll
160+
loss.backward()
161+
return loss
162+
163+
optimizer.step(closure)
164+
with torch.no_grad():
165+
return block_loo_loss(model, n_real).item()

boxcrete/features.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
Public extension surface for users / variant authors:
99
1010
* :data:`F5_ALLLOG_FEATURES` — the deployed V2 strength GP's 7-feature set.
11-
* :data:`GATE_TAU` — the production time-gate timescale (0.05).
11+
* :data:`GATE_TAU` — the production time-gate timescale (0.10).
1212
* :data:`IDX` — column-name → integer-index map for the 10 raw input
1313
dims (Cement, Fly Ash, Slag, Water, HRWR, Fine, Coarse, Source,
1414
Temperature, Time).
@@ -38,13 +38,25 @@
3838
from boxcrete.utils import DEFAULT_X_COLUMNS
3939

4040
# Time gate constant: h(t) = 1 - exp(-t / GATE_TAU).
41-
# tau=0.05 (post-input-transform time units) was found to be optimal in
42-
# the τ-sweep; see §4.5 of the benchmark.
43-
GATE_TAU = 0.05
44-
45-
46-
# Default feature set for the V2 strength GP. See STRENGTH_GP_BENCHMARK.md
47-
# §4.3 for the per-feature ablation showing all 5 log-transforms help.
41+
# tau=0.10 (post-input-transform time units). Chosen from a τ-sweep over
42+
# {0.05, 0.10, 0.15, 0.20} as the best point that keeps EXACT JS↔Python
43+
# parity. Raising τ reduces the early-time (t<1 day) negative overshoot in
44+
# the data-free window before the first (t=1 day) measurement — the min
45+
# predicted strength there improves from −1957 psi (τ=0.05) to −1302 psi
46+
# (τ=0.10), −975 (0.15), −809 (0.2) — but it also grows the fitted
47+
# outputscale and worsens the training-kernel conditioning, which makes the
48+
# in-browser JS posterior drift from the Python posterior. That drift stays
49+
# within the tight JS-port test (test/test_js_gp.mjs, rtol=1e-4/atol=1e-2)
50+
# only for τ ≤ 0.10; τ ≥ 0.15 breaks it (mean/variance drift up to ~0.5 psi
51+
# at 0.15, ~3.5 psi at 0.2). Held-out block-LOO loss is flat across the
52+
# sweep (−1.6509 at 0.10 vs −1.6529 best at 0.15, within noise), so τ=0.10
53+
# captures ~1/3 of the achievable overshoot reduction at zero parity cost.
54+
# Revisit (with a parity fix) if more early-window damping is needed.
55+
GATE_TAU = 0.1
56+
57+
58+
# Default feature set for the V2 strength GP. The per-feature ablation
59+
# found that all 5 log-transforms help.
4860
F5_ALLLOG_FEATURES = (
4961
"wb_ratio",
5062
"scm_frac",

0 commit comments

Comments
 (0)