├── .gitattributes ├── handbook └── img │ ├── 20-50-precise.png │ └── 22-5-precise.png ├── willowtree ├── __version__.py ├── __init__.py ├── graph.py ├── maketree.py ├── sampling.py └── lp.py ├── LICENSE.md ├── setup.py └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Prevent Jupyter Notebook from showing up as main repository language 2 | *.ipynb linguist-vendored 3 | -------------------------------------------------------------------------------- /handbook/img/20-50-precise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/federicomariamassari/willowtree/HEAD/handbook/img/20-50-precise.png -------------------------------------------------------------------------------- /handbook/img/22-5-precise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/federicomariamassari/willowtree/HEAD/handbook/img/22-5-precise.png -------------------------------------------------------------------------------- /willowtree/__version__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Return the version number of the currently installed 'willowtree' package. 3 | Version numbering follows a major.macro.micro convention, with micro omitted 4 | if equal to 0. 5 | 6 | To display version number in a Python environment, either call: 7 | 8 | willowtree.__version__ 9 | 10 | or, if willowtree is imported as wt: 11 | 12 | wt.__version__ 13 | ''' 14 | 15 | __version__ = '0.9' 16 | -------------------------------------------------------------------------------- /willowtree/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | __init__.py is automatically run with the Python command 'import willowtree'. 3 | 4 | The notation 'from . import ' reads: search for in 5 | the current directory (.) and import . 6 | 7 | will now be available to call as willowtree., instead 8 | of as willowtree... 9 | 10 | For example, on Terminal (macOS) or Command Prompt (Windows), the following 11 | commands will both run function maketree: 12 | 13 | $ python3 14 | >>> import willowtree 15 | >>> willowtree.maketree() 16 | 17 | or, using wt as an alias: 18 | 19 | >>> import willowtree as wt 20 | >>> wt.maketree() 21 | 22 | which is obviously less burdensome than willowtree.maketree.maketree(). 23 | ''' 24 | import time 25 | import numpy as np 26 | from scipy import stats, optimize 27 | import matplotlib.pyplot as plt 28 | import seaborn as sns 29 | 30 | from .__version__ import __version__ 31 | from .maketree import maketree 32 | from .sampling import sampling 33 | from .lp import lp 34 | from .graph import graph 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Federico Maria Massari 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A setuptools based setup module to install willowtree on your personal 3 | computer. 4 | 5 | How does it work? 6 | ------------------------------------------------------------------------------- 7 | On Terminal (macOS) or Command Prompt (Windows), navigate through folder 8 | 'willowtree', then run ($ is the Terminal prompt): 9 | 10 | $ python3 setup.py install 11 | 12 | This command will generate three folders in the current directory: 13 | 14 | build 15 | dist 16 | willowtree.egg-info 17 | 18 | and one .egg file in the main package folder, for example, if third-party 19 | extension Anaconda is installed, in 'anaconda/lib/python3.X/site-packages': 20 | 21 | willowtree-X.X-py3.X.egg 22 | 23 | where X.X refers to the most recent installed version of 'willowtree', e.g. 24 | 0.9, and py3.X to the latest installed Python version, e.g. 3.6. 25 | 26 | It is now possible to import willowtree in a Python environment. 27 | 28 | References 29 | ------------------------------------------------------------------------------- 30 | https://pythonhosted.org/an_example_pypi_project/setuptools.html 31 | https://github.com/pypa/sampleproject 32 | ''' 33 | 34 | from setuptools import setup 35 | 36 | # Look for module __version__.py in willowtree and import variable __version__ 37 | from willowtree.__version__ import __version__ 38 | 39 | # Assign the value of __version__ to variable version, then use it in setup() 40 | version = __version__ 41 | 42 | setup( 43 | name='willowtree', 44 | 45 | version=version, 46 | 47 | description='''Robust and flexible Python implementation of the willow 48 | tree lattice for derivatives pricing.''', 49 | 50 | url='https://github.com/federicomariamassari/willowtree', 51 | 52 | author='Federico Maria Massari', 53 | author_email='federico.massari@bocconialumni.it', 54 | 55 | license='MIT', 56 | 57 | classifiers=[ 58 | 'Development Status :: 3 - Alpha', 59 | 'Intended Audience :: Financial and Insurance Industry', 60 | 'Topic :: Scientific/Engineering :: Mathematics', 61 | 'License :: OSI Approved :: MIT License', 62 | 'Programming Language :: Python :: 3' 63 | ], 64 | 65 | keywords=[ 66 | 'willow-tree', 67 | 'derivatives-pricing', 68 | 'standard-brownian-motion' 69 | ], 70 | 71 | # The packages to install. There is only one folder, 'willowtree' 72 | packages=[ 73 | 'willowtree' 74 | ], 75 | 76 | # Dependencies, the auxiliary libraries necessary to run 'willowtree' 77 | install_requires=[ 78 | 'numpy >= 1.13', 79 | 'scipy >= 0.19', 80 | 'matplotlib >= 2.0', 81 | 'seaborn >= 0.8' 82 | ], 83 | ) 84 | -------------------------------------------------------------------------------- /willowtree/graph.py: -------------------------------------------------------------------------------- 1 | def graph(z, q, gamma, t, P): 2 | ''' 3 | Plot the willow tree. 4 | 5 | Input 6 | --------------------------------------------------------------------------- 7 | z, q: np.arrays, required arguments. The discrete density pairs, output of 8 | the function 'sampling'. 9 | gamma: float, required argument. Weighting parameter governing the shape of 10 | the distribution of probabilities {q(i)} in 'sampling'. 11 | t: np.array, required argument. The time array, a partition of [0, T]. 12 | P: np.array, 2-D or 3-D, required argument. The Markov chain, output of the 13 | function 'lp'. 14 | ''' 15 | 16 | # Import required libraries 17 | import numpy as np 18 | import matplotlib.pyplot as plt 19 | import seaborn as sns 20 | 21 | ''' 22 | Define auxiliary functions to automate repetitive tasks like transposing, 23 | reshaping, and computing Kronecker product. 24 | ''' 25 | def aux1(a, n, t): 26 | res = a.transpose().reshape(n ** 2 * (len(t) - 2)) 27 | return res 28 | 29 | def aux2(a, b): 30 | res = np.kron(a, np.ones(b)) 31 | return res 32 | 33 | # Make bidimensional grid G, the structure of the willow tree 34 | G = z[:, np.newaxis] @ np.sqrt(t)[np.newaxis] 35 | 36 | # Assign the space and time dimensions to variables n, k 37 | n, k = len(z), len(t)-1 38 | 39 | ''' 40 | If at least one transition matrix is well-defined, also use P(k) to plot 41 | the willow tree. 42 | ''' 43 | if k > 1: 44 | ''' 45 | Define the initial ramification, from t(0) to t(1). 'initial' stacks, 46 | in order: the start date t(0); the end date t(1); the initial node 47 | z(0); the end nodes z(1, i), with 1 denoting t(1) and i the node in 48 | 1, ..., n; the transition probabilities q(i) associated to each path 49 | z(0) -> z(1, i). 50 | ''' 51 | initial = np.array([np.zeros(n), np.full(n, t[1]), G[:,0], G[:,1], q]) 52 | 53 | ''' 54 | Define additional ramifications using P(k) to plot, for each node, the 55 | n**2 transition probabilities. 56 | ''' 57 | start_date = aux1(aux2(t[1:-1], (n ** 2)), n, t) 58 | end_date = aux1(aux2(t[2:], (n ** 2)), n, t) 59 | start_node = aux1(aux2(G[:,1:-1], (n, 1)), n, t) 60 | end_node = aux1(aux2(G[:,2:], n), n, t) 61 | transition = aux1(np.hstack(P), n, t) 62 | 63 | # Stack the arrays in a single structure W 64 | W = np.vstack((start_date, end_date, start_node, end_node, transition)) 65 | W = np.hstack((initial, W)).transpose() 66 | 67 | else: 68 | ''' 69 | If no well-defined transition matrix could be generated (k = 1), only 70 | plot the initial ramification. 71 | ''' 72 | initial = np.array([np.zeros(n), np.full(n, t[1]), G[:,0], G[:,1], q]) 73 | W = initial.transpose() 74 | 75 | ''' 76 | Include a square root function to define the upper and lower limits of the 77 | tree. 78 | ''' 79 | steps = np.linspace(0, t[-1], 1000) 80 | square_root = np.sqrt(steps) * z[n - 1] 81 | 82 | # Plot grid G together with the square root bounds 83 | sns.set() 84 | plt.figure(figsize = (10, 8)) 85 | ax = plt.axes() 86 | ax.plot(t, G.transpose(), '.k') 87 | l1 = ax.plot(steps, square_root, 'k', steps, -square_root, 'k') 88 | l2 = [ax.plot(W[i,:2], W[i,2:4], 'k') for i in range(len(W)) if W[i,4] > 0] 89 | plt.setp((l1, l2), linewidth = 0.5) 90 | ax.set(title = \ 91 | 'Willow Tree, $n$ = {} space points, $k$ = {} time steps, $\gamma$ = {}'\ 92 | .format(n, k, gamma), xlabel = 'Time', ylabel = 'Space') 93 | ax.invert_yaxis() 94 | plt.show() 95 | -------------------------------------------------------------------------------- /willowtree/maketree.py: -------------------------------------------------------------------------------- 1 | def maketree(n=12, gamma=0.1, algorithm='kurtosis-matching', k=10, 2 | tol=1e-12, extra_precision=False): 3 | ''' 4 | Generate and plot the willow tree in a single step. 5 | 6 | Input 7 | --------------------------------------------------------------------------- 8 | n: int, optional argument. The number of space nodes in the lattice; 9 | gamma: float, optional argument. Weighting parameter governing the shape 10 | of the distribution of probabilities {q(i)} in 'sampling'. 11 | Admissible values in [0, 1]. If input gamma < 0, the parameter will 12 | adjust to 0; if input gamma > 1, it will adjust to 1; 13 | algorithm: str, optional argument. The algorithm used to generate the 14 | discrete density pairs in 'sampling'. 15 | * For kurtosis matching, omit or set to 'kurtosis-matching', 16 | 'KRT', or 'krt'; 17 | * For first partial moment set to 'first-partial-moment', 18 | 'FPM', or 'fpm'. 19 | Which algorithm should you choose? 'kurtosis-matching' is best 20 | suited for deep out-of-the-money derivatives, since it proxies 21 | for kurtosis (the parameter governing the shape of the tails of 22 | the density); 'first-partial-moment' is instead best suited for 23 | very near-the-money derivatives, since it provides extra 24 | precision in computing mean and variance (while relaxing the 25 | condition on kurtosis); 26 | k: int, optional argument. The number of time steps, with k-1 the number 27 | of transition matrices generated; 28 | tol: float, generally in scientific notation, optional argument. Set the 29 | precision of the solutions to the linear programming problems. 30 | extra_precision: bool, optional argument. If True, also specify the upper 31 | bound for each variable p(i,j) in each of the k-1 linear 32 | programming (LP) problems for the Markov chain. Otherwise 33 | leave None as upper bound (extra_precision=False). 34 | 35 | Output 36 | --------------------------------------------------------------------------- 37 | q, z: NumPy arrays, the discrete density pairs, a discrete approximation of 38 | the standard normal distribution. 39 | * z is a sequence of representative standard normal variates determined 40 | with stratified sampling: partition the standard normal CDF into 41 | subgroups (strata) and draw a single value from each of them; 42 | * q is the set of probabilities associated to z and governing the width 43 | of the strata. 44 | P: NumPy array. The Markov chain, whose elements are transition matrices. 45 | P is 2-dim if either of the following is true: k = 2, len(t_new) = 3. 46 | Otherwise, P is a 3-dim array with shape (k-1, len(z), len(z)); 47 | t: Numpy array. The vector of time nodes. Of length k+1 if the algorithm 48 | manages to generate the full Markov chain (including both well-defined 49 | and interpolated matrices); shorter, otherwise. 50 | 51 | Example 52 | --------------------------------------------------------------------------- 53 | q, z, P, t = maketree() 54 | Generate willow tree with the suggested parameters. 55 | ''' 56 | 57 | # Import required libraries 58 | import time 59 | import numpy as np 60 | from scipy import stats, optimize 61 | import matplotlib.pyplot as plt 62 | import seaborn as sns 63 | 64 | # Import companion willowtree modules 65 | from willowtree import sampling, lp, graph 66 | 67 | # Generate discrete density pairs 68 | q, z, gamma = sampling(n, gamma) 69 | 70 | # Generate Markov chain 71 | P, t = lp(z, q, k, tol = tol, extra_precision = extra_precision) 72 | 73 | # Plot results 74 | graph(z, q, gamma, t, P) 75 | 76 | return q, z, P, t 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Willow Tree 2 | `willowtree` is an open source Python implementation of Michael Curran's derivatives pricing model of the same name. 3 | 4 | Curran, M. (2001): _[Willow Power: Optimizing Derivative Pricing Trees](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=1590288)_ 5 | 6 | willow-tree 7 | 8 | ## What is the Willow Tree? 9 | The willow tree is a highly efficient, recombining lattice designed for fast and accurate pricing of derivative contracts. It models the standard Brownian motion directly, by means of a discrete time Markov chain, and the resulting estimate can serve as the basis for more complex processes, such as the geometric Brownian motion. 10 | 11 | The lattice has two distinctive features: 12 | 13 | - __it expands as the square root of time__, in accordance with the Brownian motion and unlike the binomial model, which grows linearly in time. It opens quite fast at the beginning, covering a high-probability region neglected by standard trees, and slowly later on, being constrained within a set confidence region of the normal marginal distribution. This aspect both prevents the waste of time and computational resources on the tails of the distribution, areas with little impact on the definition of the current price of the security, and avoids the arbitrary practice of pruning the tree, namely, disregarding branches, along with their offsprings, located in low probability regions; 14 | 15 | 16 | - __it has a constant number of nodes per time step__. With time, this number grows linearly, and not quadratically, as it does in the binomial model. 17 | 18 | 19 | These features increase the efficiency of the lattice, because they force the paths to travel across a smaller structure squeezed in the body of the desired density, and allow for a fast convergence to the true, unknown price distribution. 20 | 21 | ## Main Features 22 | Being a proxy for the standard Brownian motion, `willowtree` can: 23 | - help price a wide array of European and American derivative contracts; 24 | - be considerably faster than other lattices, such as the binomial and trinomial, especially in higher dimensions: once a set of transition matrices is generated, it can be stored and becomes available for future use; 25 | - serve as building block for more complex processes (e.g. the geometric Brownian motion) and models (e.g. stochastic volatility, interest rate), of particular relevance in finance, engineering, and physics. 26 | 27 | ## Documentation 28 | A detailed history of the model, as well as the mathematical theory behind it, are available in the [companion handbook](https://nbviewer.jupyter.org/github/federicomariamassari/willowtree/blob/master/handbook/01.ipynb). 29 | 30 | ## Dependencies 31 | `willowtree` requires Python 3.5+, and is built on top of the following modules: 32 | - **NumPy**: v. 1.13+ 33 | - **SciPy**: v. 0.19+ 34 | - **Matplotlib**: v. 2.0+ 35 | - **Seaborn**: v. 0.8+ 36 | 37 | ## Installation 38 | The source code is currently hosted on GitHub at: https://github.com/federicomariamassari/willowtree. 39 | Either clone or download the git repository. To clone the repository, on either Terminal (macOS) or Command Prompt (Windows) enter the folder inside which you want the repository to be, possibly changing directory with `cd `, and execute: 40 | ```shell 41 | $ git clone https://github.com/federicomariamassari/willowtree.git 42 | ``` 43 | When the process is over, navigate through folder `willowtree` using `cd willowtree` and run: 44 | ```shell 45 | $ python3 setup.py install 46 | ``` 47 | or, if only Python 3 is present on your system, simply: 48 | ```shell 49 | $ python setup.py install 50 | ``` 51 | Then, within a Python environment, e.g. IDLE or Jupyter Notebook, execute: 52 | ```python 53 | import willowtree 54 | ``` 55 | or use `wt` as alias: 56 | ```python 57 | import willowtree as wt 58 | ``` 59 | Finally, call any of its modules, e.g. `sampling`, as either: `willowtree.sampling` or `wt.sampling` based on the previous choice. 60 | 61 | To check which version of `willowtree` is actually installed, use `willowtree.__version__`. 62 | 63 | For help on individual functions, call the built-in function `help`, e.g. `help(willowtree.sampling)` or `help(sampling)` if you chose `from willowtree import sampling`. 64 | 65 | ## License 66 | `willowtree` is offered under the MIT License. 67 | 68 | ## Contributing 69 | `willowtree` is a small but continuously evolving project open to anyone willing to contribute—simply fork the repository and modify its content. Any improvements, especially in making the code more readable (or _Pythonic_) or faster is more than welcome. For git commits, it is desirable to follow [Udacity's Git Commit Message Style Guide](https://udacity.github.io/git-styleguide/). 70 | 71 | Feel free to bookmark, or "star", the repository if you find this project interesting! 72 | 73 | ## Future Directions 74 | Presently, `willowtree` only handles one-dimensional lattices, implemented according to [Xu, Hong, and Qin's methodology](https://www.researchgate.net/publication/263268910_A_new_sampling_strategy_willow_tree_method_with_application_to_path-dependent_option_pricing). Future versions will try to focus on: 75 | 76 | - presenting more algorithms to build the tree (e.g. [Haussmann and Yan](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.136.7988&rep=rep1&type=pdf), 2004); 77 | - accounting for multi-dimensionality (e.g. [Lu and Xu](https://www.researchgate.net/publication/312622137_A_Simple_and_Efficient_Two-Factor_Willow_Tree_Method_for_Convertible_Bond_Pricing_with_Stochastic_Interest_Rate_and_Default_Risk), 2017). 78 | 79 | ## Latest Updates 80 | 81 | _October 28, 2017_ 82 | 83 | Following commit `3531721`, `willowtree` is now a package which can be installed from `setup.py`. 84 | 85 | _October 24, 2017_ 86 | 87 | Following commit `ab317d5` the willow tree has become a very precise and robust algorithm. It returns well-behaved Markov chains by generating accurate transition matrices and, if this is not possible, by either replacing wrong ones with interpolated versions (giving rise to the characteristic _black patches_, as in the figure below) or shortening the chain as appropriate. 88 | 89 | willow-tree 90 | 91 | Why the black patches? Adjacent, well-defined transition matrices have positive probabilities in different cells. When these matrices are used to interpolate one in between, the resulting object is less sparse, with a larger number of paths drawn. 92 | 93 | ## Known Issues 94 | - _Inability to choose gamma = 0, with gamma in [0, 1], as parameter value in the model._ Commit `f014907` partially fixes the issue by increasing gamma by steps of 1e-9 (first two seconds of runtime), 1e-6 (additional 8 seconds), and 1e-2 until the optimisation is successful. This method ensures that a solution is found with a value of gamma as close as possible to the one supplied by the user, and in a reasonable amount of time. 95 | 96 | - _Significant slowness for n > 20._ The bottleneck is the linear programming algorithm: each iteration (for a particular tolerance level) may take a very long time, and cannot be stopped unless the optimizer is run in a separate process, using Python's `multiprocessing` module. This may not be a problem if the matrices are to be stored and used at a later time, but it is nevertheless something that should be dealt with. 97 | 98 | For a list of all open and closed issues, please visit the [issue section](https://github.com/federicomariamassari/willowtree/issues?utf8=✓&q=). 99 | -------------------------------------------------------------------------------- /willowtree/sampling.py: -------------------------------------------------------------------------------- 1 | def sampling(n, gamma, algorithm = 'kurtosis-matching'): 2 | ''' 3 | Generate a sequence of discrete density pairs {z(i), q(i)}, for i = 1, ..., 4 | n, according to Xu, Hong, and Qin's methodology [1]. 5 | 6 | Choose between kurtosis matching ('kurtosis-matching', 'KRT', 'krt') and 7 | first partial moment matching ('first-partial-moment', 'FPM', 'fpm') 8 | strategies. Additional algorithms will be provided in future versions. 9 | 10 | Which algorithm should you choose? 11 | --------------------------------------------------------------------------- 12 | - 'kurtosis-matching': best suited for deep out-of-the-money derivatives 13 | since it proxies for kurtosis (the parameter governing the shape of the 14 | tails of the density); 15 | 16 | - 'first-partial-moment': best suited for very near-the-money derivatives 17 | since it provides extra precision in computing mean and variance (while 18 | relaxing the condition on kurtosis). 19 | 20 | Input 21 | --------------------------------------------------------------------------- 22 | n: int, required argument. The number of points in the space dimension. 23 | gamma: float, required argument. Weighting parameter governing the shape 24 | of the distribution of probabilities {q(i)}. 25 | Admissible values are in the closed interval [0;1]. Otherwise, the 26 | algorithm will set gamma to the nearest integer, e.g., if the user 27 | inputs gamma < 0, the parameter will adjust to 0; if gamma > 1, it 28 | will adjust to 1. 29 | algorithm: str, optional argument. The algorithm used to compute the pairs. 30 | For kurtosis matching omit or set to 'kurtosis-matching', 'KRT', 31 | or 'krt'. For first partial moment set to 'first-partial-moment', 32 | 'FPM', or 'fpm'. 33 | 34 | Output 35 | --------------------------------------------------------------------------- 36 | q, z: NumPy arrays, the discrete density pairs, a discrete approximation of 37 | the standard normal distribution. 38 | * z is a sequence of representative standard normal variates determined 39 | with stratified sampling: partition the standard normal CDF into 40 | subgroups (strata) and draw a single value from each of them; 41 | * q is the set of probabilities associated to z and governing the width 42 | of the strata. 43 | 44 | gamma: float, the new value of gamma. If optimisation is successful with 45 | the supplied value, input and new gamma will coincide. 46 | 47 | How does the algorithm work? 48 | --------------------------------------------------------------------------- 49 | Suppose you want to obtain {z(i), q(i)}, for i = 1, ..., 14, a 14-element 50 | partition of the standard normal CDF. This means the willow tree will have 51 | 14 (vertical) space points. 52 | 53 | * You start with n = 14, and decide to choose gamma = 0. However, with such 54 | value of gamma there exists no solution. The algorithm will increase the 55 | level of gamma by 1e-9 for the first two seconds of runtime, then by 1e-6 56 | for the following 8 seconds (up to a total of ten seconds), and finally 57 | by 1e-2 until a solution is found; 58 | * Suppose, instead, you start with gamma = 1, and also no solution exists 59 | for such value of gamma. The algorithm will decrease gamma by 1e-9 for 60 | the first two seconds, then by 1e-6 for additional 8 seconds, and finally 61 | by 1e-2 until a solution is found; 62 | * What if, for n = 14, no solution exists for gamma in [0;1]? In this case, 63 | after a full gamma cycle, n will increase by 1 and gamma be reset to the 64 | original value input by the user. Another full gamma cycle will occur and 65 | if again no solution is found n is increased by 1 additional unit, and so 66 | on. 67 | 68 | Example 69 | --------------------------------------------------------------------------- 70 | q, z, gamma = sampling(n=14, gamma=0, algorithm='first-partial-moment') 71 | 72 | Compute the discrete density pairs with n = 14 space points, gamma = 0. 73 | With gamma 0 the problem is infeasible. An optimal solution is found with 74 | n = 14 and gamma = 0.000000032 (increased by 1e-9 32 times). The function 75 | then returns arrays q, z, and the new value gamma = 3.2e-8. 76 | 77 | Resources 78 | --------------------------------------------------------------------------- 79 | [1] Xu, W., Hong, Z. and Qin, C. (2013): A New Sampling Strategy Willow 80 | Tree Method With Application To Path-Dependent Option Pricing. Quantitative 81 | Finance, March 2013, 13(6): 861–872. 82 | ''' 83 | 84 | # Import required modules 85 | import time 86 | import numpy as np 87 | from scipy import stats, optimize 88 | 89 | # Define auxiliary functions to compute the discrete density pairs 90 | def prob(n, gamma): 91 | ''' 92 | Generate q, the array of probabilities governing the width of the 93 | strata, and Z, the strata themselves. 94 | ''' 95 | 96 | ''' 97 | Generate index i, an array of linearly spaced natural numbers, 98 | accounting for n odd, if necessary. Compute only half, then flip and 99 | add the other half (plus a midpoint, if required). 100 | ''' 101 | i = np.arange(1, 0.5*(n+1), dtype = int) 102 | i = np.hstack([i, i[::-1]] if n % 2 == 0 \ 103 | else [i, i[-1]+1, i[::-1]]) 104 | 105 | ''' 106 | Compute the array of probabilities q as a normalised, either linear (if 107 | gamma ≈ 0) or nonlinear transformation of i. 108 | ''' 109 | q = (i-0.5)**gamma / n 110 | q = q / np.sum(q) 111 | 112 | return q 113 | 114 | 115 | def bounds(q): 116 | ''' 117 | Compute Z, the array of bounds for the standard normal variates {z(i)} 118 | (the endpoints of the strata). Set Z[0] and Z[-1] to, respectively, 119 | -Inf and Inf. 120 | ''' 121 | Z = np.hstack([[-np.inf], stats.norm.ppf(np.cumsum(q[:-1])), \ 122 | [np.inf]]) 123 | 124 | return Z 125 | 126 | def variates(q, Z, algorithm): 127 | ''' 128 | Compute z, the sequence of representative standard normal variates. 129 | ''' 130 | 131 | ''' 132 | Choose which algorithm to use based on user input. If the input is not 133 | recognised, switch to 'kurtosis-matching'. 134 | ''' 135 | if algorithm in ('kurtosis-matching', 'KRT', 'krt'): 136 | fun = lambda z: (q @ (z**4) - 3) ** 2 137 | elif algorithm in ('first-partial-moment', 'FPM', 'fpm'): 138 | fun = lambda z: np.sum(np.abs(q @ (z[:, np.newaxis] \ 139 | - Z[1:-1][np.newaxis, :]).clip(np.min(0)) \ 140 | - (1/np.sqrt(2*np.pi) * np.exp(0.5 * (-Z[1:-1]**2)) \ 141 | - Z[1:-1] * (1-stats.norm.cdf(Z[1:-1]))))) 142 | else: 143 | fun = lambda z: (q @ (z**4) - 3) ** 2 144 | 145 | ''' 146 | Set parameters and options for the optimisation algorithm. 147 | - x0: array, the vector of initial guess points for z; 148 | - constraints: dict, the set of constraints on q and z; (1) expected 149 | value equal to 0; (2) variance of z equal to 1; (3) sum of variates 150 | equal to zero (i.e. z is symmetric); (4) equal endpoints; 151 | - bounds: array of tuples, the bounds for each z, Z[low] and Z[high]; 152 | - options: additional options, such as the solution tolerance or the 153 | maximum number of iterations. 154 | ''' 155 | x0 = np.full(n, 1e-6) 156 | constraints = ({'type': 'eq', 'fun': lambda z: q @ z}, 157 | {'type': 'eq', 'fun': lambda z: q @ (z**2) - 1}, 158 | {'type': 'eq', 'fun': lambda z: z[:np.int((n+1)/2)] \ 159 | + z[np.int(n/2):][::-1]}) 160 | bounds = np.column_stack((Z[:-1], Z[1:])) 161 | options = {'disp': False, 'ftol': 1e-15, 'maxiter': 1e4} 162 | 163 | ''' 164 | Optimisation procedure. 165 | ''' 166 | z = optimize.minimize(fun, x0, bounds = bounds, options = options, 167 | tol = 1e-15, constraints = constraints) 168 | 169 | return z 170 | 171 | # Define test of consistency for the solution array z 172 | def test(q, z, algorithm): 173 | ''' 174 | Test whether q, z are an admissible set of discrete density pairs, 175 | based on the chosen algorithm. For both algorithms, q, z must have 176 | mean = 0, and variance = 1 within a strict tolerance level (1e-15). 177 | For 'kurtosis-matching' only, q and z must also satisfy kurtosis = 3. 178 | Otherwise, the algorithm is run again, until all requirements are met. 179 | ''' 180 | mean = np.isclose(q @ z, 0, 1e-15) 181 | variance = np.isclose(q @ z ** 2, 1, 1e-15) 182 | if algorithm in ('kurtosis-matching', 'KRT', 'krt'): 183 | kurtosis = np.isclose(q @ z ** 4, 3, 1e-15) 184 | return np.array([mean, variance, kurtosis]).all() 185 | else: 186 | return np.array([mean, variance]).all() 187 | 188 | # Time the entire algorithm 189 | tic = time.time() 190 | 191 | # Store the initial values of parameters n and gamma 192 | initial_n = n 193 | initial_gamma = gamma 194 | 195 | ''' 196 | Check whether the input algorithm is valid, otherwise switch to kurtosis- 197 | matching. 198 | ''' 199 | if algorithm not in ('kurtosis-matching', 'KRT', 'krt', 200 | 'first-partial-moment', 'FPM', 'fpm'): 201 | print("Unrecognised algorithm. Switching to 'kurtosis-matching'.") 202 | 203 | ''' 204 | If gamma is within the admissible range, generate q and Z. Else, set gamma 205 | equal to the integer closer to the user input (either 0 if gamma < 0 or 1 206 | if gamma < 1), then generate q and Z. 207 | ''' 208 | if 0 <= gamma <= 1: 209 | q = prob(n, gamma) 210 | Z = bounds(q) 211 | elif gamma < 0: 212 | print('Gamma out of range, must be in [0, 1]. Setting to 0.') 213 | gamma = 0 214 | q = prob(n, gamma) 215 | Z = bounds(q) 216 | else: 217 | print('Gamma out of range, must be in [0, 1]. Setting to 1.') 218 | gamma = 1 219 | q = prob(n, gamma) 220 | Z = bounds(q) 221 | 222 | ''' 223 | Generate z, the array of standard normal variates, from q and Z, then test 224 | whether q, z are an admissible solution pair. 225 | z, the output of the optimisation algorithm, is a structure: 226 | - z.x is the solution vector (the array of standard normal variates); 227 | - z.fun is the value of the objective function, 'fun'; 228 | - z.status is the output flag: 0 if successful, != 0 otherwise. 229 | ''' 230 | z = variates(q, Z, algorithm) 231 | test_result = test(q, z.x, algorithm) 232 | 233 | ''' 234 | Set the timer and a counter for additional minimisation problems. 235 | - Timer: governs further variations in gamma. Plus (if initial gamma = 0) 236 | or minus (if initial gamma = 1) 1e-9 for the first two seconds; 237 | plus or minus 1e-6 for additional 8 seconds, and plus or minus 238 | 1e-2 afterwards. 239 | - Counter: governs further increases in n. After a full cycle of gamma, 240 | either (0 -> 1) or (1 -> 0), the counter is increased by one and 241 | n is also raised by one. After that, the counter is reset to 0. 242 | ''' 243 | start = time.time() 244 | counter = 0 245 | 246 | ''' 247 | While the solution of the minimisation problem, z.x, is not satisfactory, 248 | either z.status != 0, z.fun > 0, or the constraints on mean, variance, and 249 | kurtosis are not strictly respected, the problem is run again. 250 | First, relaxing the constraints on gamma; then, increasing n by one or 251 | more units, if required. 252 | ''' 253 | while (z.status != 0) | (z.fun > 1) | (test_result != True): 254 | 255 | # If the input value of gamma is smaller than one: 256 | if initial_gamma < 1: 257 | 258 | # Increase gamma while it is still smaller than 1 and counter = 0 259 | if (gamma < 1) & (counter <= 1): 260 | if time.time() < start + 2: 261 | gamma += 1e-9 262 | elif time.time() < start + 10: 263 | gamma += 1e-6 264 | else: 265 | gamma += 1e-2 266 | q = prob(n, gamma) 267 | Z = bounds(q) 268 | z = variates(q, Z, algorithm) 269 | test_result = test(q, z.x, algorithm) 270 | 271 | # Raise counter by 1 and reset gamma to 0 if gamma reaches 1 272 | elif (gamma == 1) & (counter <= 1): 273 | gamma = 0 274 | counter +=1 275 | q = prob(n, gamma) 276 | Z = bounds(q) 277 | z = variates(q, Z, algorithm) 278 | test_result = test(q, z.x, algorithm) 279 | 280 | # Increase n by one unit after a full increasing cycle of gamma 281 | else: 282 | gamma = initial_gamma 283 | counter = 0 284 | n += 1 285 | q = prob(n, gamma) 286 | Z = bounds(q) 287 | z = variates(q, Z, algorithm) 288 | test_result = test(q, z.x, algorithm) 289 | 290 | # If the input value of gamma is equal to one: 291 | else: 292 | 293 | # Decrease gamma while it is still higher than 0 and counter = 0 294 | if (gamma > 0) & (counter <= 1): 295 | if time.time() < start + 2: 296 | gamma -= 1e-9 297 | elif time.time() < start + 10: 298 | gamma -= 1e-6 299 | else: 300 | gamma -= 1e-2 301 | q = prob(n, gamma) 302 | Z = bounds(q) 303 | z = variates(q, Z, algorithm) 304 | test_result = test(q, z.x, algorithm) 305 | 306 | # Raise counter by 1 and reset gamma to 1 if gamma reaches 0 307 | elif (gamma == 0) & (counter <= 1): 308 | gamma = 1 309 | counter +=1 310 | q = prob(n, gamma) 311 | Z = bounds(q) 312 | z = variates(q, Z, algorithm) 313 | test_result = test(q, z.x, algorithm) 314 | 315 | # Increase n by one unit after a full decreasing cycle of gamma 316 | else: 317 | gamma = initial_gamma 318 | counter = 0 319 | start = time.time() 320 | n += 1 321 | q = prob(n, gamma) 322 | Z = bounds(q) 323 | z = variates(q, Z, algorithm) 324 | test_result = test(q, z.x, algorithm) 325 | 326 | ''' 327 | If the values of n, gamma, or both, are different from those supplied by 328 | the user, print warning. 329 | ''' 330 | if (n != initial_n) | (gamma != initial_gamma): 331 | print('Optimisation could not be terminated with supplied parameters.') 332 | print('Optimisation successful with n = {} and gamma = {:.9f}.' \ 333 | .format(n, gamma)) 334 | else: 335 | print('Optimisation terminated successfully with supplied parameters.') 336 | 337 | # Compute total running time, in seconds 338 | toc = time.time() - tic 339 | print('Total running time: {:.2f}s'.format(toc)) 340 | 341 | return q, z.x, gamma 342 | -------------------------------------------------------------------------------- /willowtree/lp.py: -------------------------------------------------------------------------------- 1 | def lp(z, q, k, tol = 1e-9, extra_precision = False): 2 | ''' 3 | Generate a time-inhomogeneous, discrete time Markov chain for the willow 4 | tree [1] via linear programming (LP), using the discrete density pairs 5 | {z(i), q(i)}, for i = 1, ..., n, output of the function 'sampling'. 6 | 7 | The willow tree linear programming problem is: 8 | 9 | c'x (1) 10 | subject to: 11 | A_eq * x = b_eq (2) 12 | p(k; i,j) >= 0 (3) 13 | 14 | with: 15 | 16 | * c: the vector of coefficients of the linear objective function; 17 | * A_eq: a matrix of linear equality constraints; 18 | * b_eq: a vector of linear equality constraints; 19 | * p(k; i,j): transition probability at position (i,j) in the k-th 20 | transition matrix; 21 | * x: the array solution to the problem. 22 | 23 | Each solution x, when reshaped, is a transition matrix P(k), for k = 1, 24 | ..., n-1. 25 | 26 | Input 27 | --------------------------------------------------------------------------- 28 | q, z: NumPy arrays, required arguments. The discrete density pairs, a 29 | discrete approximation of the standard normal distribution. Output 30 | of the function 'sampling'; 31 | k: int, required argument. The number of time steps. k-1 is the number of 32 | transition matrices generated; 33 | tol: float, generally in scientific notation, optional argument. Set the 34 | precision of the solutions to the linear programming problems. 35 | extra_precision: bool, optional argument. If True, set the upper bound of 36 | each variable p(i,j) in the LP problems to 1. Otherwise, 37 | leave it to None. 38 | 39 | Output 40 | --------------------------------------------------------------------------- 41 | P: NumPy array. The Markov chain, whose elements are transition matrices. 42 | P is 2-dim if either of the following is true: k = 2, len(t_new) = 3. 43 | Otherwise, P is a 3-dim array with shape (k-1, len(z), len(z)); 44 | t_new: Numpy array. Of length k+1, if the algorithm manages to generate 45 | the full Markov chain (both well-defined and interpolated matrices), 46 | or shorter, otherwise. 47 | 48 | How does the algorithm work? 49 | --------------------------------------------------------------------------- 50 | Suppose you start with a sequence {z(i), q(i)}, for i = 1, ..., 14, a 51 | 14-element partition of the standard normal CDF (the space dimension) 52 | generated by 'sampling.py'. 53 | 54 | You divide the interval [0,T] (the time dimension) into k subintervals 55 | which could be, for instance, thought of as 'monitoring dates' for the 56 | value of a risky asset, and want to generate a Markov chain, say, to 57 | calculate the expected value of the position today. 58 | 59 | 'lp.py' is a robust enough algorithm to return a well-defined Markov chain 60 | even if, for any transition matrix, one of the following occurs: 61 | * the optimisation algorithm is unable to find a feasible starting point 62 | (exit: 2); 63 | * the optimisation is successful (exit: 0) but the value of the function 64 | is wrong, either negative or greater than zero; 65 | * the iteration limit is reached (exit: 1); 66 | 67 | In such circumstances, the function automatically: 68 | - decreases tolerance level, e.g. from 1e-9 to 1e-8, and starts from 69 | scratch until a feasible solution is found; 70 | - only stops whenever tolerance is a number bigger than 1e-2, in which 71 | case the solution would be highly imprecise. 72 | 73 | If, despite the procedure above, still no solution is found, the algorithm 74 | automatically tries to replace the badly specified transition matrix with 75 | one obtained by interpolating the two nearest well-defined matrices. 76 | 77 | Two cases are possible: 78 | * the bad matrix occurs at the end of the Markov chain: in this case, the 79 | matrix is automatically scrapped, because no matrix on the right can be 80 | used for interpolation; 81 | * the bad matrix is an intermediate one: in this case, it is successfully 82 | interpolated. 83 | 84 | If necessary, the length of t is automatically adjusted as a consequence 85 | of the shortened Markov chain, an a new vector t_new returned. 86 | 87 | Example 88 | --------------------------------------------------------------------------- 89 | P, t = lp(z, q, k=5, tol=1e-12, extra_precision=True) 90 | 91 | Generate a Markov chain of length k-1 = 4, if possible, otherwise shorter. 92 | Use stricter tolerance for the solutions and impose upper bound 1. 93 | With len(z) = len(q) = 14, matrices P[0], P[1], and P[2] are well-defined 94 | but P[3] is not. P[3] occurs at the end of the chain, hence it cannot be 95 | interpolated. With P[3] scrapped, the returned Markov chain has length k-2, 96 | while the new vector t = [0, ..., 0.8] has length k-1. 97 | 98 | Resources 99 | --------------------------------------------------------------------------- 100 | [1] Curran, M. (2001). Willow Power: Optimizing Derivative Pricing Trees, 101 | ALGO Research Quarterly, Vol. 4, No. 4, p. 15, December 2001. 102 | [2] Ho, A.C.T. (2000). Willow Tree. MSc Thesis in Mathematics, University 103 | of British Columbia. 104 | ''' 105 | 106 | # Import required libraries 107 | import time 108 | import numpy as np 109 | from scipy import optimize 110 | 111 | def objective(z, a, beta, normalize): 112 | ''' 113 | Generate objective function c in equation (1) of the LP problem. 114 | Normalise c if array q was obtained with gamma != 0. 115 | ''' 116 | F = (np.abs(a-beta*a.transpose()) ** 3).transpose()\ 117 | .reshape(len(z)**2) 118 | c = F * normalize 119 | return c 120 | 121 | def beq(q, u, z, beta, Aeq): 122 | ''' 123 | Generate b_eq, the vector of linear equality constraints in equation 124 | (2) of the LP problem. 125 | ''' 126 | beq = np.array([u, beta*z, (beta**2)*r + (1-beta**2)*u, 127 | q]).reshape(len(Aeq)) 128 | return beq 129 | 130 | def transition_matrix(z, c, Aeq, beq, tol, extra_precision): 131 | ''' 132 | Compute transition matrix P, the solution to the LP problem--x, in 133 | equation (1). 134 | ''' 135 | # Place upper bound on each probability if extra_precision=True 136 | if extra_precision: 137 | bounds = (0, 1) 138 | else: 139 | bounds = (0, None) 140 | 141 | # Account for a different tolerance level, if specified 142 | options = {'maxiter': 1e4, 'tol': tol, 'disp': False} 143 | 144 | # Linear programming problem 145 | P = optimize.linprog(c, A_eq = Aeq, b_eq = beq, 146 | bounds = bounds, method = 'simplex', 147 | options = options) 148 | return P 149 | 150 | def test(n, P): 151 | ''' 152 | Test whether the transition matrix generated by the LP algorithm 153 | satisfies the sum(p(i,:)) = 1, for all rows i. Return False otherwise. 154 | ''' 155 | try: 156 | # Reshape P and perform test on each row; sum all columns (axis=1) 157 | P = P.reshape(n,n) 158 | return np.isclose(P.sum(axis=1), np.ones(n), 1e-6).all() == True 159 | except: 160 | return False 161 | 162 | def interpolate(P_min, P_max, alpha_min, alpha_max, alpha_interp): 163 | ''' 164 | Interpolate a bad transition matrix using Curran's method [1]. 165 | Return interpolated matrix. 166 | ''' 167 | x1 = 1 / np.sqrt(1+alpha_min) 168 | x2 = 1 / np.sqrt(1+alpha_max) 169 | x3 = 1 / np.sqrt(1+alpha_interp) 170 | 171 | coeff_min = (x3-x2) / (x1-x2) 172 | coeff_max = (x1-x3) / (x1-x2) 173 | 174 | return coeff_min*P_min + coeff_max*P_max 175 | 176 | ''' 177 | Store the user defined tolerance level in variable 'initial_tol'. This is 178 | the starting tolerance level for the solution to the LP problems. 179 | The variable does not change, and it is the starting point for each set of 180 | LP problems solved to determine a particular transition matrix. What varies 181 | is a new variable 'tol', which originally takes on the value 'initial_tol' 182 | but is then reduced by one degree of magnitude (e.g. from 1e-9 to 1e-8), 183 | up to 1e-2, until a solution is found. If 'tol' reaches 1e-2 and still no 184 | satisfactory solution is found, the transition matrix is labelled as badly 185 | defined, 'tol' is again set to 'initial_tol', and a new set of LP problems 186 | is run to determine the next transition matrix. 187 | ''' 188 | initial_tol = tol 189 | 190 | # Set n as the number of space nodes 191 | n = len(z) 192 | 193 | # Generate the array of time nodes from k, the desired no. of time steps 194 | t = np.linspace(0, 1, k + 1) 195 | 196 | # Define auxiliary variables for c, Aeq, beq [2] 197 | u = np.ones(n, dtype = np.int) 198 | r = z ** 2 199 | h = t[2:] - t[1:-1] 200 | alpha = h / t[1:-1] 201 | beta = 1 / np.sqrt(1+alpha) 202 | 203 | ''' 204 | Define auxiliary variables for c, the objective function. Normalise the 205 | objective, if necessary (if q was determined using gamma != 0). 206 | ''' 207 | a = z[:, np.newaxis] @ np.ones(n)[np.newaxis] 208 | normalize = np.kron(q, np.ones(n)) 209 | 210 | # Determine c, the objective function for each LP problem 211 | c = np.array([objective(z, a, beta[i], normalize) \ 212 | for i in range(len(h))]) 213 | 214 | # Determine Aeq, the matrix of linear equality constraints [2] 215 | Aeq = np.vstack([np.kron(np.eye(n), u), 216 | np.kron(np.eye(n), z), 217 | np.kron(np.eye(n), r), 218 | np.kron(q, np.eye(n))]) 219 | 220 | # Determine beq, the array of linear equality constraints [2] 221 | beq = np.array([beq(q, u, z, beta[i], Aeq) \ 222 | for i in range(len(h))]) 223 | 224 | # Preallocate memory for the 3-dim (or 2-dim) Markov chain 225 | Px = np.array([np.zeros([n, n]) \ 226 | for i in range(len(h))]) 227 | 228 | ''' 229 | Initialise 1-dim array 'flag' of length h, the one assumed for the full 230 | Markov chain. By construction, 'flag' has null components, which are 231 | either left unmodified if the procedure to find a transition matrix is 232 | successful, or set to -1 otherwise. 233 | ''' 234 | flag = np.zeros(len(h), dtype = np.int) 235 | 236 | ''' 237 | Begin procedure to find transition matrix, initially shaped as a 1-dim 238 | array to speed computation. 239 | ''' 240 | for i in range(len(h)): 241 | # Run one initial LP problem 242 | P = transition_matrix(z, c[i], Aeq, beq[i], tol, 243 | extra_precision) 244 | 245 | # If the returned matrix != np.nan (exit != 2, see above), continue 246 | if type(P.x) != np.float: 247 | 248 | # Set timer. This will prevent the LP from being too time consuming 249 | start = time.time() 250 | 251 | ''' 252 | Continue looking for a feasible solution unless one of the 253 | following occurs: 254 | * exit == 0 (satisfactory solution found); 255 | * objective function in [0;1] (well-behaved matrix); 256 | * all p(k; i,j) probabilities in [0;1]; 257 | * the tests for mean, variance, and kurtosis (if applicable) are 258 | all passed; 259 | ''' 260 | while (P.status != 0) | (P.fun < 0) | (P.fun > 1) \ 261 | | ((P.x[P.x < 0]).any()) | ((P.x[P.x > 1]).any()) \ 262 | | (test(n, P.x) != True): 263 | 264 | ''' 265 | If no satisfactory solution is found, and if both of the 266 | following apply: 267 | * tolerance level still smaller than 1e-3; and 268 | * the elapsed time is less than one minute. 269 | Increase tolerance by one order of magnitude and proceed with 270 | a new LP problem. 271 | ''' 272 | if (tol < 1e-3) & (time.time() - start < 60): 273 | tol *= 10 274 | P = transition_matrix(z, c[i], Aeq, beq[i], tol, 275 | extra_precision) 276 | else: 277 | # Break process and set flag to -1 (bad matrix) otherwise 278 | flag[i] = -1 279 | break 280 | 281 | # Reshape array solution to 2-dim transition matrix 282 | Px[i] = P.x.reshape(n, n) 283 | 284 | else: 285 | ''' 286 | If the returned matrix is np.nan (exit == 2), set flag to -1 287 | (badly defined) and pass to following matrix in the chain. 288 | ''' 289 | flag[i] = -1 290 | 291 | # Inform user of the quality of the solution 292 | if flag[i] == -1: 293 | print('Warning: P[{}] wrongly specified.'.format(i)) 294 | print('Replacing with interpolated matrix if possible.') 295 | else: 296 | print('P[{}] successfully generated.'.format(i)) 297 | 298 | ''' 299 | Set tolerance back to initial level for each new transition matrix to 300 | determine. 301 | ''' 302 | tol = initial_tol 303 | 304 | ''' 305 | Store the positions of all -1 flags in a new array 'failure' and those 306 | of all 0 flags in a new array 'success'. 307 | ''' 308 | failure = np.nonzero(flag)[0] 309 | success = np.nonzero(flag + 1)[0] 310 | 311 | try: 312 | ''' 313 | Initialise empty arrays 'minvec' and 'maxvec'. These arrays will be 314 | useful to determine which matrices to use in order to interpolate the 315 | badly defined transition matrices. Each component of 'minvec' and 316 | 'maxvec' is, respectively, a lower and an upper bound for a bad matrix. 317 | For example, suppose the Markov chain is made of four matrices (k = 5) 318 | and the flag vector is as such: 319 | 320 | flag = np.array([0, -1, 0, -1]) 321 | 322 | This means that the first and third matrices are well-defined, whereas 323 | the second and last ones are not. Arrays 'failure' and 'success' will 324 | thus be: 325 | 326 | failure = np.array([1, 3]) 327 | success = np.array([0, 2]) 328 | 329 | That is, well-defined matrices occur at position 0 and 2; bad ones at 330 | positions 1 and 3. 331 | 332 | 'minvec' and 'maxvec' will then be: 333 | 334 | minvec = np.array([0, 2]) 335 | maxvec = np.array([2]) 336 | 337 | So, the first bad matrix (position 1) has two well-defined adjacent 338 | matrices: one in 0 (minvec element 0), the other in 2 (maxvec element 339 | 2). The second one (position 3) has only one well-defined adjacent 340 | matrix, at position 2 (minvec element 2). 341 | 342 | As a consequence, it is possible to interpolate the first matrix using 343 | the adjacent arrays, but the second one needs to be scrapped. 344 | ''' 345 | minvec = np.array([], dtype = np.int) 346 | maxvec = minvec 347 | 348 | ''' 349 | To retrieve 'minvec', start from the end of the chain and proceed 350 | backwards, to avoid errors due to bad matrices at the beginning of 351 | the chain, if any. 352 | ''' 353 | for i in reversed(range(len(failure))): 354 | minvec = np.append(minvec, [max(x for x in success \ 355 | if x < failure[i])]) 356 | 357 | # Sort ascending the resulting vector, which was computed backwards 358 | minvec.sort() 359 | except ValueError: 360 | pass 361 | 362 | ''' 363 | Try separately for 'minvec' and 'maxvec', to prevent errors in one which 364 | would not occur also in the other. 365 | ''' 366 | try: 367 | ''' 368 | To retrieve 'maxvec', start from the beginning of the chain to avoid 369 | errors due to bad matrices at the end of the chain, if any. 370 | ''' 371 | for i in range(len(failure)): 372 | maxvec = np.append(maxvec, [min(x for x in success \ 373 | if x > failure[i])]) 374 | except ValueError: 375 | pass 376 | 377 | ''' 378 | The following lines of code align the length of 'minvec' and 'maxvec' to 379 | that of 'flag'. The purpose is to univocally assign unique minimum and 380 | maximum values to each bad transition matrix, to identify the indices of 381 | the adjacent matrices to be used in the interpolation step. An example 382 | should clarify. Consider the following 'flag' vector: 383 | 384 | flag = np.array([-1, 0, 0, -1, -1, 0, -1, 0]) 385 | 386 | The vector signals that there are bad transition matrices at positions 0, 387 | 3-4, and 6. Before aligning size, 'minvec' and 'maxvec' are: 388 | 389 | minvec = np.array([2, 2, 5]) 390 | maxvec = np.array([5, 5, 7]) 391 | 392 | After aligning size, 'minvec' and 'maxvec' (now 'repl_min' and 'repl_max') 393 | become: 394 | 395 | repl_min = np.array([-1, -1, -1, 2, 2, -1, 5, -1]) 396 | repl_max = np.array([-1, -1, -1, 5, 5, -1, 7, -1]) 397 | 398 | This way, if possible, each bad matrix is univocally assigned two indices, 399 | a minimum and a maximum, corresponding to the positions of the matrices to 400 | use in the interpolation step. However, only three out of four bad matrices 401 | have indices assigned (the beginning one has no minimum, therefore it will 402 | be automatically scrapped). 403 | ''' 404 | repl_min = np.full(len(flag), -1, dtype=np.int) 405 | repl_max = np.full(len(flag), -1, dtype=np.int) 406 | 407 | ''' 408 | Replace -1 components in 'repl_min', 'repl_max', at positions corresponding 409 | to the values in 'minvec', 'maxvec', with positive numbers 1, then to be 410 | replaced by the actual components of 'minvec', 'maxvec' using masking. 411 | ''' 412 | for i in failure: 413 | repl_min[i] = 1 414 | repl_max[i] = 1 415 | 416 | ''' 417 | Uniform 'minvec' length to that of 'repl_min'. Only pad array with -1 on 418 | the left. Replace 'repl_min' components 1 with actual 'minvec' values. 419 | ''' 420 | minvec = np.pad(minvec, ((len(failure)-len(minvec)),0), 421 | mode='constant', constant_values=-1) 422 | repl_min[repl_min>0] = minvec 423 | 424 | ''' 425 | Uniform 'maxvec' length to that of 'repl_max'. Only pad array with -1 on 426 | the right. Replace 'repl_max' components 1 with actual 'maxvec' values. 427 | ''' 428 | maxvec = np.pad(maxvec, (0,len(failure) - len(maxvec)), 429 | mode='constant', constant_values=-1) 430 | repl_max[repl_max>0] = maxvec 431 | 432 | succ_vec = (repl_min > -1) & (repl_min < repl_max) 433 | succ_vec = np.array([1 if succ_vec[i] == True else 0 for i \ 434 | in range(len(succ_vec))]) 435 | 436 | try: 437 | threshold_low = np.argwhere(succ_vec)[0,0] 438 | threshold_high = np.argwhere(succ_vec)[-1,0] 439 | except: 440 | threshold_low, threshold_high = -1, -1 441 | 442 | failure = failure[(failure >= threshold_low) \ 443 | & (failure <= threshold_high)] 444 | 445 | minvec = repl_min * succ_vec 446 | maxvec = repl_max * succ_vec 447 | 448 | minvec = minvec[minvec > -1] 449 | maxvec = maxvec[maxvec > 0] 450 | 451 | ''' 452 | Interpolate bad matrices according to Curran's [1] methodology. If the 453 | interpolation is successful, replace negative flags with 0 (success), then 454 | substitute the matrices in the Markov chain. 455 | ''' 456 | if (flag == -1).any(): 457 | try: 458 | Px[failure] = [interpolate(Px[minvec[i]], Px[maxvec[i]], 459 | alpha[minvec[i]], alpha[maxvec[i]], 460 | alpha[failure[i]]) for i \ 461 | in range(len(failure))] 462 | except ValueError: 463 | pass 464 | 465 | for i in failure: 466 | print('Interpolation of P[{}] successful.'.format(i)) 467 | flag[failure] = 0 468 | else: 469 | pass 470 | 471 | success = np.nonzero(flag + 1)[0] 472 | Px = Px[success] 473 | 474 | ''' 475 | Resize array t in case the generated Markov chain is shorter, either at 476 | the beginning or at the end. 477 | ''' 478 | try: 479 | if success[0] == 0: 480 | t_new = t[range(len(success)+2)] 481 | else: 482 | t_new = np.append(0,t[(t >= t[success[0]+1]) \ 483 | & (t <= t[success[-1]+2])]) 484 | 485 | if t_new[1] != t[1]: 486 | print('Warning: t has been increased. t[1] = {:.2f}'\ 487 | .format(t_new[1])) 488 | if t_new[-1] != t[-1]: 489 | print('Warning: t has been shortened. T = {:.2f}'\ 490 | .format(t_new[-1])) 491 | except: 492 | t_new = t[:2] 493 | print('Warning: t has been shortened. T = {:.2f}'.format(t_new[-1])) 494 | 495 | return Px, t_new 496 | --------------------------------------------------------------------------------