Skip to content

Commit 1017d7e

Browse files
Merge pull request #20 from facebookresearch/within-group-lengthscale-prior
Add within-group lengthscale shrinkage prior for the strength GP
2 parents 29f8961 + d617837 commit 1017d7e

12 files changed

Lines changed: 939 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)