Skip to content

Commit 16cb28c

Browse files
authored
Merge pull request #91 from dnv-opensource/feature/64-importance_sampling_unit_test
Feature/64 importance sampling MarginalCDF test
2 parents 57567bb + 44b3830 commit 16cb28c

6 files changed

Lines changed: 251 additions & 27 deletions

File tree

examples/crest_heights_north_sea/importance_sampling.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ def importance_sampling_distribution_uniform_region(
103103
Importance sampling weights for each sample. Shape (n_samples,)
104104
105105
Details:
106+
The mathematical justification for this algorithm is given in
107+
108+
"Efficient Long-Term Structural Reliability Estimation with Non-Gaussian
109+
Stochastic Models: A Design of Experiments Approach.” arXiv, March 3, 2025.
110+
https://doi.org/10.48550/arXiv.2503.01566."
111+
106112
The algorithm works as follows:
107113
1. For a chosen `threshold` c for the real environment distribution.
108114
@@ -152,7 +158,7 @@ def importance_sampling_distribution_uniform_region(
152158
- This is a reasonable approximation if p(x_i) is considered to be close enough to 0.
153159
154160
Todo: TODO
155-
- (sw 2024_09_17): Reference the paper here (or preprint) once its published Issue #177.
161+
- (ak 2025-07-09): add seeded option for testing purposes
156162
157163
"""
158164
uniform_dist = torch.distributions.Uniform(region[0], region[1])

tests/qoi/data/importance_sampling/brute_force_solution.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
156 KB
Binary file not shown.
95.6 KB
Binary file not shown.
48.4 KB
Binary file not shown.

tests/qoi/test_marginal_cdf_extrapolation.py

Lines changed: 243 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,48 @@
1+
# %%
2+
import json
3+
from pathlib import Path
4+
5+
import matplotlib.pyplot as plt
6+
import numpy as np
7+
import pandas as pd
18
import pytest
29
import torch
310
from botorch.models.deterministic import GenericDeterministicModel
411
from botorch.sampling.index_sampler import IndexSampler
5-
from torch.distributions import Gumbel
12+
from torch.distributions import Gumbel, MultivariateNormal
13+
from torch.utils.data import DataLoader
614

15+
from axtreme.data import FixedRandomSampler, ImportanceAddedWrapper, MinimalDataset
16+
from axtreme.eval.qoi_job import QoIJob
717
from axtreme.qoi.marginal_cdf_extrapolation import MarginalCDFExtrapolation, acceptable_timestep_error, q_to_qtimestep
818

19+
torch.set_default_dtype(torch.float64)
20+
921

1022
class TestMarginalCDFExtrapolation:
1123
"""
1224
1325
Plan:
14-
- __call__:
15-
- Unit test None
16-
- Itegration tests:
17-
- batcheed params and weights
18-
- dtype:
19-
- float32 where safe
20-
- float32 where not safe
21-
- _parameter_estimates:
22-
- Integration test:
23-
- batch produce same results as non batch
24-
- 1 and multi (2) posterior samples.
25-
OUT OF SCOPE:
26-
- Are importance weights working properly?
27-
28-
29-
Other sub componets we potentially should test?
26+
- without importance sampling:
27+
- __call__:
28+
- Unit test None
29+
- Integration tests:
30+
- batched params and weights
31+
- dtype:
32+
- float32 where safe
33+
- float32 where not safe
34+
- _parameter_estimates:
35+
- Integration test:
36+
- batch produce same results as non batch
37+
- 1 and multi (2) posterior samples.
38+
- with importance sampling:
39+
- Unit test:
40+
- _parameter_estimates: input and output weights are attached to the same samples
41+
- System Test
42+
- Show that that the QoI estimation with importance samples has less uncertainty in the result compared
43+
to when using the regular approach of including the whole environment dataset.
44+
45+
Other sub components we potentially should test?
3046
- distributions with different batches get optimised/treated properly (e.g in optimisation_)
3147
"""
3248

@@ -48,7 +64,7 @@ def test_call_basic_example(
4864
4965
Demonstrate both float32 and float64 can be used (with this short period)
5066
51-
Take 2 posterior samples to comfirm the shape can be supported throughout.
67+
Take 2 posterior samples to confirm the shape can be supported throughout.
5268
"""
5369
# Define the inputs
5470
quantile = torch.tensor(0.5, dtype=dtype)
@@ -75,7 +91,7 @@ def test_call_basic_example(
7591

7692
# Calculated the expected value directly.
7793
# This relies on knowledge of the internals, specifically that the underlying distribution produce will be
78-
# [Gumbel(0, 1e-6), Gumbel(1, 1e-6), Gumbel(2, 1e-6)]. The first two will be clipped to qauntile q= 1-finfo.eps
94+
# [Gumbel(0, 1e-6), Gumbel(1, 1e-6), Gumbel(2, 1e-6)]. The first two will be clipped to quantile q= 1-finfo.eps
7995
# as per the bounds of ApproximateMixture
8096
dist = Gumbel(torch.tensor(2, dtype=dtype), 1e-6)
8197
q_timestep = (dist.cdf(qoi[0]) + (1 - torch.finfo(dtype).eps) * 2) / 3
@@ -88,7 +104,7 @@ def test_call_insuffecient_numeric_precision(self, gp_passthrough_1p: GenericDet
88104
89105
Demonstrate both float32 and float64 can be used (with this short period)
90106
91-
Take 2 posterior samples to comfirm the shape can be supported throughout.
107+
Take 2 posterior samples to confirm the shape can be supported throughout.
92108
"""
93109
# Define the inputs
94110
dtype = torch.float32
@@ -119,7 +135,7 @@ def test_call_insuffecient_numeric_precision(self, gp_passthrough_1p: GenericDet
119135
def test_parameter_estimates_env_batch_invariant(
120136
self, gp_passthrough_1p_sampler: IndexSampler, gp_passthrough_1p: GenericDeterministicModel
121137
):
122-
"""Checks that batched and non-batch enve data produce the saem result.
138+
"""Checks that batched and non-batch env data produce the same result.
123139
124140
Tests the when the data is split into smaller batches they are then combined into the same output.
125141
@@ -159,6 +175,200 @@ def test_parameter_estimates_env_batch_invariant(
159175
torch.testing.assert_close(param_non_batch, param_batch)
160176
torch.testing.assert_close(weight_non_batch, weight_batch)
161177

178+
def test_parameter_estimates_consistency_of_weights(self, gp_passthrough_1p: GenericDeterministicModel):
179+
"""
180+
Tests that the `_parameter_estimates` method in `MarginalCDFExtrapolation` correctly combines environment
181+
samples and their associated importance weights, and that the outputs match expected values when using a
182+
deterministic Gaussian Process (GP). The deterministic nature of the GP model leads to fully predictable
183+
posterior samples, i.e. they are identical to the environment samples.
184+
185+
This test ensures that:
186+
- Posterior samples returned by `_parameter_estimates` are correctly ordered.
187+
- Importance weights are preserved and correctly matched to the associated samples.
188+
189+
Args:
190+
gp_passthrough_1p is defined in conftest.py. It creates a deterministic GP which always produces identical
191+
posterior samples. The output location is a direct pass through of the given input data, and the scale is
192+
set to 1e-6.
193+
"""
194+
# MarginalCDFExtrapolation expects an iterable of env data. To use importance samples, each item is expected to
195+
# be of the following form [env_samples, importance_weights], where:
196+
# env_samples.shape = (batch_size,d), and importance_weights.shape = (batch_size,)
197+
# Note: in practice a dataloader is typically used to achieve this.
198+
env_and_importance_data = [
199+
# data batch 1: [env_samples, importance_weights]
200+
[torch.tensor([[1.0], [2.0]]), torch.Tensor([0.1, 0.2])],
201+
# data batch 2: [env_samples, importance_weights]
202+
[torch.tensor([[3.0], [4.0]]), torch.Tensor([0.3, 0.4])],
203+
]
204+
205+
# Run the method
206+
qoi_estimator = MarginalCDFExtrapolation(
207+
env_iterable=env_and_importance_data,
208+
period_len=99999, # not used in this test
209+
posterior_sampler=IndexSampler(torch.Size([1])), # draw 1 posterior sample to simplify comparison
210+
quantile=torch.tensor(float("nan"), dtype=torch.float64), # not used in this test
211+
quantile_accuracy=torch.tensor(float("nan"), dtype=torch.float64), # not used in this test
212+
dtype=torch.float64,
213+
)
214+
215+
# Get posterior samples and connected weights for given QoI estimator
216+
posterior_samples, importance_weights_qoi = qoi_estimator._parameter_estimates(gp_passthrough_1p)
217+
218+
# As a deterministic GP is used the posterior samples should not only have the same values as the
219+
# input samples but their order should be the same as well
220+
# For the deterministic GP gp_passthrough_1p the a dummy posterior mean function is used which sets `loc`
221+
# equal to the `env_value` and `scale` to 1e-6.
222+
expected_posterior = torch.tensor(
223+
[[[1.0000e00, 1.0000e-06], [2.0000e00, 1.0000e-06], [3.0000e00, 1.0000e-06], [4.0000e00, 1.0000e-06]]]
224+
)
225+
assert torch.equal(expected_posterior, posterior_samples)
226+
227+
# The calculated importance weights should be the same as the input importance weights
228+
assert torch.equal(torch.tensor([[0.1, 0.2, 0.3, 0.4]]), importance_weights_qoi)
229+
230+
@pytest.mark.system
231+
@pytest.mark.non_deterministic
232+
# Ruff does not allow default arguments in test functions. Using a decorator circumvents that.
233+
@pytest.mark.parametrize("error_tolerance, visualise", [(1, False)])
234+
def test_system_marginal_cdf_with_importance_sampling(
235+
self,
236+
error_tolerance: float,
237+
*,
238+
visualise: bool,
239+
):
240+
"""
241+
There is variability in the specific samples in the env_data used to instantiate a QoiEstimator, which creates
242+
the variability of the QoIEstimator estimate. This can be seen by inspecting how the estimates given change when
243+
the QoiEstimator has been instantiated with a different dataset. This test shows how (good) importance samples
244+
can reduce the variability between estimates of QoIEstimators instantiated with different env_data. The effect
245+
of GP uncertainty is removed by using a deterministic model, meaning all uncertainty in the estimate comes from
246+
the environment samples.
247+
248+
Args:
249+
error_tolerance: The error allowed in assertions is multiplied by this number.
250+
visualise: Bool to specify if the QoI results shall be plotted.
251+
252+
Expectation:
253+
As we use a deterministic GP the QoI estimator will not be a distribution but a point representing the mean.
254+
The std of the means of several runs of the QoI estimator should be lower with uncertainty sampling than
255+
without. By visual inspection a threshold for the std for importance sampling is chosen.
256+
"""
257+
258+
# Load precalculated importance samples and weights
259+
importance_samples = torch.load(
260+
Path(__file__).parent / "data" / "importance_sampling" / "importance_samples.pt", weights_only=True
261+
)
262+
importance_weights = torch.load(
263+
Path(__file__).parent / "data" / "importance_sampling" / "importance_weights.pt", weights_only=True
264+
)
265+
266+
importance_dataset = ImportanceAddedWrapper(
267+
MinimalDataset(importance_samples), MinimalDataset(importance_weights)
268+
)
269+
270+
# Load environment data
271+
# The environment is chosen such that it is concentrated in one region of the search space and only few samples
272+
# cover the subregion where the extreme response occurs
273+
env_data = np.load(Path(__file__).parent / "data" / "importance_sampling" / "environment_distribution.npy")
274+
275+
# Get brute force estimate
276+
brute_force_path = Path(__file__).parent / "data" / "importance_sampling" / "brute_force_solution.json"
277+
with brute_force_path.open() as file:
278+
brute_force_qoi = json.load(file)["statistics"]["median"]
279+
280+
# Set up a deterministic GP
281+
def _true_underlying_func(x: torch.Tensor) -> torch.Tensor:
282+
# Define loc function
283+
dist_mean, dist_cov = torch.tensor([1, 1]), torch.tensor([[0.03, 0], [0, 0.03]])
284+
dist = MultivariateNormal(loc=dist_mean, covariance_matrix=dist_cov)
285+
loc = torch.exp(dist.log_prob(x))
286+
287+
# Define scale function
288+
scale = torch.ones(x.shape[0]) * 0.1
289+
290+
return torch.stack([loc, scale], dim=-1)
291+
292+
gp_deterministic = GenericDeterministicModel(_true_underlying_func, num_outputs=2)
293+
294+
# Create jobs with with and without importance sampling
295+
qoi_jobs = []
296+
datasets = {"full": env_data, "importance_sample": importance_dataset}
297+
for dataset_name, dataset in datasets.items():
298+
for i in range(200):
299+
# A fixed random sampler selects the same samples if the seed is the same which allows the results to be
300+
# compared if this function is run multiple times
301+
dataset_size = 800
302+
sampler = FixedRandomSampler(dataset, num_samples=dataset_size, seed=i, replacement=True)
303+
dataloader = DataLoader(dataset, sampler=sampler, batch_size=100)
304+
305+
qoi_estimator = MarginalCDFExtrapolation(
306+
env_iterable=dataloader,
307+
period_len=1_000,
308+
quantile=torch.tensor(0.5),
309+
quantile_accuracy=torch.tensor(0.01),
310+
# IndexSampler needs to be used with GenericDeterministicModel. Each sample just selects the mean.
311+
# As we use a deterministic model all posterior samples are identical and hence we only use one.
312+
posterior_sampler=IndexSampler(torch.Size([1])),
313+
)
314+
315+
qoi_jobs.append(
316+
QoIJob(
317+
name=f"qoi_{dataset_name}_{dataset_size}_{i}",
318+
qoi=qoi_estimator,
319+
model=gp_deterministic,
320+
tags={
321+
"dataset_name": dataset_name,
322+
"dataset_size": dataset_size,
323+
},
324+
)
325+
)
326+
327+
jobs_output_file = None
328+
qoi_results = [job(output_file=jobs_output_file) for job in qoi_jobs]
329+
330+
df_jobs = pd.json_normalize([item.to_dict() for item in qoi_results], max_level=1)
331+
df_jobs.columns = df_jobs.columns.str.removeprefix("tags.")
332+
333+
# The aim of this test is to show that QoI estimation with importance sampling results in less uncertainty in
334+
# the QoI compared to when using the full environment dataset.
335+
336+
# All absolute values in the following asserts are chosen based on visual inspection of the QoI results.
337+
338+
# There are two criteria we use to judge the convergence:
339+
340+
# 1. By using importance sampling the variance between multiple runs is significantly reduced
341+
# compared to using the whole environment dataset. This is the main benefit of using importance sampling.
342+
# We verify this by checking that the std of the QoI means is small with importance sampling.
343+
std_qoi_means_importance_sampling = df_jobs.loc[df_jobs["dataset_name"] == "importance_sample", "mean"].std()
344+
assert std_qoi_means_importance_sampling <= 0.25 * error_tolerance
345+
346+
# 2. The mean of the QoI means is close to the brute force solution.
347+
mean_qoi_means_importance_sampling = df_jobs.loc[df_jobs["dataset_name"] == "importance_sample", "mean"].mean()
348+
assert abs(mean_qoi_means_importance_sampling - brute_force_qoi) <= 0.2 * error_tolerance
349+
350+
if visualise:
351+
_, ax = plt.subplots(1, 2, figsize=(10, 5), sharex=True, sharey=True)
352+
353+
# Plot results for full env data
354+
std_qoi_means_full = df_jobs.loc[df_jobs["dataset_name"] == "full", "mean"].std()
355+
mean_qoi_means_full = df_jobs.loc[df_jobs["dataset_name"] == "full", "mean"].mean()
356+
357+
df_jobs[df_jobs["dataset_name"] == "full"].hist(column="mean", ax=ax[0], grid=False)
358+
ax[0].axvline(brute_force_qoi, c="orange", label=f"Brute force ({brute_force_qoi:.2f})")
359+
ax[0].set_title(f"Dataset: full, mean={std_qoi_means_full:.2f}, std={mean_qoi_means_full:.2f}")
360+
ax[0].legend()
361+
362+
# Plot results for importance sampling
363+
df_jobs[df_jobs["dataset_name"] == "importance_sample"].hist(column="mean", ax=ax[1], grid=False)
364+
ax[1].axvline(brute_force_qoi, c="orange", label=f"Brute force ({brute_force_qoi:.2f})")
365+
ax[1].set_title(
366+
"Dataset: importance sample, "
367+
f"mean={mean_qoi_means_importance_sampling:.2f}, "
368+
f"std={std_qoi_means_importance_sampling:.2f}"
369+
)
370+
ax[0].legend()
371+
162372

163373
@pytest.mark.parametrize("dtype", [(torch.float32), (torch.float64)])
164374
@pytest.mark.parametrize(
@@ -175,7 +385,7 @@ def test_parameter_estimates_env_batch_invariant(
175385
def test_q_to_qtimestep_numerical_precision_of_timestep_conversion(
176386
dtype: torch.dtype, longterm_q: float, period_len: int
177387
):
178-
"""Numerical stability of converting from period quantiles to timestep quantiles.
388+
"""Numerical stability of converting from period quantiles to time step quantiles.
179389
180390
This serves as documentation to show standard operators do not cause numerical issues when converting.
181391
"""
@@ -199,15 +409,15 @@ def test_q_to_qtimestep_numerical_precision_of_timestep_conversion(
199409
def test_q_to_qtimestep_numerical_precision_period_increase(q: float):
200410
"""Estimate the numerical error introduced through this operation.
201411
202-
This calcutates the "round trip error" of going q_longterm -> q_timestep -> q_longterm, as this is easier to test.
412+
This calculates the "round trip error" of going q_longterm -> q_timestep -> q_longterm, as this is easier to test.
203413
This error may be larger than conversion q_longterm -> q_timestep.
204-
By default python uses flaot64 (on most machines). This has a precision of 1e-15.
414+
By default python uses float64 (on most machines). This has a precision of 1e-15.
205415
206416
NOTE:
207417
- abs = 1e-10: all tests pass
208-
- abs = 1e-11: japprox half the tests fail.
418+
- abs = 1e-11: approx half the tests fail.
209419
210-
By default python uses flaot64 (on most machines). This has a precision of 1e-15.
420+
By default python uses float64 (on most machines). This has a precision of 1e-15.
211421
"""
212422

213423
period_len = int(1e13)
@@ -219,3 +429,10 @@ def test_acceptable_timestep_error_at_limits_of_precision():
219429
"""When values reach the limits of precision check an error is thrown."""
220430
with pytest.raises(ValueError):
221431
_ = acceptable_timestep_error(0.5, int(1e6), atol=1e-10)
432+
433+
434+
# %% Can be used to get plots for test_system_marginal_cdf_with_importance_sampling
435+
if __name__ == "__main__":
436+
# %%
437+
MarginalCDF = TestMarginalCDFExtrapolation()
438+
MarginalCDF.test_system_marginal_cdf_with_importance_sampling(1, visualise=True)

0 commit comments

Comments
 (0)