# Introduction to modelx **modelx** is a Python package to develop, run, debug and save complex numerical models using Python, just like you do with a spreadsheet. modelx is best suited for models in such fields as actuarial science, quantitative finance and risk management, where the calculation logic is expressed in recursive formulas. modelx provides classes such as *Model*, *UserSpace* and *Cells*, for you to create their instances. *Model*, *UserSpace* and *Cells* are to modelx what *Workbook*, *Worksheet* and *Range* are to Excel. A *Cells* object can be created from Python functions, and act like a cached function. A *UserSpace* object serves as a contanier of *Cells* objects, and provides the namespace for the contained *Cells* objects. Here is a list of modelx's main features: - A *UserSpace* can be quickly parameterized with names defined it, so that you can make multiple copies of the *UserSpace* dynamically - Quickly parameterize a *UserSpace* with names defined in it, and create dynamic copies of it - Quickly build object-oriented models, utilizing inheritance and composition - Trace formula dependency for debugging - Import and use any Python modules, such as [Numpy][np], [pandas][pd], [SciPy][sp], [scikit-learn][sklearn], etc.. - See formula traceback upon error and inspect local variables - Save models to text files and version-control with [Git][git] - Save data such as pandas DataFrames in Excel or CSV files within models - Auto-document saved models by Python documentation generators, such as [Sphinx][sphinx] - Use Spyder with a plugin for modelx (spyder-modelx) to interface with modelx through GUI [np]: https://numpy.org/ [pd]: https://pandas.pydata.org/ [sp]: https://scipy.org/ [sklearn]: https://scikit-learn.org/ [git]: https://git-scm.com/ [sphinx]: https://www.sphinx-doc.org To feel that modelx makes our life easier, let's build a simple model using modelx. ## A Quick Tour of modelx ### Building and running a model Let's say we want to build a model that performs a Monte Carlo simulation to generate 10,000 stochastic paths of a stock price that follow a geometric Brownian motion and to price an European call option on the stock. For now, let's ignore the fact that a Black-Scholes formula would give the analytical solution. Later, we will check the analytical method gives the same answer. Here's the entire script for building the model using modelx. ```python import modelx as mx import numpy as np model = mx.new_model() # Create a new Model named "Model1" space = model.new_space("MonteCarlo") # Create a UserSpace named "MonteCarlo" # Define names in MonteCarlo space.np = np space.M = 10000 # Number of scenarios space.T = 3 # Time to maturity in years space.N = 36 # Number of time steps space.S0 = 100 # S(0): Stock price at t=0 space.r = 0.05 # Risk Free Rate space.sigma = 0.2 # Volatility space.K = 110 # Option Strike # Define Cells objects in MonteCarlo from function definitions @mx.defcells def std_norm_rand(): gen = np.random.default_rng(1234) return gen.standard_normal(size=(N, M)) @mx.defcells def stock(i): """Stock price at time t_i""" dt = T/N if i == 0: return np.full(shape=M, fill_value=S0) else: epsilon = std_norm_rand()[i-1] return stock(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5) @mx.defcells def call_opt(): """Call option price by Monte Carlo""" return np.average(np.maximum(stock(N) - K, 0)) * np.exp(-r*T) ``` After executing the code above from IPython console, calling `call_opt` gives the price of the European option. ```python >>> call_opt() 16.26919556999345 ``` `call_opt` is a *Cells* object, and it retains the returned value until it needs to be recalculated. It is not only `call_opt` that retains the returned value, but also all the intermediate values used to calculate the target `call_opt` are retained and available at no cost. ```python >>> stock(space.N) # Stock price at i=N i.e. t=T array([ 78.58406132, 59.01504804, 115.148291 , ..., 155.39335662, 74.7907511 , 137.82730703]) ``` If we want to see the option price for another strike, simply assign the new strike to ``K``. ```python >>> space.K = 100 # Cache is cleared by this assignment >>> call_opt() # New option price for the updated strike 20.96156962064 ``` You can dynamically create multiple copies of *MonteCarlo* with different combinations of ``r`` and ``sigma``, by parameterizing *MonteCarlo* with ``r`` and ``sigma``: ```python >>> space.parameters = ("r", "sigma") # Parameterize MonteCarlo with r and sigma >>> space[0.03, 0.15].call_opt() # Dynamically create a copy of MonteCarlo with r=3% and sigma=15% 14.812014828333284 >>> space[0.06, 0.4].call_opt() # Dynamically create another copy with r=6% and sigma=40% 33.90481014639403 ``` Having the two copies of *MonteCarlo* make it easy to perform such tasks as comparing the values of the same items, such as the option price, or the stock price at any time before or at maturity, with different parameters. The dynamic UserSpaces are immutable, and destroyed when the base *MonteCarlo* is updated. ### Closer look at the model Now, let's look back and take a closer look at the initial script to understand more about what was going on when we built the model. ```python import modelx as mx import numpy as np ``` The first `import` statement starts modelx behind the scene, and defines `mx`, an alias for the modelx modules for convenience. The second import statement should be familiar to most Python users. It imports the numpy module as `np` into the global namespace of the `__main__` module, which is the module that we are just working in. As we will see later, defining `np` in the global namespace of `__main__` doesn't make it available from the *Formulas*. By the next statement, we are creating a new *Model* object and assigning it to a name `model`. Since we don't give an explicit name to the `new_model` function, the model is named *Model1* by modelx. A *Model* object is to modelx what a *Workbook* is to Excel. It is the outermost container of all objects contained in it. Then the next statement creates a *UserSpace* object named *MonteCarlo* in the model. A *UserSpace* is to modelx what a *Worksheet* is to Excel. It is a container in which we are going to create *Cells* objects. ```python model = mx.new_model() # Create a new Model named "Model1" space = model.new_space("MonteCarlo") # Create a UserSpace named "MonteCralo" ``` A *Cells* object acts like a cached function. It can be called like a function, and the returned value is retained until it needs to be updated. A *Cells* object resembles a cell in Excel, but unlike Excel's cell, its formula can have parameters, so it can retain multiple values, one value for one set of parameter values. A *UserSpace* has another important role, aside from being the parent of containing *Cells*, which is to provide the namespace for the *Formulas* of the containing *Cells*. In this sense, a *UserSpace* resembles a Python module. A *Cells* object has an associated *Formula* object. The *Formula* object is essentially a Python function, except that it is not evaluated in the Python's global namespace, which is, in our case, `__main__`'s namespace, but instead, it is evaluated in the namespace provided by the parent *UserSpace*. You can define names in the *UserSpace*'s namespace by attribute assignment operations. The next block of code assigns values and objects we use in our model to names in the namespace of *MonteCarlo*. ```python # Define names in MonteCarlo space.np = np space.M = 10000 # Number of scenarios space.T = 3 # Time to maturity in years space.N = 36 # Number of time steps space.S0 = 100 # stock(0): Stock price at t=0 space.r = 0.05 # Risk Free Rate space.sigma = 0.2 # Volatility space.K = 110 # Option Strike ``` Internally, modelx keep these names and their values as *Reference* objects. The next part constructs the main body of our model's calculation logic. It creates 3 *Cells* objects, `std_norm_rand`, `stock` and `call_opt` in *MonteCarlo*. A *Cells* object acts like a cached function. It can be called like a function, and the returned value is retained until it needs to be updated. `defcells` is a convenience decorator for creating *Cells* objects quickly from function definitions. The first `def` statement with `defcells` decorator creates a *Cells* object named `std_norm_rand`, and assigns the object to the name `std_norm_rand` in the global namespace of `__main__`. In addition, the statement defines the *formula* property of the Cells object from the `std_norm_rand` function definition. The *formula* property holds the *Formula* object, which is essentially a copy of the decorated Python function, but the global names in the Formula refer to the values we just assigned above. The same goes with `stock` and `call_opt`. Note that within the definitions of the formulas, we can refer to the other Cells defined in *MonteCarlo* as well as the names defined above. Also note that we can refer to the names directly, without preceding object names and the dot. ```python # Create Cells objects in MonteCarlo and define their formulas from function definitions @mx.defcells def std_norm_rand(): gen = np.random.default_rng(1234) return gen.standard_normal(size=(N, M)) @mx.defcells def stock(i): """Stock price at time t_i""" dt = T/N if i == 0: return np.full(shape=M, fill_value=S0) else: epsilon = std_norm_rand()[i-1] return stock(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5) @mx.defcells def call_opt(): """Call option price by Monte Carlo""" return np.average(np.maximum(stock(N) - K, 0)) * np.exp(-r*T) ``` ### Debugging the model We often need to debug models we build to make sure their results are correct. modelx has features to help us with such debugging. One of such features is modelx's capability to trace calculation dependency. The `precedents` method on `Cells` returns a list of precedents for given arguments. The list contains *References* and *Nodes*, which represents *Cells* associated with arguments, that are used by the arguments and the Cells. Continuing from the above example, below shows the precedents of `call_opt()` and `stock(36)`. ```python >>> call_opt() # Make suer this is run. 20.96156962064 >>> call_opt.precedents() # Returns precedents of call_opt() [Model1.MonteCarlo.stock(i=36)= array([ 78.58406132, 59.01504804, 115.148291 , ..., 155.39335662, 74.7907511 , 137.82730703]), Model1.MonteCarlo.np=, Model1.MonteCarlo.N=36, Model1.MonteCarlo.K=100, Model1.MonteCarlo.r=0.05, Model1.MonteCarlo.T=3] >>> stock.precedents(36) # Reteruns precedents of stock(36) [Model1.MonteCarlo.std_norm_rand()= array([[-1.60383681, 0.06409991, 0.7408913 , ..., 0.82163882, -0.49991377, 1.17804635], [-0.67804259, 1.35072849, 2.07565699, ..., 0.32146055, -0.7599273 , 1.73113515], [-1.42381038, -0.36400253, -0.55303109, ..., 0.04814081, -1.19998129, -0.08490359], ..., [-2.12588633, -0.19431652, -1.68358751, ..., -0.3466555 , -0.10290633, -0.68737272], [-1.32955138, 0.28343894, -2.01866314, ..., 1.58520134, 0.30001717, -0.63270348], [ 2.02929671, -1.42904385, 0.26366402, ..., -0.05042656, 0.14542656, -0.21076562]]), Model1.MonteCarlo.stock(i=35)= array([ 69.72140666, 63.93061459, 113.12553545, ..., 155.45729533, 73.98023943, 139.16636139]), Model1.MonteCarlo.T=3, Model1.MonteCarlo.N=36, Model1.MonteCarlo.np=, Model1.MonteCarlo.M=10000, Model1.MonteCarlo.S0=100, Model1.MonteCarlo.r=0.05, Model1.MonteCarlo.sigma=0.2] ``` Conversely, `succs` method returns a list of *Nodes* that are using, for example, `sock(36)`: ```python >>> stock.succs(36) [Model1.MonteCarlo.call_opt()=20.96156962064] ``` Another feature of modelx makes it easy to trace errors. When an error is raised in a *Cells* call, modelx prints out the traceback of the call. Let's intentionally make `stock(10)` raise an error just before returning by inserting a `raise` statement. ```python @mx.defcells def stock(i): """Stock price at time t_i""" dt = T/N if i == 0: return np.full(shape=M, fill_value=S0) else: epsilon = std_norm_rand()[i-1] if i == 10: raise ValueError('Error raised') return stock(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5) ``` Execution of `call_opt` eventually reaches `stock(10)` when the error is raised. The error message prints out the traceback of the execution. ```python >>> call_opt() ... FormulaError: Error raised during formula execution ValueError: Error raised Formula traceback: 0: Model1.MonteCarlo.call_opt(), line 3 ... 25: Model1.MonteCarlo.stock(i=12), line 10 26: Model1.MonteCarlo.stock(i=11), line 10 27: Model1.MonteCarlo.stock(i=10), line 9 Formula source: def stock(i): """Stock price at time t_i""" dt = T/N if i == 0: return np.full(shape=M, fill_value=S0) else: epsilon = std_norm_rand()[i-1] if i == 10: raise ValueError('Error raised') return stock(i-1) * np.exp((r - 0.5 * sigma**2) * dt + sigma * epsilon * dt**0.5) ``` In addition, the `trace_locals` function helps to inspect the values of the local variables held when the error is raised. ```python >>> mx.trace_locals() {'i': 10, 'dt': 0.08333333333333333, 'epsilon': array([-0.52430375, -1.29168268, 0.04276587, ..., -0.45993114, 1.33283969, 0.26335339])} ``` ### Saving the model A *Model* can be saved as files in a directory tree or as just one zip file, by `write` or `zip` method. ```python >>> model.write(r'C:\Users\mxuser\Model1') >>> model.zip(r'C:\Users\mxuser\Model1.zip') ``` The contents of the directory tree are written as a pseudo Python package, and the *UserSpaces* are output in `__init__.py` in sub directories as if they are Python modules, and the *Cells* are output as if they are Python functions. This means that we can version-control the output of the model using [Git][git] as if they are Python code, and auto-document it from the docstrings using a document generator, such as [Shpinx][sphinx]. ### Using modelx with Spyder [Spyder](https://www.spyder-ide.org/) is a popular open-source Python IDE, and it allows plugins to be installed to add extra features to itself. The Spyder plugin for modelx, which is avaialble as a separate pacakge, enriches user interface to modelx in Spyder. The plugin adds custom IPython consoles and GUI widgets for using modelx in Spyder. Using Spyder and the plugin, the sample model is shown as a tree in a GUI widget: ```{figure} /images/tutorial/Introduction/MonteCarloInSpyder.png --- align: center --- Sample model in Spyder ``` The widget makes it easy to edit the model. Other widgets installed by the plugin help to view data of modelx objects and analyze the dependency of them. For more on the plugin, see {doc}`/spyder`. (overview-of-modelx-objects)= ## Overview of modelx objects As we have seen in the quick tour above, modelx lets us build models composed of a few types of objects. *Model*, *UserSpace*, *Cells*, *Reference* are the most frequent types we use. In this section we briefly review these types of objects to have basic understanding of them. The diagram below illustrates containment relationships between those objects. ```{figure} /images/tutorial/Introduction/ModelStructure.png :align: center Models, Spaces and Cells ``` Models are the top level container objects. Models can be saved to files and loaded back again. Multiple models can be opened in one Python session at the same time. Within a model, we can create *UserSpace* objects. *UserSpace* objects are editable. There are read-only types of space objects, such as *ItemSpace* and *DynamicSpace*. For example, in the quick tour above, we have created two *ItemSpace* objects, `MonteCarlo[0.03, 0.15]` and `MonteCarlo[0.06, 0.4]`: ```python >>> space[0.03, 0.15].call_opt() # space refers to the MonteCarlo space 14.812014828333284 >>> space[0.06, 0.4].call_opt() 33.90481014639403 ``` Collectively, we call them Space objects, or just spaces, whether they are of the editable or read-only types. Spaces serve as containers, separating contents in the model into components. the spaces can contain *Cells*, *Reference* objects and other Space objects, allowing a tree structure to form within the model. The spaces also serve as the namespaces for the formulas associated to the spaces themselves or to the *Cells* objects contained in them. We call *Cells* objects just cells. A cells is an object that has one formula and can hold its value, just like spreadsheet cells can have formulas and values. But unlike spreadsheet cells, in modelx, a cells value is either calculated by its formula or assigned as an input by the user for each argument. When an input value is assigned by the user, its fomula is not calculated for the argument. *Reference* objects are names bound to arbitrary objects. We call *Reference* objects, references, or just refs. References can be defined either in spaces or in models. References defined in a space can be referenced from the formulas of the cells defined in the space, or the formula associated with the space. For example, `Cells1.formula` (and `Space1.formula` if any) can refer to `Ref2`. References defined in a model (for example *Ref1* in the diagram above) can be referenced from formulas defined anywhere in the model, unless other references override the name binding defined by the reference in the model.