Skip to content

Commit f54fb2e

Browse files
Add within-group lengthscale shrinkage prior for the strength GP
In the strength GP fit on the public dataset, the Matern ARD lengthscales for Fly Ash and Coarse Aggregates rail to the upper cap (~1000): both features are under-sampled relative to Cement and Fine Aggregate, so the marginal likelihood is essentially flat in their lengthscale direction and the optimiser drifts to the constraint boundary. This makes the strength surface look insensitive to those two ingredients in the explorer UI, even though concrete chemistry clearly says it isn't. This PR introduces `WithinGroupShrinkagePrior` — a soft hard-tying prior that ties Matern ARD lengthscales within material groups. The production setting (`sigma=0.001`) ties members of {Cement, Fly Ash, Slag} to a single binder lengthscale, and {Fine, Coarse} aggregates to a single aggregate lengthscale, encoding the domain prior that ingredients of the same material class should share kernel smoothness. The structural prior: * Beats the no-prior baseline on held-out LOO CV RMSE — 725 vs 772 psi on the 647 public strength rows, via the GP's closed-form LOO identity. * Encodes only group structure (a public, ASTM-defined fact about cementitious vs aggregate materials), not any numeric values. * Makes Fly Ash and Coarse Aggregate sliders responsive in the explorer UI: both lengthscales now sit at 4.79 and 6.67 respectively, well below the cap and within the same range as the previously-identifiable features. Adds 11 regression tests in `test/test_lengthscale_identifiability.py` covering identifiability against the cap, within-group tying on both the committed and freshly-fit GP, prior installation by `fit_strength_gp`, an LOO-CV ceiling that catches the no-prior baseline, and unit tests for the prior class. A reproducible empirical sweep (`scripts/lengthscale_prior_study.py`) lets users re-run the variant comparison on their own machine. `docs/model/{strength,compositions, test_vectors}.json` are regenerated with the new fit; the JS GP inference and JS<->Python parity tests stay green. All LOO computation re-uses `boxcrete.compute_loo_cv` (now also re-exported from the package root). See `WithinGroupShrinkagePrior` docstring in `boxcrete/models.py` for the full empirical comparison and methodology.
1 parent 29f8961 commit f54fb2e

12 files changed

Lines changed: 937 additions & 81 deletions

boxcrete/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
get_strength_gp_input_transform,
1313
PartialFixedNoiseLikelihood,
1414
SustainableConcreteModel,
15+
WithinGroupShrinkagePrior,
1516
)
1617
from boxcrete.plotting import (
1718
compute_loo_cv,
@@ -80,6 +81,7 @@
8081
"SustainableConcreteDataset",
8182
"SustainableConcreteModel",
8283
"UnitSystem",
84+
"WithinGroupShrinkagePrior",
8385
"compute_loo_cv",
8486
"convert_slump",
8587
"convert_strength",

boxcrete/models.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from gpytorch.kernels import MaternKernel, RBFKernel, ScaleKernel
3535
from gpytorch.likelihoods import GaussianLikelihood
3636
from gpytorch.mlls import ExactMarginalLogLikelihood
37+
from gpytorch.priors import LogNormalPrior
3738
from linear_operator.operators import DiagLinearOperator
3839
from torch import Tensor
3940

@@ -478,13 +479,136 @@ def _shaped_noise_covar(self, base_shape, *params, **kwargs):
478479
return super()._shaped_noise_covar(base_shape, *params, **kwargs)
479480

480481

482+
# --- Within-group lengthscale shrinkage prior --------------------------------
483+
484+
# Material-class groupings used by the production within-group shrinkage
485+
# prior on the Matern kernel's lengthscales. Indices refer to
486+
# DEFAULT_X_COLUMNS: Cement, Fly Ash, Slag, Water, HRWR, Fine Aggregate,
487+
# Coarse Aggregates, Material Source, Temp, Time.
488+
_BINDER_LENGTHSCALE_GROUP = (0, 1, 2) # Cement, Fly Ash, Slag
489+
_AGGREGATE_LENGTHSCALE_GROUP = (5, 6) # Fine Aggregate, Coarse Aggregates
490+
491+
# σ → 0 effectively hard-ties the members of each group to a shared lengthscale.
492+
# Empirical LOO CV (n=647 public rows) found σ=0.001 minimises held-out RMSE
493+
# while keeping all lengthscales well below the identifiability cap. See
494+
# `scripts/lengthscale_prior_study.py` for the reproducible sweep.
495+
_LENGTHSCALE_SHRINKAGE_SIGMA = 0.001
496+
497+
498+
class WithinGroupShrinkagePrior(LogNormalPrior):
499+
"""Soft hard-tying prior on Matern ARD lengthscales within material groups.
500+
501+
Penalises within-group variance of log-lengthscales. Encodes the
502+
domain fact that interchangeable materials (e.g., cementitious binders
503+
or aggregates) should have similar smoothness scales in the GP. With
504+
``sigma → 0`` this approaches a hard tying constraint that forces
505+
each group to share a single lengthscale.
506+
507+
Why this prior exists
508+
---------------------
509+
Several composition features in ``data/boxcrete_data.csv`` are
510+
under-sampled. In particular, Fly Ash is zero in the majority of rows
511+
(most concretes use only Cement + Slag), and Coarse Aggregates are
512+
zero for the mortar half of the dataset. Without a prior, ARD pushes
513+
the corresponding Matern lengthscales to the optimiser's upper bound
514+
(``1e3`` in normalised input space), making the GP effectively
515+
insensitive to those features — the website's interactive sliders
516+
for Fly Ash and Coarse Aggregates would not respond to user input.
517+
518+
This prior softly ties the lengthscales of materials that play
519+
interchangeable physical roles, so the well-identified members of
520+
each group (Cement, Fine Aggregate) supply usable scale information
521+
to their under-identified peers (Fly Ash, Coarse Aggregates).
522+
523+
Empirical comparison
524+
--------------------
525+
Analytical LOO CV (via ``boxcrete.compute_loo_cv``) on n=647 public
526+
strength rows; lower RMSE is better:
527+
528+
+-----------------------------------------+-----------+
529+
| Variant | LOO RMSE |
530+
+=========================================+===========+
531+
| No prior (Fly Ash & Coarse Agg railed) | 772 psi |
532+
| Within-group shrinkage σ=0.50 | 754 psi |
533+
| Within-group shrinkage σ=0.10 | 731 psi |
534+
| Within-group shrinkage σ=0.001 (prod) | **725 psi** |
535+
+-----------------------------------------+-----------+
536+
537+
The σ → 0 limit Pareto-dominates every alternative we evaluated:
538+
per-feature LogNormal priors, Cauchy / Student-t shrinkage,
539+
asymmetric per-group widths, Cement-anchored shrinkage, and additive
540+
kernel decompositions. See ``scripts/lengthscale_prior_study.py``
541+
to reproduce the sweep on a fresh checkout.
542+
543+
Mathematical form
544+
-----------------
545+
The prior contributes the following log-density (up to a constant)
546+
to the marginal log-likelihood::
547+
548+
log p(ℓ) = -Σ_g Σ_{i ∈ g} (log ℓ_i - mean_{j ∈ g} log ℓ_j)² / (2 σ_g²)
549+
550+
where ``g`` ranges over the configured groups. Subclassing
551+
``LogNormalPrior`` lets the prior satisfy GPyTorch's
552+
``isinstance(_, Prior)`` check without reimplementing the Prior
553+
interface; the inherited ``loc`` / ``scale`` are unused placeholders.
554+
555+
Args:
556+
groups_with_sigma: List of ``(dim_indices, sigma)`` tuples. Each
557+
entry contributes a within-group penalty with its own width.
558+
``sigma → 0`` hard-ties the group; ``sigma → ∞`` is uniform.
559+
dim: Dimensionality of the lengthscale tensor (matches the
560+
kernel's ``ard_num_dims``).
561+
"""
562+
563+
def __init__(
564+
self,
565+
groups_with_sigma: list[tuple[tuple[int, ...], float]],
566+
dim: int,
567+
):
568+
super().__init__(loc=torch.zeros(1, dim), scale=1.0)
569+
self._groups_with_sigma = groups_with_sigma
570+
571+
def log_prob(self, x):
572+
log_x = x.log()
573+
flat_log = log_x.flatten()
574+
total = torch.zeros((), dtype=x.dtype, device=x.device)
575+
for grp, sigma in self._groups_with_sigma:
576+
if len(grp) < 2:
577+
continue
578+
grp_log = flat_log[list(grp)]
579+
sq_dev = ((grp_log - grp_log.mean()) ** 2).sum()
580+
total = total - 0.5 * sq_dev / (sigma**2)
581+
# gpytorch sums log_prob over elements, so distribute the scalar
582+
# penalty uniformly across x's shape.
583+
return total / x.numel() * torch.ones_like(x)
584+
585+
586+
def _default_lengthscale_prior(d_in: int) -> WithinGroupShrinkagePrior | None:
587+
"""Returns the production within-group shrinkage prior, or None if the
588+
input dimensionality doesn't match the production schema (in which case
589+
we fall back to the unconstrained MLE)."""
590+
# Only apply if the model uses the production 10-dim DEFAULT_X_COLUMNS
591+
# layout (Cement, Fly Ash, Slag, Water, HRWR, Fine, Coarse, MS, Temp,
592+
# Time). For sub-dim or test fits, return None.
593+
if d_in != 10:
594+
return None
595+
return WithinGroupShrinkagePrior(
596+
groups_with_sigma=[
597+
(_BINDER_LENGTHSCALE_GROUP, _LENGTHSCALE_SHRINKAGE_SIGMA),
598+
(_AGGREGATE_LENGTHSCALE_GROUP, _LENGTHSCALE_SHRINKAGE_SIGMA),
599+
],
600+
dim=d_in,
601+
)
602+
603+
481604
def fit_strength_gp(
482605
X: Tensor,
483606
Y: Tensor,
484607
Yvar: Tensor,
485608
X_bounds: Tensor | None = None,
486609
use_fixed_noise: bool = False,
487610
optimizer_kwargs: dict | None = None,
611+
lengthscale_prior: object | None = "default",
488612
) -> SingleTaskGP:
489613
"""Fits a Gaussian process model to the given strength data.
490614
@@ -495,6 +619,13 @@ def fit_strength_gp(
495619
X_bounds: Optional `2 x d`-dim bounds Tensor.
496620
use_fixed_noise: Whether to use fixed observation noise.
497621
optimizer_kwargs: Optional keyword arguments for the optimizer.
622+
lengthscale_prior: Prior on the Matern kernel's per-dim
623+
lengthscales. ``"default"`` (the default) installs the
624+
production ``WithinGroupShrinkagePrior``: a soft hard-tying
625+
prior that links binder lengthscales {Cement, Fly Ash, Slag}
626+
and aggregate lengthscales {Fine, Coarse} within each group.
627+
Pass ``None`` to fit without any lengthscale prior, or pass
628+
a ``gpytorch.priors.Prior`` instance to install a custom one.
498629
499630
Returns:
500631
A SingleTaskGP model fit to the strength data.
@@ -504,6 +635,9 @@ def fit_strength_gp(
504635
if d_out != 1:
505636
raise ValueError("Output dimensions is not one in strength curve fitting.")
506637

638+
if lengthscale_prior == "default":
639+
lengthscale_prior = _default_lengthscale_prior(d_in)
640+
507641
# add data to condition GP to be zero at day zero
508642
X_0, Y_0, Yvar_0 = get_day_zero_data(X=X, bounds=X_bounds, n=128)
509643
n_real = X.shape[0]
@@ -517,7 +651,7 @@ def fit_strength_gp(
517651
nu=2.5,
518652
ard_num_dims=d_in,
519653
lengthscale_constraint=LogTransformedInterval(1e-2, 1e3, initial_value=1.0),
520-
lengthscale_prior=None,
654+
lengthscale_prior=lengthscale_prior,
521655
)
522656
scaled_base_kernel = ScaleKernel(
523657
base_kernel=base_kernel,

docs/model/compositions.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/model/strength.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)