├── poetry.toml ├── docs ├── assets │ └── iclogo.png ├── index.md ├── .icons │ └── logos │ │ └── iclogo.svg └── 5-Parallel-Euler-Maruyama-Class.ipynb ├── requirements.txt ├── src └── euler_maruyama │ ├── __init__.py │ ├── plotting.py │ ├── parallel_euler_maruyama.py │ ├── coefficients.py │ └── euler_maruyama.py ├── .github └── workflows │ ├── docs.yml │ └── tests_workflow.yaml ├── .gitignore ├── pyproject.toml ├── LICENSE.md ├── tests ├── test_coefficients.py ├── test_parallel_euler_maruyama.py └── test_euler_maruyama.py ├── mkdocs.yml └── README.md /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true -------------------------------------------------------------------------------- /docs/assets/iclogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImperialCollegeLondon/ReCoDe-Euler-Maruyama/HEAD/docs/assets/iclogo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | mkdocs-jupyter==0.21.0 3 | mkdocs==1.3.0 4 | mkdocs-material==8.3.9 5 | python-markdown-math==0.8 6 | mkdocs-include-markdown-plugin==3.5.2 7 | poetry 8 | numpy 9 | matplotlib 10 | jupyter 11 | joblib 12 | -------------------------------------------------------------------------------- /src/euler_maruyama/__init__.py: -------------------------------------------------------------------------------- 1 | from .coefficients import ( 2 | ConstantDiffusion, 3 | LinearDrift, 4 | MeanReversionDrift, 5 | MultiplicativeNoiseDiffusion, 6 | ) 7 | from .euler_maruyama import EulerMaruyama 8 | from .parallel_euler_maruyama import ParallelEulerMaruyama 9 | from .plotting import plot_approximation 10 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | {% 9 | include-markdown "../README.md" 10 | 11 | %} 12 | 13 | 15 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - gnikit/changes 7 | jobs: 8 | deploy: 9 | name: Deploy Mkdocs to gh-pages branch 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.x 16 | - name: Install dependencies 17 | run: pip install -r requirements.txt 18 | - name: Deploy Mkdocs 19 | run: mkdocs gh-deploy --force 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | .venv/ 13 | .idea/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | *dist/ 29 | pip-wheel-metadata/ 30 | .python-version 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage_report/ 40 | .coverage.* 41 | .coverage 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .pytest_cache/ 47 | .mypy_cache/ 48 | .ruff_cache/ 49 | .test_report/ 50 | test-results/ 51 | 52 | ## Docs 53 | docs/_build/ 54 | site/ 55 | -------------------------------------------------------------------------------- /src/euler_maruyama/plotting.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | 4 | 5 | def plot_approximation(Y: np.ndarray, t: np.ndarray, title: str) -> None: 6 | """Plot the numerical approximation obtained for the Stochastic Differential Equation. 7 | 8 | Parameters 9 | ---------- 10 | Y: np.ndarray 11 | The approximated trajectories of the Euler-Maruyama method, shape = (number of simulations, number of steps). 12 | 13 | t: np.ndarray 14 | Array containing the time steps values, shape: (number of steps, ). 15 | 16 | title: str 17 | Title of the figure. 18 | """ 19 | 20 | fig, ax = plt.subplots(figsize=(7, 5)) 21 | 22 | ax.plot(t, Y.T, alpha=0.3) 23 | 24 | ax.set_xlabel(r"$t$") 25 | ax.set_ylabel(r"$Y_t$") 26 | 27 | ax.set_title(title) 28 | 29 | plt.show() 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "euler-maruyama" 3 | version = "0.1.0" 4 | description = "ReCoDe - Python class for the Euler-Maruyama method for solving stochastic differential equations." 5 | authors = [ 6 | "Antonio Malpica ", 7 | "Chris Cooling " 8 | ] 9 | readme = "README.md" 10 | packages = [{include = "euler_maruyama", from = "src"}] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.9" 14 | numpy = "^1.24.3" 15 | matplotlib = "^3.7.1" 16 | jupyter = "^1.0.0" 17 | joblib = "^1.2.0" 18 | 19 | [tool.poetry.group.dev] 20 | optional = true 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | black = {version = "22.10.*", allow-prereleases = true} 24 | isort = "5.12.*" 25 | pytest = "^7.4.0" 26 | pytest-html = "^3.2.0" 27 | 28 | 29 | [build-system] 30 | requires = ["poetry-core"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /.github/workflows/tests_workflow.yaml: -------------------------------------------------------------------------------- 1 | name: test - workflow 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'src/**' 8 | - 'tests/**' 9 | - 'pyproject.toml' 10 | 11 | jobs: 12 | tests: 13 | name: Test (${{ matrix.python }}) 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python: [ "3.9", "3.10", "3.11" ] 18 | container: 19 | image: python:${{ matrix.python }} 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | with: 24 | path: main 25 | - name: Install poetry 26 | working-directory: ./main 27 | run: | 28 | pip install pip poetry --upgrade 29 | poetry install --with dev 30 | - name: Code format checking 31 | working-directory: ./main 32 | run: poetry run black . 33 | - name: Imports order checking 34 | working-directory: ./main 35 | run: poetry run isort . 36 | - name: Tests 37 | working-directory: ./main 38 | run: poetry run pytest tests/ -vv 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Imperial College London 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/test_coefficients.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from euler_maruyama import ( 5 | ConstantDiffusion, 6 | LinearDrift, 7 | MeanReversionDrift, 8 | MultiplicativeNoiseDiffusion, 9 | ) 10 | 11 | 12 | @pytest.fixture(scope="module") 13 | def X_input(): 14 | X_input = np.random.randn(20, 1) 15 | return X_input 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def t_input(): 20 | t_input = 10 21 | return t_input 22 | 23 | 24 | def test_LinearDrift(X_input, t_input): 25 | 26 | a = 2.5 27 | linear_drift = LinearDrift(a=a) 28 | 29 | output = linear_drift.get_value(X=X_input, t=t_input) 30 | 31 | expected_output = np.ones_like(X_input) * t_input * a 32 | 33 | np.testing.assert_array_equal(output, expected_output) 34 | 35 | 36 | def test_MeanReversionDrift(X_input, t_input): 37 | 38 | theta = 0.2 39 | mean = 1 40 | mean_reversion_drift = MeanReversionDrift(theta=theta, mean=mean) 41 | 42 | output = mean_reversion_drift.get_value(X=X_input, t=t_input) 43 | 44 | expected_output = theta * (mean - X_input) 45 | 46 | np.testing.assert_array_equal(output, expected_output) 47 | 48 | 49 | def test_ConstantDiffusion(X_input, t_input): 50 | 51 | b = 0.1 52 | constant_diffusion = ConstantDiffusion(b=b) 53 | 54 | output = constant_diffusion.get_value(X=X_input, t=t_input) 55 | 56 | expected_output = np.ones_like(X_input) * b 57 | 58 | np.testing.assert_array_equal(output, expected_output) 59 | 60 | 61 | def test_MultiplicativeNoiseDiffusion(X_input, t_input): 62 | 63 | b = 0.5 64 | multiplicative_noise_diffusion = MultiplicativeNoiseDiffusion(b=b) 65 | 66 | output = multiplicative_noise_diffusion.get_value(X=X_input, t=t_input) 67 | 68 | expected_output = X_input * b 69 | 70 | np.testing.assert_array_equal(output, expected_output) 71 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: ReCoDE Euler-Maruyama 2 | 3 | # Steps to follow on GitHub to publish your mkdocs site: 4 | # 1. Create a new branch called gh-pages 5 | # 2. Ensure GitHub Pages is enabled for your repository, Settings>Pages> Enable. 6 | # Make sure that Source is set to `gh-pages` and `/(root)`. Save this setting. 7 | # 3. Ensure that on GitHub workflows have read and write access to your repository 8 | # Settings>Actions>General>Workflow permissions, tick `Read and write permissions` 9 | # and save this setting. 10 | # Change this to the name of your repo 11 | repo_url: https://github.com/ImperialCollegeLondon/ReCoDe-Euler-Maruyama 12 | edit_uri: tree/main/docs/ 13 | 14 | theme: 15 | name: material 16 | custom_dir: docs 17 | 18 | icon: 19 | logo: logos/iclogo 20 | favicon: assets/iclogo.png 21 | font: 22 | text: Roboto 23 | code: Roboto Mono 24 | palette: 25 | - scheme: default 26 | toggle: 27 | icon: material/toggle-switch 28 | name: Switch to dark mode 29 | - scheme: slate 30 | toggle: 31 | icon: material/toggle-switch-off-outline 32 | name: Switch to light mode 33 | 34 | # SPA behaviour 35 | features: 36 | - navigation.instant 37 | - navigation.top 38 | - toc.follow 39 | - content.code.annotate 40 | extra: 41 | homepage: https://imperialcollegelondon.github.io/ReCoDE-home/ 42 | 43 | # Add here all the plugins you want to use. 44 | # Don't forget to add them in requirements.txt as well. 45 | plugins: 46 | - tags 47 | - search 48 | - include-markdown # https://github.com/mondeja/mkdocs-include-markdown-plugin 49 | - mkdocs-jupyter 50 | 51 | # Set settings for markdown extensions 52 | markdown_extensions: 53 | - meta 54 | - mdx_math: 55 | enable_dollar_delimiter: True 56 | - pymdownx.highlight: 57 | anchor_linenums: false 58 | - pymdownx.inlinehilite 59 | - pymdownx.snippets 60 | - pymdownx.superfences 61 | 62 | # Render math in mkdocs 63 | extra_javascript: 64 | - https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS_HTML 65 | -------------------------------------------------------------------------------- /tests/test_parallel_euler_maruyama.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from euler_maruyama import ConstantDiffusion, LinearDrift, ParallelEulerMaruyama 5 | 6 | 7 | @pytest.fixture(scope="function") 8 | def parallel_em(): 9 | 10 | linear_drift = LinearDrift(a=2) 11 | constant_diffusion = ConstantDiffusion(b=0.5) 12 | 13 | em = ParallelEulerMaruyama( 14 | t_0=0, 15 | t_n=2, 16 | n_steps=500, 17 | X_0=1.0, 18 | drift=linear_drift, 19 | diffusion=constant_diffusion, 20 | n_sim=1000, 21 | n_jobs=4, 22 | ) 23 | 24 | return em 25 | 26 | 27 | def test_n_jobs(parallel_em): 28 | 29 | assert parallel_em.n_jobs == 4 30 | 31 | parallel_em.n_jobs = 2 32 | assert parallel_em.n_jobs == 2 33 | 34 | with pytest.raises(ValueError) as ex_info: 35 | parallel_em.n_jobs = -5 36 | 37 | assert ex_info.value.args[0] == "Number of batches must be positive." 38 | 39 | 40 | def test_num_sim_batch(parallel_em): 41 | 42 | batches = parallel_em._num_sim_batch() 43 | 44 | assert batches == [250] * 4 45 | 46 | parallel_em.n_jobs = 3 47 | 48 | new_batches = parallel_em._num_sim_batch() 49 | 50 | assert new_batches == [333] * (3 - 1) + [334] * 1 51 | 52 | parallel_em.n_jobs = 7 53 | 54 | new_batches_2 = parallel_em._num_sim_batch() 55 | 56 | assert new_batches_2 == [142] * (7 - 6) + [143] * 6 57 | 58 | 59 | def test_numerical_approximation(parallel_em): 60 | 61 | Y = parallel_em.compute_numerical_approximation() 62 | 63 | assert Y.shape == (parallel_em.n_sim, parallel_em.n_steps + 1) 64 | 65 | expected_Y_0 = np.ones(parallel_em.n_sim) * parallel_em._X_0 66 | np.testing.assert_array_equal(Y[:, 0], expected_Y_0) 67 | 68 | expected_mean_Y_t = parallel_em._X_0 + parallel_em._t_n * 2 69 | mean_Y_t = np.mean(Y[:, -1]) 70 | assert expected_mean_Y_t == pytest.approx(mean_Y_t, 0.1) 71 | 72 | expected_std_Y_t = 0.5 * np.sqrt(parallel_em._t_n) 73 | std_Y_t = np.std(Y[:, -1]) 74 | assert expected_std_Y_t == pytest.approx(std_Y_t, 0.1) 75 | -------------------------------------------------------------------------------- /tests/test_euler_maruyama.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from euler_maruyama import ConstantDiffusion, EulerMaruyama, LinearDrift 5 | 6 | 7 | @pytest.fixture(scope="function") 8 | def em(): 9 | 10 | linear_drift = LinearDrift(a=2) 11 | constant_diffusion = ConstantDiffusion(b=0.5) 12 | 13 | em = EulerMaruyama( 14 | t_0=0, 15 | t_n=2, 16 | n_steps=500, 17 | X_0=1.0, 18 | drift=linear_drift, 19 | diffusion=constant_diffusion, 20 | n_sim=1000, 21 | ) 22 | 23 | return em 24 | 25 | 26 | def test_discretisation(em): 27 | 28 | expected_t, expected_delta = np.linspace( 29 | em._t_0, em._t_n, em.n_steps + 1, retstep=True 30 | ) 31 | np.testing.assert_array_equal(em.t, expected_t) 32 | assert em.delta == expected_delta 33 | 34 | 35 | def test_n_sim(em): 36 | 37 | assert em.n_sim == 1000 38 | 39 | em.n_sim = 10 40 | 41 | assert em.n_sim == 10 42 | 43 | with pytest.raises(ValueError) as ex_info: 44 | em.n_sim = -10 45 | 46 | assert ex_info.value.args[0] == "Number of simulations must be positive." 47 | 48 | 49 | def test_n_steps(em): 50 | 51 | assert em.n_steps == 500 52 | 53 | em.n_steps = 10 54 | 55 | assert em.n_steps == 10 56 | expected_t, expected_delta = np.linspace(em._t_0, em._t_n, 10 + 1, retstep=True) 57 | np.testing.assert_array_equal(em.t, expected_t) 58 | assert em.delta == expected_delta 59 | 60 | 61 | def test_allocate_Y(em): 62 | 63 | Y = em._allocate_Y(dim=em.n_sim) 64 | 65 | assert Y.shape == (em.n_sim, em.n_steps + 1) 66 | 67 | expected_Y_0 = np.ones(em.n_sim) * em._X_0 68 | np.testing.assert_array_equal(Y[:, 0], expected_Y_0) 69 | 70 | 71 | def test_numerical_approximation(em): 72 | 73 | Y = em.compute_numerical_approximation() 74 | 75 | assert Y.shape == (em.n_sim, em.n_steps + 1) 76 | 77 | expected_Y_0 = np.ones(em.n_sim) * em._X_0 78 | np.testing.assert_array_equal(Y[:, 0], expected_Y_0) 79 | 80 | expected_mean_Y_t = em._X_0 + em._t_n * 2 81 | mean_Y_t = np.mean(Y[:, -1]) 82 | assert expected_mean_Y_t == pytest.approx(mean_Y_t, 0.1) 83 | 84 | expected_std_Y_t = 0.5 * np.sqrt(em._t_n) 85 | std_Y_t = np.std(Y[:, -1]) 86 | assert expected_std_Y_t == pytest.approx(std_Y_t, 0.1) 87 | -------------------------------------------------------------------------------- /src/euler_maruyama/parallel_euler_maruyama.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from joblib import Parallel, delayed 3 | 4 | from .coefficients import Coefficient 5 | from .euler_maruyama import EulerMaruyama 6 | 7 | 8 | class ParallelEulerMaruyama(EulerMaruyama): 9 | """Class to perform the numerical solution of a Stochastic Differential Equation (SDE) through the Euler-Maruyama method 10 | using parallel computation. 11 | 12 | Parameters 13 | ---------- 14 | t_0: float 15 | Initial time. 16 | 17 | t_n: float 18 | Final time. 19 | 20 | n_steps: int 21 | Number of time steps to discretise the time interval [t_0, t_n]. 22 | 23 | X_0: float 24 | Initial condition of the SDE. 25 | 26 | drift: Coefficient 27 | Drift (mu) coefficient of the SDE. 28 | 29 | diffusion: Coefficient 30 | Diffusion (sigma) coefficient of the SDE. 31 | 32 | n_sim: int 33 | Number of simulated approximations. 34 | 35 | n_jobs: int 36 | Number of batches to compute in parallel. 37 | 38 | Attributes 39 | ---------- 40 | _t_0: float 41 | Initial time. 42 | 43 | _t_n: float 44 | Final time. 45 | 46 | _n_steps: int 47 | Number of time steps to discretise the time interval [t_0, t_n]. 48 | 49 | _X_0: float 50 | Initial condition of the SDE. 51 | 52 | _drift: Coefficient 53 | Drift (mu) coefficient of the SDE. 54 | 55 | _diffusion: Coefficient 56 | Diffusion (sigma) coefficient of the SDE. 57 | 58 | _n_sim: int 59 | Number of simulated approximations. 60 | 61 | _n_jobs: int 62 | Number of batches to compute in parallel. 63 | 64 | delta: float 65 | Length of the time step. 66 | 67 | t: np.ndarray 68 | Array containing the time steps values, shape: (n_steps+1,). 69 | 70 | Y: np.ndarray 71 | Array containing the approximated solution of the SDE, shape: shape(n_sim, n_steps+1). 72 | 73 | Methods 74 | ------- 75 | compute_numerical_approximation 76 | """ 77 | 78 | def __init__( 79 | self, 80 | t_0: float, 81 | t_n: float, 82 | n_steps: int, 83 | X_0: float, 84 | drift: Coefficient, 85 | diffusion: Coefficient, 86 | n_sim: int, 87 | n_jobs: int, 88 | ): 89 | super().__init__( 90 | t_0=t_0, 91 | t_n=t_n, 92 | n_steps=n_steps, 93 | X_0=X_0, 94 | drift=drift, 95 | diffusion=diffusion, 96 | n_sim=n_sim, 97 | ) 98 | self._n_jobs = n_jobs 99 | 100 | @property 101 | def n_jobs(self): 102 | return self._n_jobs 103 | 104 | @n_jobs.setter 105 | def n_jobs(self, value: int): 106 | """Change the number of batches. 107 | 108 | Parameters 109 | ---------- 110 | value: int 111 | Number of batches. 112 | """ 113 | if value > 0: 114 | self._n_jobs = value 115 | else: 116 | raise ValueError("Number of batches must be positive.") 117 | 118 | def _num_sim_batch(self) -> list[int]: 119 | """Calculate the number of simulations within each batch for parallel computation. 120 | 121 | Returns 122 | ------- 123 | batches: list[int] 124 | List containing the number of simulations to include in each batch. 125 | """ 126 | batch_size = self._n_sim // self._n_jobs 127 | remainder = self._n_sim % self._n_jobs 128 | 129 | batches = [batch_size] * (self._n_jobs - remainder) + [ 130 | batch_size + 1 131 | ] * remainder 132 | 133 | return batches 134 | 135 | def compute_numerical_approximation(self) -> np.ndarray: 136 | """Compute the EM approximation for all simulated trajectories using parallel computing. 137 | 138 | Returns 139 | ------- 140 | Y: np.ndarray 141 | Array containing the approximated solution of the SDE, shape(n_sim, n_steps+1). 142 | """ 143 | Y_dim_batch_list = self._num_sim_batch() 144 | 145 | Y = Parallel(n_jobs=self._n_jobs)( 146 | delayed(self._solve_numerical_approximation)(dim=Y_dim) 147 | for Y_dim in Y_dim_batch_list 148 | ) 149 | 150 | self.Y = np.concatenate(Y, axis=0) 151 | 152 | return self.Y 153 | -------------------------------------------------------------------------------- /src/euler_maruyama/coefficients.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | 6 | 7 | class Coefficient(ABC): 8 | """Abstract class to define the internal structure of the drift and diffusion coefficients. 9 | 10 | Methods 11 | ------- 12 | get_value 13 | plot_X_sample 14 | plot_t_sample 15 | """ 16 | 17 | def __init__(self): 18 | pass 19 | 20 | @abstractmethod 21 | def get_value(self, X: np.ndarray, t: float) -> np.ndarray: 22 | raise NotImplementedError 23 | 24 | def plot_X_sample(self) -> None: 25 | """Plot the coefficient value for 100 samples of X between 0 and 10.""" 26 | result = np.zeros(100) 27 | x_array = np.linspace(0, 10, 100) 28 | for i, x in enumerate(x_array): 29 | result[i] = self.get_value(X=np.array(x), t=0) 30 | 31 | fig, ax = plt.subplots(figsize=(7, 5)) 32 | 33 | ax.plot(x_array, result) 34 | 35 | ax.set_xlabel(r"$X$") 36 | ax.set_ylabel(r"Coefficient value") 37 | 38 | plt.show() 39 | 40 | def plot_t_sample(self) -> None: 41 | """Plot the coefficient value for 100 samples of t between 0 and 1.""" 42 | result = np.zeros(100) 43 | t_array = np.linspace(0, 1, 100) 44 | for i, t in enumerate(t_array): 45 | result[i] = self.get_value(X=np.array(1.0), t=t) 46 | 47 | fig, ax = plt.subplots(figsize=(7, 5)) 48 | 49 | ax.plot(t_array, result) 50 | 51 | ax.set_xlabel(r"$t$") 52 | ax.set_ylabel(r"Coefficient value") 53 | 54 | plt.show() 55 | 56 | 57 | class LinearDrift(Coefficient): 58 | """Implement a linear drift of the form: 59 | 60 | mu(X_t, t) = a*t 61 | 62 | where a is a real value parameter. 63 | 64 | Parameters 65 | --------- 66 | a: float 67 | The linear coefficient of drift. 68 | 69 | Methods 70 | ------- 71 | get_value 72 | """ 73 | 74 | def __init__(self, a: float): 75 | super().__init__() 76 | self.a = a 77 | 78 | def get_value(self, X: np.ndarray, t: float) -> np.ndarray: 79 | """Compute the linear drift value as mu(X_t, t) = a*t. 80 | 81 | Parameters 82 | ---------- 83 | X: np.ndarray 84 | The X_t values, shape = (number of simulations, ) 85 | 86 | t: flotat 87 | The time value. 88 | 89 | Returns 90 | ------- 91 | np.ndarray 92 | The linear drift coefficient values, shape = (number of simulations, ) 93 | """ 94 | return np.ones_like(X) * self.a * t 95 | 96 | 97 | class MeanReversionDrift(Coefficient): 98 | """Implement a mean-reversion drift of the form: 99 | 100 | mu(X_t, t) = theta*(mean - X_t) 101 | 102 | where theta and mean are real value parameters. 103 | 104 | Parameters 105 | --------- 106 | theta: float 107 | The speed of reversion. 108 | 109 | mean: float 110 | The equilibrium value. 111 | 112 | Methods 113 | ------- 114 | get_value 115 | """ 116 | 117 | def __init__(self, theta: float, mean: float): 118 | super().__init__() 119 | self.theta = theta 120 | self.mean = mean 121 | 122 | def get_value(self, X: np.ndarray, t: float) -> np.ndarray: 123 | """Compute the mean-reversion drift value as mu(X_t, t) = theta*(mean - X_t). 124 | 125 | Parameters 126 | ---------- 127 | X: np.ndarray 128 | The X_t values, shape = (number of simulations, ) 129 | 130 | t: flotat 131 | The time value. 132 | 133 | Returns 134 | ------- 135 | np.ndarray 136 | The mean-reversion drift coefficient values, shape = (number of simulations, ) 137 | """ 138 | return self.theta * (self.mean - X) 139 | 140 | 141 | class ConstantDiffusion(Coefficient): 142 | """Implement a constant diffusion of the form: 143 | 144 | sigma(X_t, t) = b 145 | 146 | where b is a real value parameter. 147 | 148 | Parameters 149 | --------- 150 | b: float 151 | The constant diffusion value. 152 | 153 | Methods 154 | ------- 155 | get_value 156 | """ 157 | 158 | def __init__(self, b: float): 159 | super().__init__() 160 | self.b = b 161 | 162 | def get_value(self, X: np.ndarray, t: float) -> np.ndarray: 163 | """Compute the constant diffusion value as sigma(X_t, t) = b. 164 | 165 | Parameters 166 | ---------- 167 | X: np.ndarray 168 | The X_t values, shape = (number of simulations, ) 169 | 170 | t: flotat 171 | The time value. 172 | 173 | Returns 174 | ------- 175 | np.ndarray 176 | The constant diffusion coefficient values, shape = (number of simulations, ) 177 | """ 178 | return np.ones_like(X) * self.b 179 | 180 | 181 | class MultiplicativeNoiseDiffusion(Coefficient): 182 | """Implement a multiplicative noise-like diffusion of the form: 183 | 184 | sigma(X_t, t) = b*X_t 185 | 186 | where b is a real value parameter. 187 | 188 | Parameters 189 | --------- 190 | b: float 191 | The amplitude of the multiplicative noise. 192 | 193 | Methods 194 | ------- 195 | get_value 196 | """ 197 | 198 | def __init__(self, b: float): 199 | super().__init__() 200 | self.b = b 201 | 202 | def get_value(self, X: np.ndarray, t: float) -> np.ndarray: 203 | """Compute the multiplicative noise-like diffusion value as sigma(X_t, t) = b*X_t. 204 | 205 | Parameters 206 | ---------- 207 | X: np.ndarray 208 | The X_t values, shape = (number of simulations, ) 209 | 210 | t: flotat 211 | The time value. 212 | 213 | Returns 214 | ------- 215 | np.ndarray 216 | The multiplicative noise-like diffusion coefficient values, shape = (number of simulations, ) 217 | """ 218 | return self.b * X 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReCoDE project - Euler-Maruyama method 2 | 3 | ## Description 4 | 5 | This code is part of the **Re**search **Co**mputing and **D**ata Science **E**xamples (ReCoDE) projects. 6 | The project consists of a Python class containing the Euler-Maruyama (EM) method for the numerical solution 7 | of a Stochastic Differential Equation (SDE). SDEs describe the dynamics that govern the time-evolution of 8 | systems subjected to deterministic and random influences. They arise in fields such as biology, physics or 9 | finance to model variables exhibiting uncertain and fluctuating behaviour. Being able to numerical solve an SDE 10 | is essential for these fields, especially if there is no closed-form solution. This project provides an 11 | object-oriented implementation of the EM method. Throughout the project, it is emphasised the benefits that 12 | class encapsulation provides in terms of code modularity and re-usability. 13 | 14 | ## Learning Outcomes 15 | This project is designed for Master's and Ph.D. students with basic Python knowledge and need to solve SDEs 16 | for their research projects. After going through this project, students will: 17 | 18 | 1. Understand how to solve an SDE using the EM method. 19 | 2. Learn to encapsulate the EM method code into a Python class. 20 | 3. Explore how to parallelise the code to improve solution speed. 21 | 22 | 23 | ## Requirements 24 | 25 | ### System 26 | 27 | | Program | Version | 28 | |------------------------------------------------------------|---------| 29 | | [Git](https://git-scm.com/) | >= 2.41 | 30 | | [Python](https://www.python.org/downloads/) | >= 3.9 | 31 | 32 | ### Dependencies 33 | 34 | | Packages | Version | 35 | |--------------------------------------------------------|-----------| 36 | | [poetry](https://python-poetry.org/docs/) | >= 1.4.* | 37 | | [numpy](https://numpy.org/doc/stable/) | >= 1.24.* | 38 | | [matplotlib](https://matplotlib.org/stable/index.html) | >= 3.7.* | 39 | | [jupyter](http://jupyter.org/install) | >= 1.0.* | 40 | | [joblib](https://joblib.readthedocs.io/en/stable/) | >= 1.2.* | 41 | 42 | ## Project Structure 43 | ```bash 44 | . 45 | ├── .github/workflows 46 | │ └── tests_workflow.yaml 47 | ├── docs 48 | │ ├── 1-Introduction.ipynb 49 | │ ├── 2-Probability-Distributions.ipynb 50 | │ ├── 3-Euler-Maruyama-Method.ipynb 51 | │ ├── 4-Euler-Maruyama-Class.ipynb 52 | │ └── 5-Parallel-Euler-Maruyama-Class.ipynb 53 | ├── src 54 | │ └── euler_maruyama 55 | │ ├── __init__.py 56 | │ ├── coefficients.py 57 | │ ├── euler_maruyama.py 58 | │ └── parallel_euler_maruyama.py 59 | ├── tests 60 | │ ├── test_coefficient.py 61 | │ ├── test_euler_maruyama.py 62 | │ └── test_parallel_euler_maruyama.py 63 | ├── .gitignore 64 | ├── poetry.lock 65 | ├── poetry.toml 66 | ├── pyproject.toml 67 | ├── README.md 68 | └── requirements.txt 69 | ``` 70 | 71 | ## Getting Started 72 | 73 | You can read the Jupyter notebooks non-interactively on Github. Click [here](https://github.com/ImperialCollegeLondon/ReCoDe_Euler_Maruyama/tree/main/docs) 74 | to view the collection of Jupyter notebooks located in the ``docs`` folder. However, for an improved experience, we suggest cloning the Github repository and running the Jupyter notebooks on your local 75 | machine. To assist you setting up the project locally, we provide a list of steps: 76 | 77 | ### 1. Clone the repository 78 | 79 | After installing `git` in your local machine, you can run the following command in a terminal: 80 | 81 | ```bash 82 | git clone https://github.com/ImperialCollegeLondon/ReCoDe_Euler_Maruyama.git euler-maruyama 83 | cd euler-maruyama 84 | ``` 85 | 86 | ### 2. Install poetry 87 | 88 | Once you have downloaded a `Python` version, you need to install `poetry`. 89 | `Poetry` is a dependency management and packaging tool for `Python` projects that simplifies the process of managing dependencies and distributing packages. 90 | It allows you to define project dependencies in a `pyproject.toml` file and provides commands to install, update, and remove dependencies. 91 | The main advantages of `poetry` include dependency resolution to ensure consistent environments, the management of virtual environments for isolation and simplified package publishing. 92 | It streamlines the development workflow and facilitates collaboration by providing a unified and straightforward approach to managing dependencies in `Python` projects. 93 | You can find more information in its [documentation](https://python-poetry.org/). 94 | Our main focus here is to use `poetry` to install the project and their dependencies locally. 95 | 96 | ```bash 97 | pip install poetry 98 | ``` 99 | 100 | You can check that `poetry` has been successfully installed by running: 101 | 102 | ```bash 103 | poetry --version 104 | Poetry (version 1.4.0) 105 | ``` 106 | 107 | 108 | ### 3. Install the project 109 | 110 | Now, we need to install the project and its requirements. You can run the following command in the folder 111 | where you downloaded the Github repository: 112 | 113 | ```bash 114 | poetry install 115 | ``` 116 | 117 | This command creates a virtual environment in the same folder you are working, as specified by the 118 | `poetry.toml` configuration file of the project. Then, the packages requirements are installed and 119 | finally, the project is installed locally with the name `euler-maruyama` version (0.1.0). 120 | 121 | ### 4. Activate the local environment 122 | 123 | Run the following command to activate the local environment you created in the previous step: 124 | 125 | ```bash 126 | poetry shell 127 | ``` 128 | 129 | ### 5. Launch the Jupyter notebooks 130 | 131 | You can run this command to launch the Jupyter notebook: 132 | 133 | ```bash 134 | jupyter notebook 135 | ``` 136 | 137 | Now, you can explore and experiment with the different notebook examples we have prepared to help you 138 | understand this project. 139 | 140 | ### 6. Close the environment 141 | 142 | If you have closed the Jupyter notebooks and want to exit from the local environment, just run: 143 | 144 | ```bash 145 | exit 146 | ``` 147 | -------------------------------------------------------------------------------- /src/euler_maruyama/euler_maruyama.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .coefficients import Coefficient 4 | 5 | 6 | class EulerMaruyama: 7 | """Class to perform the numerical solution of a Stochastic Differential Equation (SDE) through the Euler-Maruyama method. 8 | 9 | Considering a SDE of the form: dX_t = mu(X_t, t)dt + sigma(X_t, t)dW_t, the solution of this SDE over 10 | the time interval [t_0, t_n] can be approximated as follows: 11 | 12 | Y_{n+1} = Y_n + mu(Y_n, tau_n)(tau_{n+1} - tau_n) + sigma(Y_n, tau_n)(W_{tau_{n+1}} - W_{tau_n}) 13 | 14 | with initial condition Y_0 = X_0 and where the time interval is discretised: 15 | 16 | t_0 = tau_0 < tau_1 < ... < tau_n = t_n 17 | 18 | with Delta_t = tau_{n+1} - tau_n = (t_n - t_0) / n and DeltaW_n = (W_{tau_{n+1}} - W_{tau_n}) ~ N(0, Delta_t) 19 | because W_t is a Wiener process, so-called Brownian motion. 20 | 21 | Parameters 22 | ---------- 23 | t_0: float 24 | Initial time. 25 | 26 | t_n: float 27 | Final time. 28 | 29 | n_steps: int 30 | Number of time steps to discretise the time interval [t_0, t_n]. 31 | 32 | X_0: float 33 | Initial condition of the SDE. 34 | 35 | drift: Coefficient 36 | Drift (mu) coefficient of the SDE. 37 | 38 | diffusion: Coefficient 39 | Diffusion (sigma) coefficient of the SDE. 40 | 41 | n_sim: int 42 | Number of simulated approximations. 43 | 44 | Attributes 45 | ---------- 46 | _t_0: float 47 | Initial time. 48 | 49 | _t_n: float 50 | Final time. 51 | 52 | _n_steps: int 53 | Number of time steps to discretise the time interval [t_0, t_n]. 54 | 55 | _X_0: float 56 | Initial condition of the SDE. 57 | 58 | _drift: Coefficient 59 | Drift (mu) coefficient of the SDE. 60 | 61 | _diffusion: Coefficient 62 | Diffusion (sigma) coefficient of the SDE. 63 | 64 | _n_sim: int 65 | Number of simulated approximations. 66 | 67 | delta: float 68 | Length of the time step. 69 | 70 | t: np.ndarray 71 | Array containing the time steps values, shape: (n_steps+1,). 72 | 73 | Y: np.ndarray 74 | Array containing the approximated solution of the SDE, shape: shape(n_sim, n_steps+1). 75 | 76 | Methods 77 | ------- 78 | compute_numerical_approximation 79 | """ 80 | 81 | def __init__( 82 | self, 83 | t_0: float, 84 | t_n: float, 85 | n_steps: int, 86 | X_0: float, 87 | drift: Coefficient, 88 | diffusion: Coefficient, 89 | n_sim: int, 90 | ): 91 | 92 | self._t_0 = t_0 93 | self._t_n = t_n 94 | self._n_steps = n_steps 95 | 96 | self._X_0 = X_0 97 | 98 | self._drift = drift 99 | self._diffusion = diffusion 100 | 101 | self._n_sim = n_sim 102 | 103 | self.Y = None 104 | self._compute_discretisation() 105 | 106 | @property 107 | def n_sim(self): 108 | return self._n_sim 109 | 110 | @n_sim.setter 111 | def n_sim(self, value: int): 112 | """Change the number of simulations. 113 | 114 | Parameters 115 | ---------- 116 | value: int 117 | Number of simulations. 118 | """ 119 | if value > 0: 120 | self._n_sim = value 121 | else: 122 | raise ValueError("Number of simulations must be positive.") 123 | 124 | @property 125 | def n_steps(self): 126 | return self._n_steps 127 | 128 | @n_steps.setter 129 | def n_steps(self, value: int): 130 | """Change the number of time steps attribute and recalculate the discretisation. 131 | 132 | Parameters 133 | ---------- 134 | value: int 135 | Number of time steps. 136 | """ 137 | if value > 0: 138 | self._n_steps = value 139 | self._compute_discretisation() 140 | else: 141 | raise ValueError("Number of steps must be positive.") 142 | 143 | def _compute_discretisation(self) -> None: 144 | """Calculate time steps and time delta.""" 145 | self.t, self.delta = np.linspace( 146 | self._t_0, self._t_n, self._n_steps + 1, retstep=True 147 | ) 148 | 149 | def _allocate_Y(self, dim: int) -> np.ndarray: 150 | """Allocate an array for the approximated solution. 151 | 152 | Parameters 153 | ---------- 154 | dim: int 155 | Number of simulations, dimension 0 of Y. 156 | 157 | Returns 158 | ------- 159 | Y: np.ndarray 160 | Array for the approximated solution. 161 | """ 162 | Y = np.zeros((dim, self._n_steps + 1), dtype=float) 163 | Y[:, 0] = self._X_0 * np.ones(dim) 164 | return Y 165 | 166 | def _solve_numerical_approximation(self, dim: int) -> np.ndarray: 167 | """Solve the EM approximation for the given number of simulated trajectories. 168 | 169 | Parameters 170 | ---------- 171 | dim: int 172 | The number of simulations, dimension 0 of Y. 173 | 174 | Returns 175 | ------- 176 | Y: np.ndarray 177 | Array containing the approximated solution of the SDE, shape(n_sim, n_steps+1). 178 | """ 179 | Y = self._allocate_Y(dim=dim) 180 | for n in range(self._n_steps + 1)[:-1]: 181 | tau_n = self.t[n] 182 | Y_n = Y[:, n] 183 | 184 | mu = self._drift.get_value(X=Y_n, t=tau_n) 185 | sigma = self._diffusion.get_value(X=Y_n, t=tau_n) 186 | 187 | dW = np.random.normal(loc=0, scale=np.sqrt(self.delta), size=dim) 188 | 189 | # Compute next step of the EM scheme 190 | Y[:, n + 1] = Y_n + mu * self.delta + sigma * dW 191 | 192 | return Y 193 | 194 | def compute_numerical_approximation(self) -> np.ndarray: 195 | """Compute the EM approximation for all simulated trajectories. 196 | 197 | Returns 198 | ------- 199 | Y: np.ndarray 200 | Array containing the approximated solution of the SDE, shape(n_sim, n_steps+1). 201 | """ 202 | self.Y = self._solve_numerical_approximation(dim=self._n_sim) 203 | return self.Y 204 | -------------------------------------------------------------------------------- /docs/.icons/logos/iclogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 622 | -------------------------------------------------------------------------------- /docs/5-Parallel-Euler-Maruyama-Class.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "cf534b09", 6 | "metadata": {}, 7 | "source": [ 8 | "# 5 - Parallel Euler-Maruyama class\n", 9 | "\n", 10 | "In our previous explanations, we covered the process of solving a Stochastic Differential Equation (SDE) using the Euler-Maruyama method. Moreover, we learnt to encapsulate the logic of the EM method into a Python class, the `EulerMaruyama`. This class implementation provides code reusability and modularity, making the `EulerMaruyama` class a versatile numerical tool for solving user-defined SDEs. It also allows us to analyse the performance of the EM method by modifying input parameters such as the number of time steps or the number of simulations.\n", 11 | "\n", 12 | "The aim of this notebook is to improve the performance of the already implemented `EulerMaruyama` class. To achieve this, we will design a child class of `EulerMaruyama` class that provides parallel computing to optimise the numerical solution of the EM method. This new class introduces the concept of parallel computation and highlights the advantages of object-oriented programming, refreshing the concept of inheritance." 13 | ] 14 | }, 15 | { 16 | "cell_type": "markdown", 17 | "id": "821ac275", 18 | "metadata": {}, 19 | "source": [ 20 | "## Contents\n", 21 | "### [A. Parallel computation: the joblib package](#joblib)\n", 22 | "### [B. The Parallel Euler-Maruyama class](#parallel-em-class)" 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "id": "85003ff3", 28 | "metadata": {}, 29 | "source": [ 30 | "\n", 31 | "### A. Parallel computation: the joblib package\n", 32 | "\n", 33 | "Parallel computing refers to the technique of executing several computational tasks simultaneously, dividing the required workload across multiple cores. The presence of multiple cores in modern laptops, and the availability of High Performance Computing (HPC) resources (such as [those at Imperial](https://www.imperial.ac.uk/computational-methods/hpc/)) enables parallel processing, where different cores can handle separate tasks. By relying on this core multiplicity, parallel computation can significantly speed up computations that can be divided into independent parts. By harnessing the power of parallel processing, parallel computing enables faster execution times, improved performance, and the ability to handle larger and more complex computations. To learn more about parallel computing in Python, you can refer to the following link: [Python Concurrency - What is Parallelism?](https://realpython.com/python-concurrency/#what-is-parallelism). Please note that the linked resource covers additional topics beyond the scope of this project.\n", 34 | "\n", 35 | "With the following code, you can check the number of CPU cores in your local machine:" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": 1, 41 | "id": "59c6dc46", 42 | "metadata": {}, 43 | "outputs": [ 44 | { 45 | "name": "stdout", 46 | "output_type": "stream", 47 | "text": [ 48 | "Number of CPU cores: 10\n" 49 | ] 50 | } 51 | ], 52 | "source": [ 53 | "import joblib\n", 54 | "\n", 55 | "num_cores = joblib.cpu_count()\n", 56 | "print(\"Number of CPU cores:\", num_cores)" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "id": "66fbd222", 62 | "metadata": {}, 63 | "source": [ 64 | "Here, we have introduced the Python `joblib` package, which simplifies and automates parallel computing tasks. It provides easy-to-use functions and utilities for distributing tasks across multiple cores, without requiring intricate low-level coding. `joblib` offers a high-level interface for parallel computing, making it accessible and convenient for users who want to leverage the power of parallel processing without dealing with the complexities of threading or multiprocessing in Python. You can check the `joblib` documentation in this [link](https://joblib.readthedocs.io/en/stable/).\n", 65 | "\n", 66 | "\n", 67 | "To illustrate the advantages of parallel computing, we show a simple example using the `joblib` package. Through this example, we demonstrate how parallel processing can significantly reduce computation time yielding an equivalent result." 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": 2, 73 | "id": "d888dc3c", 74 | "metadata": {}, 75 | "outputs": [], 76 | "source": [ 77 | "# Import required functions from joblib\n", 78 | "from joblib import Parallel, delayed\n", 79 | "import time\n", 80 | "\n", 81 | "def process_data(data):\n", 82 | " time.sleep(1) # this line waits for 1s to simulate the behaviour of a time-consuming task \n", 83 | " result = data * 2\n", 84 | " return result" 85 | ] 86 | }, 87 | { 88 | "cell_type": "code", 89 | "execution_count": 3, 90 | "id": "08b7e7df", 91 | "metadata": {}, 92 | "outputs": [], 93 | "source": [ 94 | "# Compute a sequential processing: all calculations are done in 1 single core\n", 95 | "start_time = time.time()\n", 96 | "results_seq = [process_data(data) for data in range(10)]\n", 97 | "end_time = time.time()\n", 98 | "seq_time = end_time - start_time" 99 | ] 100 | }, 101 | { 102 | "cell_type": "code", 103 | "execution_count": 4, 104 | "id": "d858535b", 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "# Compute a parallel processing: all calculations are split into 4 cores. \n", 109 | "start_time = time.time()\n", 110 | "# This is the joblib implementation to send all elements of the for loop into distinct cores\n", 111 | "# The cores are specified with the n_job parameter. Here, we specify 4 cores.\n", 112 | "results_parallel = Parallel(n_jobs=4)(delayed(process_data)(data) for data in range(10))\n", 113 | "end_time = time.time()\n", 114 | "parallel_time = end_time - start_time" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 5, 120 | "id": "2c52a2ba", 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "name": "stdout", 125 | "output_type": "stream", 126 | "text": [ 127 | "Sequential Results: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] in 10.03 s\n", 128 | "Parallel Results: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] in 3.56 s\n" 129 | ] 130 | } 131 | ], 132 | "source": [ 133 | "# Check results\n", 134 | "print(f\"Sequential Results: {results_seq} in {seq_time:.2f} s\" )\n", 135 | "print(f\"Parallel Results: {results_parallel} in {parallel_time:.2f} s\")" 136 | ] 137 | }, 138 | { 139 | "cell_type": "markdown", 140 | "id": "4993f80a", 141 | "metadata": {}, 142 | "source": [ 143 | "By using 4 cores, we reduce 50% of the computation time! Now, let us try a different numbers of `n_jobs` values to see the effect in the computation time." 144 | ] 145 | }, 146 | { 147 | "cell_type": "code", 148 | "execution_count": 6, 149 | "id": "57b9bc16", 150 | "metadata": {}, 151 | "outputs": [ 152 | { 153 | "name": "stdout", 154 | "output_type": "stream", 155 | "text": [ 156 | "Parallel Results with n_jobs=1: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] in 10.05 s\n" 157 | ] 158 | } 159 | ], 160 | "source": [ 161 | "# n_jobs=1 means no parallel computation\n", 162 | "start_time = time.time()\n", 163 | "results_parallel = Parallel(n_jobs=1)(delayed(process_data)(data) for data in range(10))\n", 164 | "end_time = time.time()\n", 165 | "parallel_time = end_time - start_time\n", 166 | "\n", 167 | "print(f\"Parallel Results with n_jobs=1: {results_parallel} in {parallel_time:.2f} s\")" 168 | ] 169 | }, 170 | { 171 | "cell_type": "code", 172 | "execution_count": 7, 173 | "id": "b8e5526e", 174 | "metadata": {}, 175 | "outputs": [ 176 | { 177 | "name": "stdout", 178 | "output_type": "stream", 179 | "text": [ 180 | "Parallel Results with n_jobs=2: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] in 5.54 s\n" 181 | ] 182 | } 183 | ], 184 | "source": [ 185 | "# 2 cores \n", 186 | "start_time = time.time()\n", 187 | "results_parallel = Parallel(n_jobs=2)(delayed(process_data)(data) for data in range(10))\n", 188 | "end_time = time.time()\n", 189 | "parallel_time = end_time - start_time\n", 190 | "\n", 191 | "print(f\"Parallel Results with n_jobs=2: {results_parallel} in {parallel_time:.2f} s\")" 192 | ] 193 | }, 194 | { 195 | "cell_type": "code", 196 | "execution_count": 8, 197 | "id": "bcd3561d", 198 | "metadata": {}, 199 | "outputs": [ 200 | { 201 | "name": "stdout", 202 | "output_type": "stream", 203 | "text": [ 204 | "Parallel Results with n_jobs=6: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] in 2.61 s\n" 205 | ] 206 | } 207 | ], 208 | "source": [ 209 | "# 6 cores\n", 210 | "start_time = time.time()\n", 211 | "results_parallel = Parallel(n_jobs=6)(delayed(process_data)(data) for data in range(10))\n", 212 | "end_time = time.time()\n", 213 | "parallel_time = end_time - start_time\n", 214 | "\n", 215 | "print(f\"Parallel Results with n_jobs=6: {results_parallel} in {parallel_time:.2f} s\")" 216 | ] 217 | }, 218 | { 219 | "cell_type": "code", 220 | "execution_count": 9, 221 | "id": "8a43beeb", 222 | "metadata": {}, 223 | "outputs": [ 224 | { 225 | "name": "stdout", 226 | "output_type": "stream", 227 | "text": [ 228 | "Parallel Results with n_jobs=10: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] in 1.51 s\n" 229 | ] 230 | } 231 | ], 232 | "source": [ 233 | "# 10 cores\n", 234 | "# Note that if your laptop has less cores, setting this to 10 would be equivalent to setting maximum number of cores\n", 235 | "start_time = time.time()\n", 236 | "results_parallel = Parallel(n_jobs=10)(delayed(process_data)(data) for data in range(10))\n", 237 | "end_time = time.time()\n", 238 | "parallel_time = end_time - start_time\n", 239 | "\n", 240 | "print(f\"Parallel Results with n_jobs=10: {results_parallel} in {parallel_time:.2f} s\")" 241 | ] 242 | }, 243 | { 244 | "cell_type": "markdown", 245 | "id": "a75adce1", 246 | "metadata": {}, 247 | "source": [ 248 | "`n_jobs=10` yields the minimum time elapsed in this particular example. This result is specific of this simple example, since we can allocate each element of the for loop into a different core. This ensures that the workload is evenly distributed across the cores, processing `process_data` only once in each core. The optimal value of `n_jobs` will depend on the specific task at hand." 249 | ] 250 | }, 251 | { 252 | "cell_type": "markdown", 253 | "id": "fc2baed4", 254 | "metadata": {}, 255 | "source": [ 256 | "\n", 257 | "### B. The Parallel Euler-Maruyama class\n", 258 | "\n", 259 | "Once we have explained parallel computing and how to use the `joblib` package, we introduce a new class `ParallelEulerMaruyama` in the `src/euler_maruyama/parallel_euler_maruyama.py` file. This class replicates the functionality of the `EulerMaruyama` class and provides parallel computation of the numerical approximation. We achieve such functionality relying on class inheritance. Therefore, `ParallelEulerMaruyama` is a child class of `EulerMaruyama`:\n", 260 | "\n", 261 | "```python\n", 262 | "from .euler_maruyama import EulerMaruyama\n", 263 | "\n", 264 | "class ParallelEulerMaruyama(EulerMaruyama):\n", 265 | "```\n", 266 | "\n", 267 | "By doing so, `ParallelEulerMaruyama` has the same attributes and methods that `EulerMaruyama`. Now, our aim is to modify the `compute_numerical_approximation` method to include the parallel computing. If we define a new method called `compute_numerical_approximation` in the `ParallelEulerMaruyama` class, we are overriding this method. Overriding a method redefines the implementation of the parent class. When a method if overridden, the child class provides a different implementation of the method, replacing the logic inherited from the parent class. By overriding a method, we can redefine the behavior of a particular method for a specific child class, allowing it to have its own unique functionality while still inheriting other characteristics from the parent class.\n", 268 | "\n", 269 | "```python\n", 270 | "from .euler_maruyama import EulerMaruyama\n", 271 | "\n", 272 | "class ParallelEulerMaruyama(EulerMaruyama):\n", 273 | "\n", 274 | " # __init__ and some code ...\n", 275 | "\n", 276 | " def compute_numerical_approximation(self) -> np.ndarray:\n", 277 | " # a distinct implementation from compute_numerical_approximation of EulerMaruyama\n", 278 | "```\n", 279 | "\n", 280 | "The `EulerMaruyama` class performs the numerical solution for all simulated trajectories simultaneously. By using `numpy` vectorisation, the method `_solve_numerical_approximation` efficiently updates the numerical approximation across all time steps for the specified number (`dim`) of simulated trajectories. This is precisely the reason of implementing the method `compute_numerical_approximation` in `EulerMaruyama` class as follows:\n", 281 | "\n", 282 | "```python\n", 283 | "def compute_numerical_approximation(self) -> np.ndarray:\n", 284 | " self.Y = self._solve_numerical_approximation(dim=self._n_sim) # we are solving for all _n_sim simulations \n", 285 | " return self.Y\n", 286 | "```\n", 287 | "\n", 288 | "On the other hand, we would like to split all specified simulations `_n_sim` into different batches. and compute each batch in a different core. To achieve this, we use the parallel computing approach we have commented in the previous section. Therefore, we require to define a number of batches and split the number of simulations `_n_sim` into different batches. The number of batches is specified in the class constructor with `n_jobs`, which follows a property pattern. The `_num_sim_bat` method then calculates the number of simulations that should be in each batch:\n", 289 | "\n", 290 | "```python\n", 291 | "def _num_sim_batch(self) -> list[int]:\n", 292 | " batch_size = self._n_sim // self._n_jobs\n", 293 | " remainder = self._n_sim % self._n_jobs\n", 294 | "\n", 295 | " batches = [batch_size] * (self._n_jobs - remainder) + [batch_size + 1] * remainder\n", 296 | "\n", 297 | " return batches\n", 298 | "```\n", 299 | "\n", 300 | "For example, for `_n_sim=1234` and `_n_jobs=3`, `_num_sim_batch` returns: `[411, 411, 412]`. We can use the result of `_num_sim_batch` inside the `compute_numerical_approximation` of `ParallelEulerMaruyama`:\n", 301 | "\n", 302 | "```python\n", 303 | "def compute_numerical_approximation(self) -> np.ndarray:\n", 304 | " Y_dim_batch_list = self._num_sim_batch()\n", 305 | "\n", 306 | " Y = Parallel(n_jobs=self._n_jobs)(\n", 307 | " delayed(self._solve_numerical_approximation)(dim=Y_dim) for Y_dim in Y_dim_batch_list\n", 308 | " )\n", 309 | "\n", 310 | " self.Y = np.concatenate(Y, axis=0)\n", 311 | "\n", 312 | " return self.Y\n", 313 | "```\n", 314 | "\n", 315 | "We have our parallel computation of the EM method! We will solve a particular SDE with different parameters to see the advantage of using parallel processing. " 316 | ] 317 | }, 318 | { 319 | "cell_type": "code", 320 | "execution_count": 10, 321 | "id": "7ea39e06", 322 | "metadata": {}, 323 | "outputs": [], 324 | "source": [ 325 | "import sys\n", 326 | " \n", 327 | "# we include outside folders in path\n", 328 | "sys.path.append('..')\n", 329 | "\n", 330 | "from src.euler_maruyama import ConstantDiffusion, EulerMaruyama, LinearDrift, ParallelEulerMaruyama" 331 | ] 332 | }, 333 | { 334 | "cell_type": "code", 335 | "execution_count": 11, 336 | "id": "74eccf81", 337 | "metadata": {}, 338 | "outputs": [], 339 | "source": [ 340 | "linear_drift = LinearDrift(a=1) # drift (mu) = 1*t\n", 341 | "constant_diffusion = ConstantDiffusion(b=2.5) # diffusion (sigma) = 2.5 " 342 | ] 343 | }, 344 | { 345 | "cell_type": "markdown", 346 | "id": "d85afd8b", 347 | "metadata": {}, 348 | "source": [ 349 | "We start with the simple example of `n_steps=100` and `n_sim=1_000`." 350 | ] 351 | }, 352 | { 353 | "cell_type": "code", 354 | "execution_count": 12, 355 | "id": "26aea349", 356 | "metadata": {}, 357 | "outputs": [], 358 | "source": [ 359 | "em = EulerMaruyama(t_0=0, \n", 360 | " t_n=2, \n", 361 | " n_steps=100, \n", 362 | " X_0=1, \n", 363 | " drift=linear_drift, \n", 364 | " diffusion=constant_diffusion, \n", 365 | " n_sim=1_000)" 366 | ] 367 | }, 368 | { 369 | "cell_type": "code", 370 | "execution_count": 13, 371 | "id": "7d5838f8", 372 | "metadata": {}, 373 | "outputs": [], 374 | "source": [ 375 | "start_time = time.time()\n", 376 | "results_seq_em = em.compute_numerical_approximation()\n", 377 | "end_time = time.time()\n", 378 | "seq_em_time = end_time - start_time" 379 | ] 380 | }, 381 | { 382 | "cell_type": "markdown", 383 | "id": "3761d20b", 384 | "metadata": {}, 385 | "source": [ 386 | "And we try with `n_jobs=2`" 387 | ] 388 | }, 389 | { 390 | "cell_type": "code", 391 | "execution_count": 14, 392 | "id": "4d3093b5", 393 | "metadata": {}, 394 | "outputs": [], 395 | "source": [ 396 | "parallel_em = ParallelEulerMaruyama(t_0=0, \n", 397 | " t_n=2, \n", 398 | " n_steps=100, \n", 399 | " X_0=1, \n", 400 | " drift=linear_drift, \n", 401 | " diffusion=constant_diffusion, \n", 402 | " n_sim=1_000, \n", 403 | " n_jobs=2)" 404 | ] 405 | }, 406 | { 407 | "cell_type": "code", 408 | "execution_count": 15, 409 | "id": "f7d92a2d", 410 | "metadata": {}, 411 | "outputs": [], 412 | "source": [ 413 | "start_time = time.time()\n", 414 | "results_parallel_em = parallel_em.compute_numerical_approximation()\n", 415 | "end_time = time.time()\n", 416 | "parallel_em_time = end_time - start_time" 417 | ] 418 | }, 419 | { 420 | "cell_type": "code", 421 | "execution_count": 16, 422 | "id": "2b44d004", 423 | "metadata": {}, 424 | "outputs": [ 425 | { 426 | "name": "stdout", 427 | "output_type": "stream", 428 | "text": [ 429 | "Time elapsed in EulerMaruyama class: 0.02 s\n", 430 | "Time elapsed in ParallelEulerMaruyama class with n_jobs=2: 0.82 s\n" 431 | ] 432 | } 433 | ], 434 | "source": [ 435 | "print(f\"Time elapsed in EulerMaruyama class: {seq_em_time:.2f} s\")\n", 436 | "print(f\"Time elapsed in ParallelEulerMaruyama class with n_jobs={parallel_em.n_jobs}: {parallel_em_time:.2f} s\")" 437 | ] 438 | }, 439 | { 440 | "cell_type": "markdown", 441 | "id": "a904a086", 442 | "metadata": {}, 443 | "source": [ 444 | "It seems that for this simple and reduced example `numpy` vectorisation is faster than parallel processing. This is because setting up the parallel processing run with `joblib` and the recombination of the arrays with `numpy.concatenate` introduces extra work, which slows down the code. For relatively small tasks this more than offsets the gains made in the parallelisation of the task.\n", 445 | "\n", 446 | "Let us try another number of `n_jobs`:" 447 | ] 448 | }, 449 | { 450 | "cell_type": "code", 451 | "execution_count": 17, 452 | "id": "53579c62", 453 | "metadata": {}, 454 | "outputs": [ 455 | { 456 | "name": "stdout", 457 | "output_type": "stream", 458 | "text": [ 459 | "Time elapsed in ParallelEulerMaruyama class with n_jobs=5: 0.90 s\n" 460 | ] 461 | } 462 | ], 463 | "source": [ 464 | "parallel_em.n_jobs = 5\n", 465 | "start_time = time.time()\n", 466 | "results_parallel_em = parallel_em.compute_numerical_approximation()\n", 467 | "end_time = time.time()\n", 468 | "parallel_em_time = end_time - start_time\n", 469 | "print(f\"Time elapsed in ParallelEulerMaruyama class with n_jobs={parallel_em.n_jobs}: {parallel_em_time:.2f} s\")" 470 | ] 471 | }, 472 | { 473 | "cell_type": "code", 474 | "execution_count": 18, 475 | "id": "88ac7fa0", 476 | "metadata": {}, 477 | "outputs": [ 478 | { 479 | "name": "stdout", 480 | "output_type": "stream", 481 | "text": [ 482 | "Time elapsed in ParallelEulerMaruyama class with n_jobs=10: 1.22 s\n" 483 | ] 484 | } 485 | ], 486 | "source": [ 487 | "parallel_em.n_jobs = 10\n", 488 | "start_time = time.time()\n", 489 | "results_parallel_em = parallel_em.compute_numerical_approximation()\n", 490 | "end_time = time.time()\n", 491 | "parallel_em_time = end_time - start_time\n", 492 | "print(f\"Time elapsed in ParallelEulerMaruyama class with n_jobs={parallel_em.n_jobs}: {parallel_em_time:.2f} s\")" 493 | ] 494 | }, 495 | { 496 | "cell_type": "markdown", 497 | "id": "eb12de46", 498 | "metadata": {}, 499 | "source": [ 500 | "No, any value of `n_jobs` improves the performance of the `ParallelEulerMaruyama` over the sequential-style `EulerMaruyama` class. The reason is that the number of steps and number of simulations is such small, that it takes more time to prepare the parallel processing than processing everything into a single core.\n", 501 | "\n", 502 | "Now, let us try to increase the number of simulations to `n_sim=1_000_000`:" 503 | ] 504 | }, 505 | { 506 | "cell_type": "code", 507 | "execution_count": 19, 508 | "id": "b540af61", 509 | "metadata": {}, 510 | "outputs": [], 511 | "source": [ 512 | "em = EulerMaruyama(t_0=0, \n", 513 | " t_n=2, \n", 514 | " n_steps=100, \n", 515 | " X_0=1, \n", 516 | " drift=linear_drift, \n", 517 | " diffusion=constant_diffusion, \n", 518 | " n_sim=1_000_000)" 519 | ] 520 | }, 521 | { 522 | "cell_type": "code", 523 | "execution_count": 20, 524 | "id": "6fd1b824", 525 | "metadata": {}, 526 | "outputs": [], 527 | "source": [ 528 | "start_time = time.time()\n", 529 | "results_seq_em = em.compute_numerical_approximation()\n", 530 | "end_time = time.time()\n", 531 | "seq_em_time = end_time - start_time" 532 | ] 533 | }, 534 | { 535 | "cell_type": "code", 536 | "execution_count": 21, 537 | "id": "7b1ff8ae", 538 | "metadata": {}, 539 | "outputs": [], 540 | "source": [ 541 | "parallel_em = ParallelEulerMaruyama(t_0=0, \n", 542 | " t_n=2, \n", 543 | " n_steps=100, \n", 544 | " X_0=1, \n", 545 | " drift=linear_drift, \n", 546 | " diffusion=constant_diffusion, \n", 547 | " n_sim=1_000_000, \n", 548 | " n_jobs=2)" 549 | ] 550 | }, 551 | { 552 | "cell_type": "code", 553 | "execution_count": 22, 554 | "id": "bcf6bfe1", 555 | "metadata": {}, 556 | "outputs": [], 557 | "source": [ 558 | "start_time = time.time()\n", 559 | "results_parallel_em = parallel_em.compute_numerical_approximation()\n", 560 | "end_time = time.time()\n", 561 | "parallel_em_time = end_time - start_time" 562 | ] 563 | }, 564 | { 565 | "cell_type": "code", 566 | "execution_count": 23, 567 | "id": "9d41ec01", 568 | "metadata": {}, 569 | "outputs": [ 570 | { 571 | "name": "stdout", 572 | "output_type": "stream", 573 | "text": [ 574 | "Time elapsed in EulerMaruyama class: 3.96 s\n", 575 | "Time elapsed in ParallelEulerMaruyama class with n_jobs=2: 4.33 s\n" 576 | ] 577 | } 578 | ], 579 | "source": [ 580 | "print(f\"Time elapsed in EulerMaruyama class: {seq_em_time:.2f} s\")\n", 581 | "print(f\"Time elapsed in ParallelEulerMaruyama class with n_jobs={parallel_em.n_jobs}: {parallel_em_time:.2f} s\")" 582 | ] 583 | }, 584 | { 585 | "cell_type": "markdown", 586 | "id": "2ff56e78", 587 | "metadata": {}, 588 | "source": [ 589 | "Still not working... Let us try all possible values of `n_jobs` from 1 (sequential processing) to 10 and plot the result:" 590 | ] 591 | }, 592 | { 593 | "cell_type": "code", 594 | "execution_count": 26, 595 | "id": "1dda2d42", 596 | "metadata": {}, 597 | "outputs": [], 598 | "source": [ 599 | "parallel_results = []\n", 600 | "n_jobs_values = range(1, 11)\n", 601 | "for i in n_jobs_values:\n", 602 | " parallel_em.n_jobs = i\n", 603 | " \n", 604 | " start_time = time.time()\n", 605 | " results_parallel_em = parallel_em.compute_numerical_approximation()\n", 606 | " end_time = time.time()\n", 607 | " parallel_em_time = end_time - start_time\n", 608 | " \n", 609 | " parallel_results.append(parallel_em_time)" 610 | ] 611 | }, 612 | { 613 | "cell_type": "code", 614 | "execution_count": 27, 615 | "id": "65dc2da0", 616 | "metadata": {}, 617 | "outputs": [ 618 | { 619 | "data": { 620 | "image/png": "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Yuazzz7TpUuX9H//93/6+++/9dprr6lnz55q166dVq9erWeeeUYHDhzQO++8o5EjR9r8j27OnDmKjo5WVFSUXn31VZ0/f17Tpk1Ty5YttW3btlw/hNx5550aPny43n77bT333HOqVauWJFn/HTt2rMaNG6f27dvriSee0N69ezVt2jTFxcVp/fr1eX64//PPP3X77bfrzJkzevTRR1WzZk0dO3ZMixYt0vnz5+Xu7q7jx4+refPmOn/+vIYPH67y5cvrk08+0T333KNFixbp3nvvtTnmxIkT5eXlpWeffdZ6Ltzc3OTi4qLTp09r7Nix2rRpk2bPnq2IiAiNHj3aZv81a9bo888/1/Dhw+Xh4aH3339fHTt21C+//OLw/II5c+Zo8ODBuv322/Xoo49KkqpWrSpJOn78uO644w7rh9KAgAAtX75cgwYNUkpKimJiYhx6rNxs27ZNpUuXtv68stx+++3W7S1bttSxY8d04sSJHOeM3H777fr2229L3DFzEhsbq169eumuu+7Sq6++KilzvtT69es1YsSIPPc9ffq07r77bj300EN64IEHNG3aND300EP67LPPFBMTo8cff1y9e/fW66+/rvvvv18JCQny9fXN85j58ccff2jp0qV64IEHFBERoePHj2v69Olq3bq19uzZowoVKqhWrVp66aWXNHr0aD366KNq1aqVJKl58+Y5HtMYo3vuuUerVq3SoEGD1LBhQ61YsUJPP/20jh07prfeesum/7p167R48WINGTJEvr6+evvtt3XfffcpPj5e5cuXlyQ9/vjjWrRokYYNG6batWvr1KlTWrdunX777Tc1btw41+e3e/dutWrVSn5+fvrPf/4jNzc3TZ8+XW3atLF+yO/Ro4fKlCmjJ598Ur169VLnzp3l4+OT4/Hycy5Onz6tjh07qkePHurZs6cWLVqkZ555RvXq1VOnTp0kZc65u+eee7Ru3To9+uijqlWrlnbu3Km33npL+/bty/fcxb/++itbm7u7u/z8/GzavL291a1bN82fP19PPPGEJGnHjh3avXu3PvroI/3666/5erys51fQ12p+fjcV1jm+cOGC2rRpowMHDmjYsGGKiIjQwoUL1b9/f505c8b6njTGqFu3blq3bp0ef/xx1apVS0uWLFF0dHS+zwlQ5Jx7wQu4seU1VC86Otpm6FXWcISAgABz5swZa/uoUaOMJNOgQQNz+fJla3uvXr2Mu7u7uXjxojHGmLNnz5oyZcqYRx55xOZxkpKSjL+/f7b2a+U2VO/EiRPG3d3ddOjQwaSnp1vb3333XSPJzJw5M8/j9uvXz7i4uJi4uLhs2zIyMowxxsTExBhJZu3atdZtZ8+eNREREaZy5crWx80a7lK3bl3rELasc2GxWEynTp1sjh8ZGZlteJskI8ls3rzZ2nbkyBHj6elp7r33XmvbtT+fLGPGjMn2M81tqN6gQYNMSEiI+euvv2zaH3roIePv72/Onz+fbZ/c5DVUr0uXLqZKlSrZ2s+dO2ckmWeffdYY879hN59++mm2vk8//bSRZH09lZRj5mTEiBHGz8/PZujftbJeS1e/3lu3bm0kmXnz5lnbfv/9dyPJuLi4mE2bNlnbV6xYkW34kCOvmWuH6l28eNHm/WVM5u8EDw8P89JLL1nb8ho6de3jL1261EgyL7/8sk2/+++/31gsFnPgwAFrmyTj7u5u07Zjxw4jybzzzjvWNn9/f5uhlvnVvXt34+7ubg4ePGht+/PPP42vr6+58847bZ6zCmmo3rWvobS0NBMcHGzuu+8+a9ucOXOMi4uLze8eY4z54IMPjCSzfv36PGuIjo62/k659hYVFWXtl/V6W7hwoVm2bJmxWCwmPj7eGJP5ms56X+R3+Ns/fa3m93dTYZzjKVOmGElm7ty51rZLly6ZyMhI4+PjY1JSUowx/3u9vvbaa9Z+V65cMa1atWKoHooNhuoBxcwDDzwgf39/6/1mzZpJkvr27atSpUrZtF+6dEnHjh2TlPlX9jNnzqhXr17666+/rDdXV1c1a9ZMq1atKlA933//vS5duqSYmBi5uPzvV8YjjzwiPz+/bMPprpaRkaGlS5eqa9euOV49yBq+9O233+r2229Xy5Ytrdt8fHz06KOP6vDhw9qzZ4/Nfv369bO5ytWsWTMZYzRw4ECbfs2aNVNCQoKuXLli0x4ZGakmTZpY71eqVEndunXTihUrlJ6entfpyDdjjL788kt17dpVxhibn0lUVJSSk5PzNbwpPy5cuCAPD49s7VkTzS9cuGDzb377loRj5qRMmTI6d+6cYmNjc+2TGx8fH5tFKmrUqKEyZcqoVq1a1vei9L/35R9//OHwY+TEw8PD+v5KT0/XqVOn5OPjoxo1ahT4dfLtt9/K1dVVw4cPt2n/97//LWOMli9fbtPevn1769VSSapfv778/PxsnmOZMmX0888/688//8x3Henp6Vq5cqW6d++uKlWqWNtDQkLUu3dvrVu3TikpKY4+Pbt8fHxsFmFwd3fX7bffbvN8Fi5cqFq1aqlmzZo279F27dpJUr5+b3p6eio2NjbbbdKkSTn279Chg8qVK6cFCxbIGKMFCxaoV69eBXp+BXmtFubvpvyc42+//VbBwcE2z9HNzU3Dhw9Xamqq1qxZY+1XqlQp65U4KXOBjf/7v/9z5LQARYqhekAxc/WcDknWEBUWFpZj++nTpyVlfveJJOv/8K917ZCR/Dpy5IikzP8pX83d3V1VqlSxbs/JyZMnlZKSYnf425EjR2z+R58la0jXkSNHbI7hyDnKyMhQcnKydaiRJFWvXj3bY9166606f/68Tp48qeDg4DzrzY+TJ0/qzJkzmjFjhmbMmJFjnxMnTvzjx5EkLy+vHOdjXLx40br96n/z27ckHDMnQ4YM0RdffKFOnTqpYsWK6tChg3r27KmOHTvmuk+W0NDQbPOR/P397b7//qmMjAxNnTpV77//vg4dOmQT4K9+7TriyJEjqlChQrahhFe/r6527ftKksqWLWvzHF977TVFR0crLCxMTZo0UefOndWvXz+bQHStkydP6vz589l+h2TVkpGRoYSEBNWpU8eh52dPTj/LsmXL2gyH279/v3777TcFBATkeIz8vEddXV3Vvn37fNfl5uamBx54QPPmzdPtt9+uhIQE9e7dO9/7Zynoa7Uwfzfl5xwfOXJE1atXt/nDm5T9dXjkyBGFhIRkG56Z0+sGcBaCE1DM5DY5OLd28/8neWctQzxnzpwcP/hffbWqpCvoOXJEbpP583tFKuvn0bdv31zH6NevX9/hunISEhKiVatWyRhjU3diYqIkWZcyDwkJsWm/WmJiosqVK2e9ylNSjpmTwMBAbd++XStWrNDy5cu1fPlyzZo1S/369ctxQvrV/slr65+8Zl555RW9+OKLGjhwoMaPH69y5crJxcVFMTEx122J8fw8x549e6pVq1ZasmSJVq5cqddff12vvvqqFi9ebJ3TUlzk5/lkZGSoXr16evPNN3Pse20IKSy9e/fWBx98oLFjx6pBgwaqXbu2w8f4p/+vKIzfTYX5OxcoCW6cT1LATS5riE1gYKBDf/3M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wMBA4+7ubipVqmQee+wxk5iYaO2T23LkOS0FHR4ebrp06ZKtPeu1dLX8vmZyWo783//+twkJCTFeXl6mRYsWZuPGjaZ169amdevWNvt+9dVXpnbt2qZUqVI2yzTntBz62bNnzZNPPmkqVKhg3NzcTPXq1c3rr79u/SqAvJ7LtXWmpaWZp59+2jRo0MD4+vqa0qVLmwYNGpj3338/23452bp1q4mKijI+Pj7G29vbtG3b1mzYsMGmjyPLked1LnL7WeZ0ji5dumReffVVU6dOHePh4WHKli1rmjRpYsaNG2eSk5PzfPy8liOXZA4dOmSMyf1317UcWY78n75W8/u7qTDO8fHjx82AAQPMLbfcYtzd3U29evVyXF781KlT5uGHHzZ+fn7G39/fPPzww2bbtm0sR45iw2IMM/gA3DwsFouGDh2a59U3AACAazHHCQAAAADsIDgBAAAAgB0EJwAAAACwg1X1ANxUmNYJAAAKgitOAAAAAGAHwQkAAAAA7LjphuplZGTozz//lK+vrywWi7PLAQAAAOAkxhidPXtWFSpUkItL3teUbrrg9OeffyosLMzZZQAAAAAoJhISEhQaGppnn5suOPn6+krKPDl+fn5OrgYAAACAs6SkpCgsLMyaEfJy0wWnrOF5fn5+BCcAAAAA+ZrCw+IQAAAAAGAHwQkAAAAA7CA4AQAAAIAdN90cJwAAgJImPT1dly9fdnYZQInk5uYmV1fXf3wcghMAAEAxlpqaqqNHj8oY4+xSgBLJYrEoNDRUPj4+/+g4BCcAAIBiKj09XUePHpW3t7cCAgLytfIXgP8xxujkyZM6evSoqlev/o+uPBGcAAAAiqnLly/LGKOAgAB5eXk5uxygRAoICNDhw4d1+fLlfxScnL44xLFjx9S3b1+VL19eXl5eqlevnjZv3pznPqtXr1bjxo3l4eGhatWqafbs2denWAAAACfgShNQcIX1/nFqcDp9+rRatGghNzc3LV++XHv27NHkyZNVtmzZXPc5dOiQunTporZt22r79u2KiYnR4MGDtWLFiutYOQAAAICbiVOH6r366qsKCwvTrFmzrG0RERF57vPBBx8oIiJCkydPliTVqlVL69at01tvvaWoqKgirRcAAADAzcmpV5y+/vprNW3aVA888IACAwPVqFEjffjhh3nus3HjRrVv396mLSoqShs3bsyxf1pamlJSUmxuAAAAuDFZLBYtXbpUknT48GFZLBZt37493/u3adNGMTExhVrT7NmzVaZMmUI9Jq4/pwanP/74Q9OmTVP16tW1YsUKPfHEExo+fLg++eSTXPdJSkpSUFCQTVtQUJBSUlJ04cKFbP0nTpwof39/6y0sLKzQnwcAAECxlp4urV4tzZ+f+W96epE/ZP/+/WWxWGSxWOTu7q5q1arppZde0pUrV4r8sQtTmzZtrM/j6tvjjz9+3WvJCoKurq46duyYzbbExESVKlVKFotFhw8fvu613QycGpwyMjLUuHFjvfLKK2rUqJEeffRRPfLII/rggw8K7TFGjRql5ORk6y0hIaHQjg0AAFDsLV4sVa4stW0r9e6d+W/lypntRaxjx45KTEzU/v379e9//1tjx47V66+/7vBx0tPTlZGRUQQV5s8jjzyixMREm9trr712XWu4+guQK1asqE8//dRm+yeffKKKFSv+48e5dOnSPz7GjcqpwSkkJES1a9e2aatVq5bi4+Nz3Sc4OFjHjx+3aTt+/Lj8/PxyXKbTw8NDfn5+NjcAAICbwuLF0v33S0eP2rYfO5bZXsThycPDQ8HBwQoPD9cTTzyh9u3b6+uvv9abb76pevXqqXTp0goLC9OQIUOUmppq3S9raNvXX3+t2rVry8PDQ/Hx8YqLi9O//vUv3XLLLfL391fr1q21detWh2ratWuXOnXqJB8fHwUFBenhhx/WX3/9lec+3t7eCg4OtrllfaZcvXq1LBaLzpw5Y+2/fft2u1d+vvrqKzVu3Fienp6qUqWKxo0bZ3M1zmKxaNq0abrnnntUunRpTZgwwbotOjraZo0ASZo1a5aio6Nt2tLT0zVo0CBFRETIy8tLNWrU0NSpU2369O/fX927d9eECRNUoUIF1ahRw/r4WUMes5QpU8a6mnW7du00bNgwm+0nT56Uu7u7fvjhB0nSnDlz1LRpU/n6+io4OFi9e/fWiRMnrP2zzt2KFSvUqFEjeXl5qV27djpx4oSWL1+uWrVqyc/PT71799b58+et+3333Xdq2bKlypQpo/Lly+vuu+/WwYMHcz3XhcWpwalFixbau3evTdu+ffsUHh6e6z6RkZHWH0aW2NhYRUZGFkmNAAAAJVJ6ujRihGRM9m1ZbTEx12XYXhYvLy9dunRJLi4uevvtt7V792598skn+vHHH/Wf//zHpu/58+f16quv6qOPPtLu3bsVGBios2fPKjo6WuvWrdOmTZtUvXp1de7cWWfPns3X4585c0bt2rVTo0aNtHnzZn333Xc6fvy4evbsWRRPN1dr165Vv379NGLECO3Zs0fTp0/X7NmzbcKRJI0dO1b33nuvdu7cqYEDB1rb77nnHp0+fVrr1q2TJK1bt06nT59W165dbfbPyMhQaGioFi5cqD179mj06NF67rnn9MUXX9j0++GHH7R3717FxsZq2bJl+XoOgwcP1rx585SWlmZtmzt3ripWrKh27dpJyrxKNn78eO3YsUNLly7V4cOH1b9//2zHGjt2rN59911t2LBBCQkJ6tmzp6ZMmaJ58+bpm2++0cqVK/XOO+9Y+587d05PPfWUNm/erB9++EEuLi669957i/6qpHGiX375xZQqVcpMmDDB7N+/33z22WfG29vbzJ0719rn2WefNQ8//LD1/h9//GG8vb3N008/bX777Tfz3nvvGVdXV/Pdd9/l6zGTk5ONJJOcnFzozwcAAKAwXbhwwezZs8dcuHDB8Z1XrTImMyLlfVu1qrDLNsYYEx0dbbp162aMMSYjI8PExsYaDw8PM3LkyGx9Fy5caMqXL2+9P2vWLCPJbN++Pc/HSE9PN76+vua///2vtU2SWbJkiTHGmEOHDhlJZtu2bcYYY8aPH286dOhgc4yEhAQjyezdu9cYY0zr1q3NiBEjrNtbt25t3NzcTOnSpW1uWZ9XV61aZSSZ06dPW/fZtm2bkWQOHTpkfT7+/v7W7XfddZd55ZVXbOqYM2eOCQkJsXkeMTExNn2ufj4xMTFmwIABxhhjBgwYYJ588slsj5uToUOHmvvuu896Pzo62gQFBZm0tDSbflefxyz+/v5m1qxZxpjM12bZsmXN559/bt1ev359M3bs2FwfOy4uzkgyZ8+eNcb879x9//331j4TJ040kszBgwetbY899piJiorK9bgnT540kszOnTtz3J7X+8iRbODU5chvu+02LVmyRKNGjdJLL72kiIgITZkyRX369LH2SUxMtBm6FxERoW+++UZPPvmkpk6dqtDQUH300Uclcyny9HRp7VopMVEKCZFatZL+wbcZAwAAWCUmFm6/Ali2bJl8fHx0+fJlZWRkqHfv3ho7dqy+//57TZw4Ub///rtSUlJ05coVXbx4UefPn5e3t7ckyd3dXfXr17c53vHjx/XCCy9o9erVOnHihNLT03X+/Pk8p3lcbceOHVq1apV8fHyybTt48KBuvfXWHPfr06ePnn/+eZu2axcrc8SOHTu0fv16mytM6enp2c5B06ZNcz3GwIED1bx5c73yyitauHChNm7cmOPCG++9955mzpyp+Ph4XbhwQZcuXVLDhg1t+tSrV0/u7u4OPQdPT089/PDDmjlzpnr27KmtW7dq165d+vrrr619tmzZorFjx2rHjh06ffq09YpQfHy8zXSdq3/OQUFB8vb2VpUqVWzafvnlF+v9/fv3a/To0fr555/1119/2Ry3bt26Dj0PRzg1OEnS3XffrbvvvjvX7VnjKK/Wpk0bbdu2rQirug4WL868fH71mOPQUGnqVKlHD+fVBQAAbgwhIYXbrwDatm2radOmyd3dXRUqVFCpUqV0+PBh3X333XriiSc0YcIElStXTuvWrdOgQYN06dIla2jw8vKSxWKxOV50dLROnTqlqVOnKjw8XB4eHoqMjMz3ggapqanq2rWrXn311WzbQvI4D/7+/qpWrVqO21xcMme+mKuGRF69kENudYwbN049cvjM5+npaf3v0qVL53qMevXqqWbNmurVq5dq1aqlunXrZlt2fcGCBRo5cqQmT56syMhI+fr66vXXX9fPP/9s0y+nx7FYLDbPKafnNXjwYDVs2FBHjx7VrFmz1K5dO+uUm3PnzikqKkpRUVH67LPPFBAQoPj4eEVFRWX7ebm5udk87tX3s9quHobXtWtXhYeH68MPP1SFChWUkZGhunXrFvnCFk4PTjelrIma1445zpqouWgR4QkAAPwzrVpl/lH22LGc5zlZLJnbW7UqshJKly6dLXBs2bJFGRkZmjx5sjV0XDvnJjfr16/X+++/r86dO0uSEhIS7C7scLXGjRvryy+/VOXKlVWqVOF8DA4ICJCUOUqqbNmykmT3e6MaN26svXv35hrG8mvgwIEaMmSIpk2bluP29evXq3nz5hoyZIi1Lb+LKAQEBCjxqquR+/fvt1mgQcoMb02bNtWHH36oefPm6d1337Vu+/3333Xq1ClNmjTJ+nVAmzdvzvdzy82pU6e0d+9effjhh2r1/1+7WXO9ippTF4e4KRXDiZoAAOAG5OqaOZJFygxJV8u6P2XKdZ8mUK1aNV2+fFnvvPOO/vjjD82ZMyffX0VTvXp1zZkzR7/99pt+/vln9enTJ8dVlXMzdOhQ/f333+rVq5fi4uJ08OBBrVixQgMGDFB6Hp+9zp8/r6SkJJvb6dOnrc8nLCxMY8eO1f79+/XNN99o8uTJedYxevRoffrppxo3bpx2796t3377TQsWLNALL7yQ7+ciZS6TfvLkSQ0ePDjH7dWrV9fmzZu1YsUK7du3Ty+++KLi4uLydex27drp3Xff1bZt27R582Y9/vjj2a4ESZlXnSZNmiRjjO69915re6VKleTu7m79OX/99dcaP368Q88vJ2XLllX58uU1Y8YMHThwQD/++KOeeuqpf3zc/CA4XW9r12ZfEvRqxkgJCZn9AAAA/okePTJHslz7/T6hoU4b4dKgQQO9+eabevXVV1W3bl199tlnmjhxYr72/fjjj3X69Gk1btxYDz/8sIYPH67AwMB8P3aFChW0fv16paenq0OHDqpXr55iYmJUpkwZ69WvnHz44YcKCQmxufXq1UtS5jCz+fPn6/fff1f9+vX16quv6uWXX86zjqioKC1btkwrV67UbbfdpjvuuENvvfVWnitL56RUqVK65ZZbcr169thjj6lHjx568MEH1axZM506dcrm6lNeJk+erLCwMLVq1Uq9e/fWyJEjrcMor9arVy+VKlVKvXr1shlmGBAQoNmzZ2vhwoWqXbu2Jk2apDfeeMOh55cTFxcXLViwQFu2bFHdunX15JNPFui7wQrCYq4dvHiDS0lJkb+/v5KTk53znU7z52d++Zw98+ZJ//8NCQAAbk4XL17UoUOHFBERYfOh1GEsSIUicvjwYVWtWlVxcXFq3Lixs8vJUV7vI0eyAXOcrrdiMFETAADcZFxdpTZtnF0FbiCXL1/WqVOn9MILL+iOO+4otqGpMDFU73rLmqh57VjjLBaLFBZWpBM1AQAAgH9i/fr1CgkJUVxcXL7nqJV0XHG63rImat5/f2ZIunqkpBMnagIAAAD51aZNm2zLld/ouOLkDMVwoiYAAACA3HHFyVl69JC6dWOiJgAAAFACEJyciYmaAAAAQInAUD0AAAAAsIPgBAAAAAB2EJwAAAAAwA6CEwAAAIqd1atXy2Kx6MyZM84uBZBEcAIAAEAR6N+/vywWS7Zbx44dr3stWY+9adMmm/a0tDSVL19eFotFq1evvu51oWQhOAEAAKBIdOzYUYmJiTa3+fPnX7fHv3TpkvW/w8LCNGvWLJvtS5YskY+PT6E+Dm5cBCcAAIASwhij85euOOVmjHG4Xg8PDwUHB9vcypYtq8OHD8tisWj79u3WvmfOnLF75WfdunVq1aqVvLy8FBYWpuHDh+vcuXPW7ZUrV9b48ePVr18/+fn56dFHH7Vui46O1oIFC3ThwgVr28yZMxUdHZ3tcZ555hndeuut8vb2VpUqVfTiiy/q8uXL1u1jx45Vw4YN9dFHHykiIkKenp7Wx58yZYrNsRo2bKixY8dKkgYOHKi7777bZvvly5cVGBiojz/+WJL03XffqWXLlipTpozKly+vu+++WwcPHrT2zzp3X3zxhfVc3Hbbbdq3b5/i4uLUtGlT+fj4qFOnTjp58qR1v7i4OP3rX//SLbfcIn9/f7Vu3Vpbt27N9VwjO77HCQAAoIS4cDldtUevcMpj73kpSt7uzvvoePDgQXXs2FEvv/yyZs6cqZMnT2rYsGEaNmyYzZWkN954Q6NHj9aYMWNs9m/SpIkqV66sL7/8Un379lV8fLx++uknvffeexo/frxNX19fX82ePVsVKlTQzp079cgjj8jX11f/+c9/rH0OHDigL7/8UosXL5arq2u+nsPgwYN15513KjExUSEhIZKkZcuW6fz583rwwQclSefOndNTTz2l+vXrKzU1VaNHj9a9996r7du3y8Xlf9c8xowZoylTpqhSpUoaOHCgevfuLV9fX02dOlXe3t7q2bOnRo8erWnTpkmSzp49q+joaL3zzjsyxmjy5Mnq3Lmz9u/fL19fXwd+EjcvghMAAACKxLJly7INhXvuuefUu3dvh481ceJE9enTRzExMZKk6tWr6+2331br1q01bdo061Wfdu3a6d///neOxxg4cKBmzpypvn37avbs2ercubMCAgKy9XvhhRes/125cmWNHDlSCxYssAlOly5d0qeffprj/rlp3ry5atSooTlz5liPNWvWLD3wwAPW83TffffZ7DNz5kwFBARoz549qlu3rrV95MiRioqKkiSNGDFCvXr10g8//KAWLVpIkgYNGqTZs2db+7dr187muDNmzFCZMmW0Zs2abFfBkDOCEwAAQAnh5eaqPS9FOe2xHdW2bVvrFY8s5cqVU0pKisPH2rFjh3799Vd99tln1jZjjDIyMnTo0CHVqlVLktS0adNcj9G3b189++yz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jDHmoYceMu3atTPGFDw4eXt7m5SUFGtbVFSUqVy5sklPT7e21ahRw0ycONF6X5J5/PHHbY7drFkz88QTTxhjjJkzZ46pUaOGycjIsG5PS0szXl5eZsWKFcaY/P88mzdvbh555BGbtgceeMB07tzZer9bt24mOjo6z+NUqFDBPP/88zluy+/7ws3NzZw4ccLaZ+3atcbPz89cvHjR5nhVq1Y106dPN8YY4+vra2bPnp1nbY0aNTJjx47Nsw8A5IWhegDw/9WvX9/mfkhIiM1wtPz67bffFBYWprCwMGtb7dq1VaZMGf3222/WtkqVKqlixYrW+5GRkcrIyNDevXtVrlw59e/fX1FRUerataumTp2qxMREu48dGRlp/e9SpUqpadOmNo/53nvvqUmTJgoICJCPj49mzJih+Ph4m2PUq1dP7u7uNm1btmxR165dValSJfn6+qp169aSlG3fq89hUFCQvL29VaVKFZu2rHN64MABnT9/Xv/617/k4+NjvX366ad5DqlyZL+mTZvmeb4uXLggSdYhYTl5+eWXtXbtWq1cuTLPY+WlcuXK8vX1td4PCgpS7dq15eLiYtN27evt6p9n1v2sn+eOHTt04MAB+fr6Ws9BuXLldPHiRZvzkNPP81q//fZbtjleLVq0sHnt2HPixAn9+eefuuuuu3J9jPy8L8LDwxUQEGC9v2PHDqWmpqp8+fI2P+9Dhw5Zn+dTTz2lwYMHq3379po0aVKOrx8vLy+bYaIA4KhSzi4AAIoLNzc3m/sWi8WpSxjPmjVLw4cP13fffafPP/9cL7zwgmJjY3XHHXcU6HgLFizQyJEjNXnyZEVGRsrX11evv/66fv75Z5t+pUuXtrl/7tw5RUVFKSoqSp999pkCAgIUHx+vqKiobJP3rz6HFoslz3OampoqSfrmm29sAqQkeXh45Po8HNnv2udyrfLly8tisej06dO59qlataoeeeQRPfvss/r4449ttrm4uMgYY9N2+fLlbMfI6Tz809dbamqqmjRpkuNqcVcHD3vnoLB4eXkVynGurTc1NVUhISFavXp1tr5Z88vGjh2r3r1765tvvtHy5cs1ZswYLViwQPfee6+1799//21zXgDAUVxxAoBCVqtWLSUkJCghIcHatmfPHp05c0a1a9e2tsXHx+vPP/+03t+0aZNcXFxsJvA3atRIo0aN0oYNG1S3bl3Nmzcvz8fetGmT9b+vXLmiLVu2qFatWpKk9evXq3nz5hoyZIgaNWqkatWq5Wuy/O+//65Tp05p0qRJatWqlWrWrFmgK3HXql27tjw8PBQfH69q1arZ3LKuSmRdKUlPT3dov/xyd3dX7dq1tWfPnjz7jR49Wvv27dOCBQts2gMCAnT27FmdO3fO2rZ9+3aHasjL1T/PrPtZP8/GjRtr//79CgwMzHYe/P39HXqcWrVqaf369TZt69evt3m92uPr66vKlSvrhx9+yPUx8vO+uFbjxo2VlJSkUqVKZXuet9xyi7XfrbfeqieffFIrV65Ujx49NGvWLOu2rKtwjRo1yvfzAYBrEZwAoJC1b99e9erVU58+fbR161b98ssv6tevn1q3bm0zdMzT01PR0dHasWOH1q5dq+HDh6tnz54KDg7WoUOHNGrUKG3cuFFHjhzRypUrtX//fuuH5ty89957WrJkiX7//XcNHTpUp0+f1sCBAyVJ1atX1+bNm7VixQrt27dPL774ouLi4uw+n0qVKsnd3V3vvPOO/vjjD3399dcaP378PztJyvygPXLkSD355JP65JNPdPDgQW3dulXvvPOOPvnkE0mZw7YsFouWLVumkydPKjU1NV/7OSIqKirXFfeyBAUF6amnnsq2pHWzZs3k7e2t5557TgcPHtS8efM0e/Zsh2vIzcKFCzVz5kzt27dPY8aM0S+//GJdpa5Pnz665ZZb1K1bN61du1aHDh3S6tWrNXz4cB09etShx3n66ac1e/ZsTZs2Tfv379ebb76pxYsXa+TIkQ4dZ+zYsZo8ebLefvtt7d+/3/pzkfL/vrhW+/btFRkZqe7du2vlypU6fPiwNmzYoOeff16bN2/WhQsXNGzYMK1evVpHjhzR+vXrFRcXZ/Ne2bRpkzw8PLINfQQARxCcAKCQWSwWffXVVypbtqzuvPNOtW/fXlWqVNHnn39u069atWrq0aOHOnfurA4dOqh+/fp6//33JUne3t76/fffdd999+nWW2/Vo48+qqFDh+qxxx7L87EnTZqkSZMmqUGDBlq3bp2+/vpr61/lH3vsMfXo0UMPPvigmjVrplOnTmnIkCF2n09AQIBmz56thQsXqnbt2po0aZLeeOONAp4dW+PHj9eLL76oiRMnqlatWurYsaO++eYbRURESJIqVqyocePG6dlnn1VQUJA1NNjbzxGDBg3St99+a7MUfE5GjhwpHx8fm7Zy5cpp7ty5+vbbb1WvXj3Nnz/foWXs7Rk3bpwWLFig+vXr69NPP9X8+fOtV2e8vb31008/qVKlSurRo4dq1aqlQYMG6eLFi/Lz83Pocbp3766pU6fqjTfeUJ06dTR9+nTNmjVLbdq0ceg40dHRmjJlit5//33VqVNHd999t/V7sPL7vriWxWLRt99+qzvvvFMDBgzQrbfeqoceekhHjhxRUFCQXF1dderUKfXr10+33nqrevbsqU6dOmncuHHWY8yfP199+vS5Ib9rDcD1YzHXDs4GADgsJCRE48eP1+DBg53y+IcPH1ZERIS2bdumhg0bOqWGkuyBBx5Q48aNNWrUKGeXgkL2119/qUaNGtq8eXOBgjUAZOGKEwD8A+fPn1dsbKyOHz+uOnXqOLscFNDrr7+e7WoSbgyHDx/W+++/T2gC8I9xxQkA7Pjss89yHSJnjLHOVXrzzTevc2X/wxUnAACKFsEJAOw4e/asjh8/nuM2Nzc3hYeHX+eKAADA9UZwAgAAAAA7mOMEAAAAAHYQnAAAAADADoITAAAAANhBcAIAAAAAOwhOAAAAAGAHwQkAAAAA7CA4AQAAAIAd/w9g3fBLaN13WwAAAABJRU5ErkJggg==", 621 | "text/plain": [ 622 | "
" 623 | ] 624 | }, 625 | "metadata": {}, 626 | "output_type": "display_data" 627 | } 628 | ], 629 | "source": [ 630 | "import matplotlib.pyplot as plt\n", 631 | "\n", 632 | "fig, ax = plt.subplots(figsize=(10, 7))\n", 633 | "\n", 634 | "ax.scatter(n_jobs_values, parallel_results, label=\"ParallelEulerMaruyama\", color=\"red\")\n", 635 | "ax.axhline(y=seq_em_time, label=\"EulerMaruyama\")\n", 636 | "\n", 637 | "ax.set_xticks(n_jobs_values)\n", 638 | "\n", 639 | "ax.set_xlabel(\"n_jobs parameter (Number of cores)\")\n", 640 | "ax.set_ylabel(\"time elapsed [s]\")\n", 641 | "ax.set_title(\"Time to compute 1000000 simulations of the EM method\")\n", 642 | "\n", 643 | "ax.legend(loc=0)\n", 644 | "\n", 645 | "plt.show()" 646 | ] 647 | }, 648 | { 649 | "cell_type": "markdown", 650 | "id": "1ab62140", 651 | "metadata": {}, 652 | "source": [ 653 | "In this simple example encompassing 1,000,000 simulations of the EM method, parallel processing shows improved performance over numpy vectorization when using more than three cores. Compared to the earlier case where we performed 1,000 simulations, the task is much larger. This means we save quite a lot of time through parallelising it, which more than offsets the additional time taken to set up the parallel processing jobs and recombine the array at the end. This sort of pattern is fairly typical of parallel programming - it will tend to slow down smaller tasks, but may speed up larger tasks." 654 | ] 655 | } 656 | ], 657 | "metadata": { 658 | "kernelspec": { 659 | "display_name": "Python 3 (ipykernel)", 660 | "language": "python", 661 | "name": "python3" 662 | }, 663 | "language_info": { 664 | "codemirror_mode": { 665 | "name": "ipython", 666 | "version": 3 667 | }, 668 | "file_extension": ".py", 669 | "mimetype": "text/x-python", 670 | "name": "python", 671 | "nbconvert_exporter": "python", 672 | "pygments_lexer": "ipython3", 673 | "version": "3.9.7" 674 | } 675 | }, 676 | "nbformat": 4, 677 | "nbformat_minor": 5 678 | } 679 | --------------------------------------------------------------------------------