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
18import pytest
29import torch
310from botorch .models .deterministic import GenericDeterministicModel
411from 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
717from axtreme .qoi .marginal_cdf_extrapolation import MarginalCDFExtrapolation , acceptable_timestep_error , q_to_qtimestep
818
19+ torch .set_default_dtype (torch .float64 )
20+
921
1022class 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(
175385def 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(
199409def 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