|
| 1 | +.. _custom_sampler: |
| 2 | + |
| 3 | +Implementing custom sampler for Stochastic objects |
| 4 | +================================================== |
| 5 | + |
| 6 | +The :ref:`stochastic_usage` documentation teaches how to work with stochastic |
| 7 | +objects, discussing the standard initializations, how to create objects and interpret |
| 8 | +outputs. Our goal here is to show how to build a custom sampler that gives complete |
| 9 | +control of the distributions used. |
| 10 | + |
| 11 | +Custom Sampler |
| 12 | +-------------- |
| 13 | + |
| 14 | +Rocketpy provides a ``CustomSampler`` abstract class which works as the backbone of |
| 15 | +a custom sampler. We begin by first importing it and some other useful modules |
| 16 | + |
| 17 | +.. jupyter-execute:: |
| 18 | + |
| 19 | + from rocketpy import CustomSampler |
| 20 | + from matplotlib import pyplot as plt |
| 21 | + import numpy as np |
| 22 | + |
| 23 | +In order to use it, we must create a new class that inherits from |
| 24 | +it and it **must** implement two methods: *sample* and *reset_seed*. The *sample* |
| 25 | +method has one argument, *n_samples*, and must return a list with *n_samples* |
| 26 | +entries, each of which is a sample of the desired variable. The *reset_seed* method |
| 27 | +has one argument, *seed*, which is used to reset the pseudorandom generators in order |
| 28 | +to avoid unwanted dependency across samples. This is especially important when the |
| 29 | +``MonteCarlo`` class is used in parallel mode. |
| 30 | + |
| 31 | +Below, we give an example of how to implement a mixture of two Gaussian |
| 32 | +distributions. |
| 33 | + |
| 34 | +.. jupyter-execute:: |
| 35 | + |
| 36 | + class TwoGaussianMixture(CustomSampler): |
| 37 | + """Class to sample from a mixture of two Gaussian distributions |
| 38 | + """ |
| 39 | + |
| 40 | + def __init__(self, means_tuple, sd_tuple, prob_tuple, seed = None): |
| 41 | + """ Creates a sampler for a mixture of two Gaussian distributions |
| 42 | + |
| 43 | + Parameters |
| 44 | + ---------- |
| 45 | + means_tuple : 2-tuple |
| 46 | + 2-Tuple that contains the mean of each normal distribution of the |
| 47 | + mixture |
| 48 | + sd_tuple : 2-tuple |
| 49 | + 2-Tuple that contains the sd of each normal distribution of the |
| 50 | + mixture |
| 51 | + prob_tuple : 2-tuple |
| 52 | + 2-Tuple that contains the probability of each normal distribution |
| 53 | + of the mixture. Its entries should be non-negative and sum up to 1. |
| 54 | + """ |
| 55 | + np.random.default_rng(seed) |
| 56 | + self.means_tuple = means_tuple |
| 57 | + self.sd_tuple = sd_tuple |
| 58 | + self.prob_tuple = prob_tuple |
| 59 | + |
| 60 | + def sample(self, n_samples = 1): |
| 61 | + """Samples from a mixture of two Gaussian |
| 62 | + |
| 63 | + Parameters |
| 64 | + ---------- |
| 65 | + n_samples : int, optional |
| 66 | + Number of samples, by default 1 |
| 67 | + |
| 68 | + Returns |
| 69 | + ------- |
| 70 | + samples_list |
| 71 | + List containing n_samples samples |
| 72 | + """ |
| 73 | + samples_list = [0] * n_samples |
| 74 | + mixture_id_list = np.random.binomial(1, self.prob_tuple[0], n_samples) |
| 75 | + for i, mixture_id in enumerate(mixture_id_list): |
| 76 | + if mixture_id: |
| 77 | + samples_list[i] = np.random.normal(self.means_tuple[0], self.sd_tuple[0]) |
| 78 | + else: |
| 79 | + samples_list[i] = np.random.normal(self.means_tuple[1], self.sd_tuple[1]) |
| 80 | + |
| 81 | + return samples_list |
| 82 | + |
| 83 | + def reset_seed(self, seed=None): |
| 84 | + """Resets all associated random number generators |
| 85 | + |
| 86 | + Parameters |
| 87 | + ---------- |
| 88 | + seed : int, optional |
| 89 | + Seed for the random number generator. |
| 90 | + """ |
| 91 | + np.random.default_rng(seed) |
| 92 | + |
| 93 | +This is an example of a distribution that is not implemented in numpy. Note that it is |
| 94 | +a general distribution, so we can use it for many different variables. |
| 95 | + |
| 96 | +.. note:: |
| 97 | + You can check more information about the mixture of Gaussian distributions |
| 98 | + `here <https://en.wikipedia.org/wiki/Mixture_model#Gaussian_mixture_model>`. |
| 99 | + Intuitively, if you think of a single Gaussian as a bell curve distribution, |
| 100 | + the mixture distribution resembles two bell curves superimposed, each with mode at their |
| 101 | + respective mean. |
| 102 | + |
| 103 | +Example: Gaussian Mixture for Total Impulse |
| 104 | +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 105 | + |
| 106 | +In order to use the new created sampler in a stochastic object, we first need |
| 107 | +to build an object. In this example, we will consider a case where the distribution of |
| 108 | +the total impulse is a mixture of two gaussian with mean parameters |
| 109 | +:math:`(6000, 7000)`, standard deviations :math:`(300, 100)` and mixture probabilities |
| 110 | +:math:`(0.7, 0.3)`. |
| 111 | + |
| 112 | +.. jupyter-execute:: |
| 113 | + |
| 114 | + means_tuple = (6000, 7000) |
| 115 | + sd_tuple = (300, 100) |
| 116 | + prob_tuple = (0.7, 0.3) |
| 117 | + total_impulse_sampler = TwoGaussianMixture(means_tuple, sd_tuple, prob_tuple) |
| 118 | + |
| 119 | +Finally, we can create ``StochasticSolidMotor`` object as we did in the example of |
| 120 | +:ref:`stochastic_usage`, but we pass the sampler object instead for the *total_impulse* |
| 121 | +argument |
| 122 | + |
| 123 | +.. jupyter-execute:: |
| 124 | + |
| 125 | + from rocketpy import SolidMotor, StochasticSolidMotor |
| 126 | + |
| 127 | + motor = SolidMotor( |
| 128 | + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", |
| 129 | + dry_mass=1.815, |
| 130 | + dry_inertia=(0.125, 0.125, 0.002), |
| 131 | + nozzle_radius=33 / 1000, |
| 132 | + grain_number=5, |
| 133 | + grain_density=1815, |
| 134 | + grain_outer_radius=33 / 1000, |
| 135 | + grain_initial_inner_radius=15 / 1000, |
| 136 | + grain_initial_height=120 / 1000, |
| 137 | + grain_separation=5 / 1000, |
| 138 | + grains_center_of_mass_position=0.397, |
| 139 | + center_of_dry_mass_position=0.317, |
| 140 | + nozzle_position=0, |
| 141 | + burn_time=3.9, |
| 142 | + throat_radius=11 / 1000, |
| 143 | + coordinate_system_orientation="nozzle_to_combustion_chamber", |
| 144 | + ) |
| 145 | + |
| 146 | + stochastic_motor = StochasticSolidMotor( |
| 147 | + solid_motor=motor, |
| 148 | + burn_start_time=(0, 0.1, "binomial"), |
| 149 | + grains_center_of_mass_position=0.001, |
| 150 | + grain_density=10, |
| 151 | + grain_separation=1 / 1000, |
| 152 | + grain_initial_height=1 / 1000, |
| 153 | + grain_initial_inner_radius=0.375 / 1000, |
| 154 | + grain_outer_radius=0.375 / 1000, |
| 155 | + throat_radius=0.5 / 1000, |
| 156 | + nozzle_radius=0.5 / 1000, |
| 157 | + nozzle_position=0.001, |
| 158 | + total_impulse=total_impulse_sampler, # total impulse using custom sampler! |
| 159 | + ) |
| 160 | + |
| 161 | + stochastic_motor.visualize_attributes() |
| 162 | + |
| 163 | +Let's generate some random motors and check the distribution of the total impulse |
| 164 | + |
| 165 | +.. jupyter-execute:: |
| 166 | + |
| 167 | + total_impulse_samples = [ |
| 168 | + stochastic_motor.create_object().total_impulse for _ in range(200) |
| 169 | + ] |
| 170 | + plt.hist(total_impulse_samples, density = True, bins = 30) |
| 171 | + |
| 172 | +Introducing dependency between parameters |
| 173 | +----------------------------------------- |
| 174 | + |
| 175 | +Although probabilistic **independency between samples**, i.e. different flight runs, |
| 176 | +is desired for Monte Carlo simulations, it is often important to be able to introduce |
| 177 | +**dependency between parameters**. A clear example of this is wind speed: if we know |
| 178 | +the wind speed in the x-axis, then our forecast model might tells us that the wind |
| 179 | +speed y-axis is more likely to be positive than negative, or vice-versa. These |
| 180 | +parameters are then correlated, and using samplers that model these correlations make |
| 181 | +the Monte Carlo analysis more robust. |
| 182 | + |
| 183 | +When we use the default numpy samplers, the Monte Carlo analysis samples the |
| 184 | +parameters independently from each other. However, using custom samplers, we can |
| 185 | +introduce dependency and correlation! It might be a bit tricky, but we will show how |
| 186 | +it can be done. First, let us import the modules required |
| 187 | + |
| 188 | +.. jupyter-execute:: |
| 189 | + |
| 190 | + from rocketpy import Environment, StochasticEnvironment |
| 191 | + from datetime import datetime, timedelta |
| 192 | + |
| 193 | +Assume we want to model the x and y axis wind speed as a Bivariate Gaussian with |
| 194 | +parameters :math:`\mu = (1, 1)` and variance-covariance matrix |
| 195 | +:math:`\Sigma = \begin{bmatrix} 0.2 & 0.17 \\ 0.17 & 0.3 \end{bmatrix}`. This will |
| 196 | +make the correlation between the speeds be of :math:`0.7`. |
| 197 | + |
| 198 | +Now, in order to correlate the parameters using different custom samplers, |
| 199 | +**the key trick is to create a common generator that will be used by both.** The code |
| 200 | +below implements an example of such a generator |
| 201 | + |
| 202 | +.. jupyter-execute:: |
| 203 | + |
| 204 | + class BivariateGaussianGenerator: |
| 205 | + """Bivariate Gaussian generator used across custom samplers |
| 206 | + """ |
| 207 | + def __init__(self, mean, cov, seed = None): |
| 208 | + """Initializes the generator |
| 209 | + |
| 210 | + Parameters |
| 211 | + ---------- |
| 212 | + mean : tuple, list |
| 213 | + Tuple or list with mean of bivariate Gaussian |
| 214 | + cov : np.array |
| 215 | + Variance-Covariance matrix |
| 216 | + seed : int, optional |
| 217 | + Number to seed random generator, by default None |
| 218 | + """ |
| 219 | + np.random.default_rng(seed) |
| 220 | + self.samples_list = [] |
| 221 | + self.samples_generated = 0 |
| 222 | + self.used_samples_x = 0 |
| 223 | + self.used_samples_y = 0 |
| 224 | + self.mean = mean |
| 225 | + self.cov = cov |
| 226 | + self.generate_samples(1000) |
| 227 | + |
| 228 | + def generate_samples(self, n_samples = 1): |
| 229 | + """Generate samples from bivariate Gaussian and append to sample list |
| 230 | + |
| 231 | + Parameters |
| 232 | + ---------- |
| 233 | + n_samples : int, optional |
| 234 | + Number of samples to be generated, by default 1 |
| 235 | + """ |
| 236 | + samples_generated = np.random.multivariate_normal(self.mean, self.cov, n_samples) |
| 237 | + self.samples_generated += n_samples |
| 238 | + self.samples_list += list(samples_generated) |
| 239 | + |
| 240 | + def reset_seed(self, seed=None): |
| 241 | + np.random.default_rng(seed) |
| 242 | + |
| 243 | + def get_samples(self, n_samples, axis): |
| 244 | + if axis == "x": |
| 245 | + if self.samples_generated < self.used_samples_x: |
| 246 | + self.generate_samples(n_samples) |
| 247 | + samples_list = [ |
| 248 | + sample[0] for sample in self.samples_list[self.used_samples_x:(self.used_samples_x + n_samples)] |
| 249 | + ] |
| 250 | + if axis == "y": |
| 251 | + if self.samples_generated < self.used_samples_y: |
| 252 | + self.generate_samples(n_samples) |
| 253 | + samples_list = [ |
| 254 | + sample[1] for sample in self.samples_list[self.used_samples_y:(self.used_samples_y + n_samples)] |
| 255 | + ] |
| 256 | + self.update_info(n_samples, axis) |
| 257 | + return samples_list |
| 258 | + |
| 259 | + def update_info(self, n_samples, axis): |
| 260 | + """Updates the information of the used samples |
| 261 | + |
| 262 | + Parameters |
| 263 | + ---------- |
| 264 | + n_samples : int |
| 265 | + Number of samples used |
| 266 | + axis : str |
| 267 | + Which axis was sampled |
| 268 | + """ |
| 269 | + if axis == "x": |
| 270 | + self.used_samples_x += n_samples |
| 271 | + if axis == "y": |
| 272 | + self.used_samples_y += n_samples |
| 273 | + |
| 274 | +This generator samples from the bivariate Gaussian and stores then in a *samples_list* |
| 275 | +attribute. Then, the idea is to create two samplers for the wind speeds that will share |
| 276 | +an object of this class and their sampling methods only get samples from the stored |
| 277 | +sample list. |
| 278 | + |
| 279 | +.. jupyter-execute:: |
| 280 | + |
| 281 | + class WindXSampler(CustomSampler): |
| 282 | + """Samples from X""" |
| 283 | + |
| 284 | + def __init__(self, bivariate_gaussian_generator): |
| 285 | + self.generator = bivariate_gaussian_generator |
| 286 | + |
| 287 | + def sample(self, n_samples=1): |
| 288 | + samples_list = self.generator.get_samples(n_samples, "x") |
| 289 | + return samples_list |
| 290 | + |
| 291 | + def reset_seed(self, seed=None): |
| 292 | + self.generator.reset_seed(seed) |
| 293 | + |
| 294 | + class WindYSampler(CustomSampler): |
| 295 | + """Samples from Y""" |
| 296 | + |
| 297 | + def __init__(self, bivariate_gaussian_generator): |
| 298 | + self.generator = bivariate_gaussian_generator |
| 299 | + |
| 300 | + def sample(self, n_samples=1): |
| 301 | + samples_list = self.generator.get_samples(n_samples, "y") |
| 302 | + return samples_list |
| 303 | + |
| 304 | + def reset_seed(self, seed=None): |
| 305 | + self.generator.reset_seed(seed) |
| 306 | + |
| 307 | +Then, we create the objects |
| 308 | + |
| 309 | +.. jupyter-execute:: |
| 310 | + |
| 311 | + mean = [1, 2] |
| 312 | + cov_mat = [[0.2, 0.171], [0.171, 0.3]] |
| 313 | + bivariate_gaussian_generator = BivariateGaussianGenerator(mean, cov_mat) |
| 314 | + wind_x_sampler = WindXSampler(bivariate_gaussian_generator) |
| 315 | + wind_y_sampler = WindYSampler(bivariate_gaussian_generator) |
| 316 | + |
| 317 | +With the sample objects created, we only need to create the stochastic objects and |
| 318 | +pass them as argument |
| 319 | + |
| 320 | +.. jupyter-execute:: |
| 321 | + |
| 322 | + spaceport_env = Environment( |
| 323 | + latitude=32.990254, |
| 324 | + longitude=-106.974998, |
| 325 | + elevation=1400, |
| 326 | + datum="WGS84", |
| 327 | + ) |
| 328 | + spaceport_env.set_atmospheric_model("custom_atmosphere", wind_u = 1, wind_v = 1) |
| 329 | + spaceport_env.set_date(datetime.now() + timedelta(days=1)) |
| 330 | + |
| 331 | + stochastic_environment = StochasticEnvironment( |
| 332 | + environment=spaceport_env, |
| 333 | + elevation=(1400, 10, "normal"), |
| 334 | + gravity=None, |
| 335 | + latitude=None, |
| 336 | + longitude=None, |
| 337 | + ensemble_member=None, |
| 338 | + wind_velocity_x_factor=wind_x_sampler, |
| 339 | + wind_velocity_y_factor=wind_y_sampler |
| 340 | + ) |
| 341 | + |
| 342 | +Finally, let us check that if there is a correlation between the wind speed in the |
| 343 | +two axis |
| 344 | + |
| 345 | +.. jupyter-execute:: |
| 346 | + |
| 347 | + wind_velocity_x_samples = [0] * 200 |
| 348 | + wind_velocity_y_samples = [0] * 200 |
| 349 | + for i in range(200): |
| 350 | + stochastic_environment.create_object() |
| 351 | + wind_velocity_x_samples[i] = stochastic_environment.obj.wind_velocity_x(0) |
| 352 | + wind_velocity_y_samples[i] = stochastic_environment.obj.wind_velocity_y(0) |
| 353 | + |
| 354 | + np.corrcoef(wind_velocity_x_samples, wind_velocity_y_samples) |
0 commit comments