3434from gpytorch .kernels import MaternKernel , RBFKernel , ScaleKernel
3535from gpytorch .likelihoods import GaussianLikelihood
3636from gpytorch .mlls import ExactMarginalLogLikelihood
37+ from gpytorch .priors import LogNormalPrior
3738from linear_operator .operators import DiagLinearOperator
3839from 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+
481604def 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 ,
0 commit comments