Skip to content

Commit 36fae6d

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. 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 36fae6d

38 files changed

Lines changed: 4102 additions & 2227 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)