Skip to content

Commit 2b9d267

Browse files
Merge pull request #14 from facebookresearch/interactive-gp-website
Add interactive GP concrete strength explorer (BOxCrete website) + PartialFixedNoiseLikelihood
2 parents be62045 + 5cb35db commit 2b9d267

24 files changed

Lines changed: 5103 additions & 45 deletions

.github/workflows/js-sync.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Verifies that the JavaScript GP implementation matches Python reference predictions.
2+
# Catches drift if model parameters or JS code are changed without re-syncing.
3+
4+
name: JS Model Sync
5+
6+
# Restrict GITHUB_TOKEN to the minimum needed: read-only access to repo
7+
# contents (required by actions/checkout). The workflow never writes to the
8+
# repo, comments on PRs, deploys, or interacts with any other GitHub APIs.
9+
permissions:
10+
contents: read
11+
12+
on:
13+
push:
14+
branches: [main, master]
15+
paths:
16+
- 'docs/gp.mjs'
17+
- 'docs/model/**'
18+
- 'test/test_js_gp.mjs'
19+
pull_request:
20+
branches: [main, master]
21+
paths:
22+
- 'docs/gp.mjs'
23+
- 'docs/model/**'
24+
- 'test/test_js_gp.mjs'
25+
26+
env:
27+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
28+
29+
jobs:
30+
js-model-sync:
31+
runs-on: ubuntu-latest
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- uses: actions/setup-node@v4
36+
with:
37+
node-version: '20'
38+
39+
- name: Run JS GP sync test
40+
run: node test/test_js_gp.mjs

boxcrete/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
fit_slump_gp,
1111
fit_strength_gp,
1212
get_strength_gp_input_transform,
13+
PartialFixedNoiseLikelihood,
1314
SustainableConcreteModel,
1415
)
1516
from boxcrete.plotting import (
@@ -72,6 +73,7 @@
7273
"MORTAR_BOUNDS_DICT",
7374
"MORTAR_CONSTRAINTS",
7475
"MORTAR_REFERENCE_POINT",
76+
"PartialFixedNoiseLikelihood",
7577
"SLUMP_DISPLAY_SCALE",
7678
"SLUMP_Y_COLUMNS",
7779
"STRENGTH_DISPLAY_SCALE",

boxcrete/models.py

Lines changed: 70 additions & 2 deletions
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 linear_operator.operators import DiagLinearOperator
3738
from torch import Tensor
3839

3940
# Indices into DEFAULT_X_COLUMNS (without Time) for derived feature computation
@@ -415,6 +416,68 @@ def get_model_dict(
415416
return dict(zip(self.model_names, model_list.models))
416417

417418

419+
class PartialFixedNoiseLikelihood(GaussianLikelihood):
420+
"""Gaussian likelihood that learns noise for real observations while applying
421+
fixed near-zero noise to pseudo-observations.
422+
423+
This enables conditioning the GP to pass through pseudo-observations (e.g.,
424+
zero strength at time zero) with high certainty, while still learning the
425+
observation noise for real data points via marginal likelihood optimization.
426+
427+
Args:
428+
n_real: Number of real observations (must come first in training data).
429+
n_pseudo: Number of pseudo-observations (must come last in training data).
430+
pseudo_noise: Fixed noise variance for pseudo-observations.
431+
**kwargs: Additional keyword arguments passed to GaussianLikelihood
432+
(e.g., noise_constraint).
433+
"""
434+
435+
def __init__(
436+
self,
437+
n_real: int,
438+
n_pseudo: int,
439+
pseudo_noise: float = 1e-6,
440+
**kwargs,
441+
):
442+
super().__init__(**kwargs)
443+
self._n_real = n_real
444+
self._n_pseudo = n_pseudo
445+
self._pseudo_noise = pseudo_noise
446+
447+
@property
448+
def n_real(self) -> int:
449+
return self._n_real
450+
451+
@property
452+
def n_pseudo(self) -> int:
453+
return self._n_pseudo
454+
455+
@property
456+
def pseudo_noise(self) -> float:
457+
return self._pseudo_noise
458+
459+
def _shaped_noise_covar(self, base_shape, *params, **kwargs):
460+
n = base_shape[-1]
461+
noise = self.noise_covar.noise.squeeze() # learned scalar noise
462+
463+
if n == self._n_real + self._n_pseudo:
464+
# Training: learned noise for real obs, fixed for pseudo-obs
465+
diag = torch.cat(
466+
[
467+
noise.expand(self._n_real),
468+
torch.full(
469+
(self._n_pseudo,),
470+
self._pseudo_noise,
471+
device=noise.device,
472+
dtype=noise.dtype,
473+
),
474+
]
475+
)
476+
return DiagLinearOperator(diag)
477+
# Prediction at test points: use learned noise
478+
return super()._shaped_noise_covar(base_shape, *params, **kwargs)
479+
480+
418481
def fit_strength_gp(
419482
X: Tensor,
420483
Y: Tensor,
@@ -443,6 +506,8 @@ def fit_strength_gp(
443506

444507
# add data to condition GP to be zero at day zero
445508
X_0, Y_0, Yvar_0 = get_day_zero_data(X=X, bounds=X_bounds, n=128)
509+
n_real = X.shape[0]
510+
n_pseudo = X_0.shape[0]
446511
X = torch.cat((X, X_0), dim=0)
447512
Y = torch.cat((Y, Y_0), dim=0)
448513
Yvar = torch.cat((Yvar, Yvar_0), dim=0)
@@ -484,8 +549,11 @@ def fit_strength_gp(
484549
if use_fixed_noise:
485550
model_kwargs["train_Yvar"] = Yvar
486551
else:
487-
model_kwargs["likelihood"] = GaussianLikelihood(
488-
noise_constraint=LogTransformedInterval(1e-6, 1.0, initial_value=1e-1)
552+
model_kwargs["likelihood"] = PartialFixedNoiseLikelihood(
553+
n_real=n_real,
554+
n_pseudo=n_pseudo,
555+
pseudo_noise=1e-6,
556+
noise_constraint=LogTransformedInterval(1e-6, 1.0, initial_value=1e-1),
489557
)
490558
model = SingleTaskGP(**model_kwargs)
491559
mll = ExactMarginalLogLikelihood(model.likelihood, model)

boxcrete/utils.py

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -929,31 +929,26 @@ def get_subset_sum_tensors(
929929
# by `fit_gwp_model`.
930930
# Derived via per-class least-squares regression on training data (which
931931
# stores -GWP). Magnitudes here are the absolute emission factors.
932-
DEFAULT_GWP_COEFFICIENTS: dict[int, dict[str, tuple[float, float]]] = {
932+
DEFAULT_GWP_COEFFICIENTS = {
933933
0: { # Material Source 0
934-
"Cement (kg/m3)": (0.762613, 0.000384),
935-
"Fly Ash (kg/m3)": (0.029577, 0.000457),
936-
"Slag (kg/m3)": (0.085921, 0.000326),
937-
"Water (kg/m3)": (0.001829, 0.001177),
938-
# HRWR GWP: consistent with published EPDs for polycarboxylate-based
939-
# superplasticizers (1.5-5.0 kg CO₂/kg range; e.g. BASF MasterGlenium,
940-
# Sika ViscoCrete). Tight std confirms deterministic upstream formula.
941-
"HRWR (kg/m3)": (3.184316, 0.016840),
942-
"Fine Aggregate (kg/m3)": (0.002762, 0.000113),
943-
"Coarse Aggregates (kg/m3)": (0.003895, 0.000121),
944-
"Temp (C)": (0.002967, 0.005397),
934+
"Cement (kg/m3)": (0.762610, 0.000365),
935+
"Fly Ash (kg/m3)": (0.029601, 0.000432),
936+
"Slag (kg/m3)": (0.085926, 0.000310),
937+
"Water (kg/m3)": (-0.001765, 0.001114),
938+
"HRWR (kg/m3)": (3.184692, 0.016001),
939+
"Fine Aggregate (kg/m3)": (0.002788, 0.000097),
940+
"Coarse Aggregates (kg/m3)": (0.003910, 0.000112),
945941
},
946942
1: { # Material Source 1
947-
"Cement (kg/m3)": (0.774398, 0.007709),
948-
"Fly Ash (kg/m3)": (0.036826, 0.005956),
949-
"Slag (kg/m3)": (0.094776, 0.007031),
950-
"Water (kg/m3)": (0.001752, 0.020415),
951-
# HRWR GWP: consistent with Source 0 (3.18 vs 3.10 kg CO₂/kg).
943+
"Cement (kg/m3)": (0.773814, 0.007240),
944+
"Fly Ash (kg/m3)": (0.035681, 0.005542),
945+
"Slag (kg/m3)": (0.092849, 0.006469),
946+
"Water (kg/m3)": (0.003864, 0.019144),
947+
# HRWR GWP: consistent with Source 0 (3.18 vs 3.15 kg CO₂/kg).
952948
# See comment above for EPD references.
953-
"HRWR (kg/m3)": (3.102591, 0.531138),
954-
"Fine Aggregate (kg/m3)": (0.002823, 0.003714),
955-
"Coarse Aggregates (kg/m3)": (0.001063, 0.003163),
956-
"Temp (C)": (0.072522, 0.054983),
949+
"HRWR (kg/m3)": (3.151231, 0.498391),
950+
"Fine Aggregate (kg/m3)": (0.002513, 0.003486),
951+
"Coarse Aggregates (kg/m3)": (-0.000039, 0.002870),
957952
},
958953
}
959954

@@ -1016,31 +1011,40 @@ def make_linear_coefficients(
10161011
return means, variances
10171012

10181013

1019-
def get_day_zero_data(X: Tensor, bounds: Tensor | None, n: int = 128):
1020-
"""Computes a tensor of n sobol points that satisfy the bounds, appended with a
1021-
zeros tensor. Useful to condition the strength GP to be zero at day zero.
1014+
def get_day_zero_data(X: Tensor, bounds: Tensor | None = None, n: int = 128):
1015+
"""Generates pseudo-observations at time=0 for conditioning the GP to predict
1016+
zero strength at day zero.
1017+
1018+
Uses the unique compositions from the training data (without time) to ensure
1019+
the constraint is enforced at all observed mix designs. If the number of unique
1020+
compositions exceeds n, a random subset is selected.
10221021
10231022
Args:
1024-
X: The input tensor.
1025-
bounds: The bounds of the input tensor. If None, will be inferred from X.
1026-
n: The number of sobol points to generate.
1023+
X: The input tensor (n_train x d), where the last column is time.
1024+
bounds: Unused, kept for API compatibility. Will be removed in a future version.
1025+
n: Maximum number of pseudo-observations. If there are fewer unique
1026+
compositions than n, all unique compositions are used.
10271027
10281028
Returns:
1029-
A tensor of n sobol points that satisfy the bounds, appended with a zeros
1030-
tensor, corresponding to the strength at day zero.
1029+
A tuple (X_0, Y_0, Yvar_0) of pseudo-observations at time=0.
10311030
"""
1032-
if bounds is None:
1033-
bounds = torch.stack((X.amin(dim=0), X.amax(dim=0)))
1031+
# Use unique observed compositions (without time)
1032+
unique_comps = torch.unique(X[:, :-1], dim=0)
1033+
n_unique = unique_comps.shape[0]
10341034

1035-
d = bounds.shape[-1]
1036-
sobol_engine = torch.quasirandom.SobolEngine(dimension=(d - 1)) # excluding time
1037-
X_0 = sobol_engine.draw(n)
1038-
X_0 = torch.cat((X_0, torch.zeros(n, 1)), dim=-1) # append time (zero)
1039-
a, b = bounds[0], bounds[1]
1040-
X_0 = (b - a) * X_0 + a # scaling according to bounds
1041-
X_0[:, -1] = 0.0 # explicitly set time to zero (day zero conditioning)
1042-
Y_0 = torch.zeros(n, 1) # zero strength
1043-
Yvar_0 = torch.full((n, 1), 1e-4) # with large certainty
1035+
if n_unique <= n:
1036+
# Use all unique compositions
1037+
X_comps = unique_comps
1038+
else:
1039+
# Random subset of unique compositions
1040+
perm = torch.randperm(n_unique)[:n]
1041+
X_comps = unique_comps[perm]
1042+
1043+
n_out = X_comps.shape[0]
1044+
# Append time=0
1045+
X_0 = torch.cat((X_comps, torch.zeros(n_out, 1, dtype=X.dtype)), dim=-1)
1046+
Y_0 = torch.zeros(n_out, 1, dtype=X.dtype)
1047+
Yvar_0 = torch.full((n_out, 1), 1e-4, dtype=X.dtype)
10441048
return X_0, Y_0, Yvar_0
10451049

10461050

docs/.nojekyll

Whitespace-only changes.

docs/Q1000572_opt.jpg

856 KB
Loading

docs/blas_f64.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/blas_f64.wasm

7.82 KB
Binary file not shown.

docs/concrete_poster.jpg

1.33 MB
Loading

docs/favicon.svg

Lines changed: 14 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)