Tutorial, Part 3¶
Object Model in Practice¶
We can use the modeling framework by inheriting from the object model and creating custom data sources, projection entities, projection values, and projections.
In the next few sections, we’ll see how this works with the sample annuity model.
About inheritance...
Inheritance is a mechanism where classes can have defined hierarchical relationships with other classes.
For example, in our previous note on classes, we defined an Account class
which models a bank account:
class Account:
_balance: float
def __init__(
self
):
self._balance = 0.0
def deposit(
self,
amount: float
) -> None:
self._balance += amount
Now suppose we want to define a special type of account that provides a 5% bonus on every deposit.
We can declare a new “bonus” Account class, derived from our original Account class like so:
class BonusAccount(
Account # "BonusAccount" is derived from "Account"
):
_bonus_rate: float
def __init__(
self
):
self._bonus_rate = 0.05
def deposit(
self,
amount: float
) -> None:
self._balance += amount * (1.0 + self._bonus_rate)
Note that:
We inherit all attributes from the super class. In this example, the super class is
Account, and we’ve inherited the_balanceanddepositattributes.We’ve declared a new
_bonus_rateattribute, that only exists inBonusAccountobjects (and not inAccountobjects).We’ve overridden the definition of the
depositfunction. The behavior ofdepositchanges depending on whether the object is anAccountor aBonusAccount.
Using inheritance, we can:
Minimize code re-use by inheriting code that is common between classes.
Extend the functionality of existing classes.
Change the functionality in existing classes.
Annuity Model Design and Construction¶
Let’s take what we’ve learned so far and see how that can be used to build our annuity model.
Data Sources¶
The Data Sources Root
In the previous tutorial, we mentioned that we define a single DataSourcesRoot object that holds all other data sources. We’ve done that for the annuity model here with
AnnuityDataSources:26class AnnuityDataSources( 27 DataSourcesRoot 28):
Note that
AnnuityDataSourcesinherits fromDataSourcesRoot:
Annuity Data Sources Attributes
By itself, a
DataSourcesRootobject doesn’t contain anything and isn’t particularly useful. To make it useful, we have to add external data connections. There are three types of objects that help us manage this:A
data_sourceobject, which is a Pythonic representation of external data. This is how data is connected to our model. Supported data formats are listed in thismodule.A
DataSourceNamespaceobject, which holds:data_sourceobjects.Other
DataSourceNamespaceobjects.DataSourceCollectionobjects.
These objects are declared as instance attributes before the model starts running, ahead of runtime.
Note
You might have noticed in the inheritance diagram above that
DataSourcesRootinherits fromDataSourceNamespace. This is becauseDataSourcesRootis a special case ofDataSourceNamespace.A
DataSourceCollection, which behaves very similarly to aDataSourceNamespace, except child objects are created on-the-fly while the model is running (during runtime), and are not known ahead of time.
To add one of these objects to our
AnnuityDataSourcesobject, we declare it as an attribute. For example, including this code in theconstructoradds aModelPointsobject namedmodel_pointsto ourAnnuityDataSourcesobject:76# Model points 77self.model_points = ModelPoints( 78 path=join( 79 self.path, 80 'model_points.json' 81 ) 82)
The Model Point File
We can take a closer look at the
ModelPointsobject to see exactly how data is loaded into the model. From the class definition:10class ModelPoints( 11 ModelPointsBase 12):
We see that
ModelPointsinherits fromModelPointsBase:
And further up the inheritance diagram, we see that
ModelPointsBaseinherits from bothDataSourceCollectionandDataSourceJsonFile.From this, we can conclude that
ModelPoints:Reads data from a JSON file.
Maintains a collection of child objects, determined at runtime.
In our model point file, our child objects are
ModelPointclasses. We know this by examining the constructor methods. ForModelPoints:35ModelPointsBase.__init__( 36 self=self, 37 path=path, 38 model_point_type=ModelPoint 39)
The constructor calls the constructor for
ModelPointsBase, passing inModelPointas themodel_point_typeparameter.Then in the constructor for
ModelPointsBase:49for data in [row[1] for row in self.cache.iterrows()]: 50 51 instance = model_point_type( 52 data=data 53 ) 54 55 self[instance.id] = instance
We see that we’re constructing and storing new instances of
model_point_typeby looping through the cache. In this case, (since we’ve inherited fromDataSourceJsonFile), the cache contains elements from the JSON file and we’re passing those into theModelPointconstructor.A Model Point
Now let’s take a look at a
ModelPointwithin the model point file:
From the inheritance diagram, we see that
ModelPointinherits fromModelPointBase.Inside
ModelPointBase, we see anidproperty:36 @property 37 def id( 38 self 39 ) -> str: 40 41 """ 42 Unique identifier for this model point. This could be a Policy Number, integer, 43 `GUID <https://en.wikipedia.org/wiki/Universally_unique_identifier>`_, 44 or any other unique code. 45 46 :return: Model point ID. 47 """ 48 49 return self.cache[DEFAULT_COL]['id']
Note that this property returns a value from the cache. This is how data makes its way into the model. When the data source is initialized, it loads raw data into a cache. Then the model developer defines an attribute that reads the cache and returns data.
Note
Why so complicated? Why not read data directly from the file and just use raw data in the model? We do this because:
Write once, use everywhere. Once we’ve written this logic, we can use it everywhere in the model with zero duplicate code.
Abstraction. Other model developers do not need to know:
How the cache is loaded (From a CSV file? A database? An XML file from a REST API?).
How the cache is structured (What columns represent what data? What do the row indexes mean?).
They can simply take a data source for granted and just use it. This also lends itself to parallel development, where one model developer can implement data sources while another implements model logic.
Zooming Out
Now we’ve seen the end-to-end process for one unit of data (in pink), here’s the model inputs package in its entirety:
![digraph {
edge [dir="back"];
node [fontname="Arial"];
rankdir="LR";
AnnuityDataSources [shape="box3d"];
EconomicScenarios [shape="folder"];
EconomicScenario [shape="cylinder"];
get_rate [shape="ellipse"];
ModelPoints [shape="folder"];
ModelPoint [shape="cylinder"];
id_mp [label="id" shape="ellipse" fillcolor="darksalmon" style="filled"];
product_type [shape="ellipse"];
product_name [shape="ellipse"];
issue_date [shape="ellipse"];
Annuitants [shape="folder"];
Annuitant [shape="cylinder"]
id_annuitant [label="id" shape="ellipse"];
gender [shape="ellipse"];
date_of_birth [shape="ellipse"];
Riders [shape="folder"];
Gmwb [shape="folder"];
rider_type [shape="ellipse"];
rider_name [shape="ellipse"];
benefit_base [shape="ellipse"];
first_withdrawal_date [shape="ellipse"];
Gmdb [shape="folder"];
Accounts [shape="folder"];
Account [shape="cylinder"];
id_account [label="id" shape="ellipse"];
account_type [shape="ellipse"];
account_name [shape="ellipse"];
account_value [shape="ellipse"];
account_date [shape="ellipse"];
Premiums [shape="folder"];
Premium [shape="cylinder"];
premium_date [shape="ellipse"];
premium_amount [shape="ellipse"];
Mortality [shape="folder"];
BaseMortality [shape="cylinder"];
base_mortality_rate [shape="ellipse"];
MortalityImprovement [shape="cylinder"];
mortality_improvement_rate [shape="ellipse"];
MortalityImprovementDates [shape="cylinder"];
mortality_improvement_start_date [shape="ellipse"];
mortality_improvement_end_date [shape="ellipse"];
PolicyholderBehaviors [shape="folder"];
BaseLapse [shape="cylinder"];
base_lapse_rate [shape="ellipse"];
ShockLapse [shape="cylinder"];
shock_lapse_multiplier [shape="ellipse"];
Annuitization [shape="cylinder"];
annuitization_rate [shape="ellipse"];
Product [shape="folder"];
BaseProduct [shape="folder"];
SurrenderCharge [shape="cylinder"];
surrender_charge_rate [shape="cylinder"];
cdsc_period [shape="cylinder"];
CreditingRate [shape="folder"];
FixedCreditingRate [shape="cylinder"];
crediting_rate [shape="ellipse"];
IndexedCreditingRate [shape="cylinder"];
index [shape="ellipse"];
term [shape="ellipse"];
cap [shape="ellipse"];
spread [shape="ellipse"];
participation_rate [shape="ellipse"];
floor [shape="ellipse"];
GmdbRider [shape="folder"];
GmdbCharge [shape="cylinder"];
charge_rate_gmdb [label="charge_rate" shape="ellipse"];
GmdbTypes [shape="cylinder"];
gmdb_type [shape="ellipse"];
GmwbRider [shape="folder"];
GmwbBenefit [shape="cylinder"];
av_active_withdrawal_rate [shape="ellipse"];
av_exhaust_withdrawal_rate [shape="ellipse"];
GmwbCharge [shape="cylinder"];
charge_rate_gmwb [label="charge_rate" shape="ellipse"];
AnnuityDataSources -> EconomicScenarios;
EconomicScenarios -> EconomicScenario;
EconomicScenario -> get_rate;
AnnuityDataSources -> ModelPoints;
ModelPoints -> ModelPoint;
ModelPoint -> id_mp;
ModelPoint -> product_type;
ModelPoint -> product_name;
ModelPoint -> issue_date;
ModelPoint -> Annuitants;
Annuitants -> Annuitant;
Annuitant -> id_annuitant;
Annuitant -> gender;
Annuitant -> date_of_birth;
ModelPoint -> Riders;
Riders -> Gmwb;
Gmwb -> rider_type;
Gmwb -> rider_name;
Gmwb -> benefit_base;
Gmwb -> first_withdrawal_date;
Riders -> Gmdb;
Gmdb -> rider_type;
Gmdb -> rider_name;
ModelPoint -> Accounts;
Accounts -> Account;
Account -> id_account;
Account -> account_type;
Account -> account_name;
Account -> account_value;
Account -> account_date;
Account -> Premiums;
Premiums -> Premium;
Premium -> premium_date;
Premium -> premium_amount;
AnnuityDataSources -> Mortality;
Mortality -> BaseMortality;
BaseMortality -> base_mortality_rate;
Mortality -> MortalityImprovement;
MortalityImprovement -> mortality_improvement_rate;
Mortality -> MortalityImprovementDates;
MortalityImprovementDates -> mortality_improvement_start_date;
MortalityImprovementDates -> mortality_improvement_end_date;
AnnuityDataSources -> PolicyholderBehaviors;
PolicyholderBehaviors -> BaseLapse;
BaseLapse -> base_lapse_rate;
PolicyholderBehaviors -> ShockLapse;
ShockLapse -> shock_lapse_multiplier;
PolicyholderBehaviors -> Annuitization;
Annuitization -> annuitization_rate;
AnnuityDataSources -> Product;
Product -> BaseProduct;
BaseProduct -> SurrenderCharge;
SurrenderCharge -> surrender_charge_rate;
SurrenderCharge -> cdsc_period;
BaseProduct -> CreditingRate;
CreditingRate -> FixedCreditingRate;
FixedCreditingRate -> crediting_rate;
CreditingRate -> IndexedCreditingRate;
IndexedCreditingRate -> index;
IndexedCreditingRate -> term;
IndexedCreditingRate -> cap;
IndexedCreditingRate -> spread;
IndexedCreditingRate -> participation_rate;
IndexedCreditingRate -> floor;
Product -> GmdbRider;
GmdbRider -> GmdbCharge;
GmdbCharge-> charge_rate_gmdb;
GmdbRider -> GmdbTypes;
GmdbTypes -> gmdb_type;
Product -> GmwbRider;
GmwbRider -> GmwbBenefit;
GmwbBenefit -> av_active_withdrawal_rate;
GmwbBenefit -> av_exhaust_withdrawal_rate;
GmwbRider -> GmwbCharge;
GmwbCharge -> charge_rate_gmwb;
}](_images/graphviz-889b47326b4cde68cf28395440251e123c59fe69.png)
Note
To-Do’s
There may be a way to combine
DataSourceNamespaceandDataSourceCollectioninto a single class that supports both static and dynamic child objects.Currently, the modeling framework only supports file-based or Python-based
DataSourceBaseobjects, listed in thedata_sourcemodule. In the future, we can add support for more data formats (like databases or data feeds), leveraging the Python community’s vast libraries of open-source code.
Projection Entities¶
Model Overview
![digraph {
edge [dir="back"];
node [fontname="Arial", shape="Box"];
Riders [shape="tab"];
Accounts [shape="tab"];
Premiums [shape="tab"];
Annuitants [shape="tab"];
"Economy";
"Base Contract" -> Annuitants;
Annuitants -> "Annuitant";
"Base Contract" -> Riders;
Riders -> "GMWB";
Riders -> "GMDB MAV";
Riders -> "GMDB RAV";
Riders -> "GMDB ROP";
"Base Contract" -> Accounts;
Accounts -> Fixed -> Premiums;
Accounts -> Indexed -> Premiums;
Accounts -> Separate -> Premiums;
Premiums -> Premium;
}](_images/graphviz-ee93f1743674269b1a24f026f961d40256edd688.png)
The annuity model consists of two top-level
ProjectionEntityobjects:Economy- the economic environment for this projection.BaseContract- the annuity contract in this projection.
The
BaseContractcontains several nestedProjectionEntityobjects:Annuitants- the annuitant(s) in this projection.Annuitant- a single annuitant in this projection.
A list of elected riders:
Gmwb- Guaranteed Minimum Withdrawal Benefit (GMWB) rider.GmdbMav- Guaranteed Minimum Death Benefit (GMDB) rider, with ratchet option.GmdbRav- GMDB rider, with return of account value option.GmdbRop- GMDB rider, with return of premium option.
Note
Riders are optional. It is possible to have a
BaseContractwith no riders.A list of accounts:
FixedAccount- Fixed interest crediting account. Typically used for Fixed Annuity contracts, but available for all contracts.IndexedAccount- Indexed strategy crediting account, typically used for Fixed Indexed Annuity contracts.SeparateAccount- Separate crediting account, typically used for Variable Annuity contracts.
Each account also maintains a list of
Premium‘s.Note
A policy must have at least one account.
It is possible for a policy to have multiple accounts of the same type. For example, a policy could have two Fixed accounts that credit interest at different rates.
Fixed Account Deep Dive
Navigating to the Account Class
To see how a
ProjectionEntityworks, let’s take a look at theFixedAccount:14class FixedAccount( 15 Account 16):
From the inheritance diagram:

We see that a
FixedAccountinherits from anAccount.Account Super Classes
Let’s take a close look at the
Accountobject. Starting with the class definition:14class Account( 15 ProjectionEntity, 16 ABC 17):
There are two super classes:

Line 15 states that an
Accountinherits fromProjectionEntity, so anAccountis a type ofProjectionEntity.Line 16 states that an
Accountalso inherits from ABC, which means that this class is an ABstract Class. Abstract classes cannot be used to create instances, and are typically used to represent an abstract object (in this case, an abstract account). Since this class is an abstract class, it can only be used through inheritance.
Premium Tracking Within an Account
Moving down into the constructor, we declare a
list of premiums:69self.premiums = self._get_new_premiums( 70 t1=self.init_t 71)
Where
_get_new_premiumsreturns a list of premiums paid atinit_t. This attribute stores all premiums that are (and will be) paid into the account, and will grow as this projection entity is projected into the future.Declaring Projection Values
Further down the constructor, we declare
ProjectionValueobjects. For example:80self.premium_cumulative = ProjectionValue( 81 init_t=self.init_t, 82 init_value=self._calc_total_premium() 83) 84 85self.interest_credited = ProjectionValue( 86 init_t=self.init_t, 87 init_value=0.0 88)
These two attributes (along with other
ProjectionValueobjects) represent key values that we’re interested in tracking. In the code above:premium_cumulativetracks the cumulative premiums paid into theAccount. Its initial value is provided by the_calc_total_premiummethod, and is set at timeinit_t.interest_creditedtracks the point-in-time interest paid into theAccount. Its initial value is set to0.0at timeinit_t.
Declaring Methods
ProjectionEntityobjects typically declare methods that calculate and updateProjectionValueobjects.For example:
80def process_withdrawal( 81 self, 82 withdrawal_amount: float 83) -> None: 84 85 """ 86 Reduces :attr:`account value <account_value>` by a withdrawal amount 87 and records the :attr:`withdrawal amount <withdrawal>`. 88 89 .. warning: 90 This algorithm does not check if the withdrawal amount is greater than the account value. 91 92 :param withdrawal_amount: Withdrawal amount. 93 :return: Nothing. 94 """ 95 96 self.withdrawal[self.time_steps.t] = withdrawal_amount 97 self.account_value[self.time_steps.t] = self.account_value - self.withdrawal
This method:
Sets the
withdrawal_amountProjectionValueat timet.Note
The withdrawal amount is calculated somewhere outside the method and passed in as a method argument.
Sets the
withdrawal_amountProjectionValueat timet, by subtracting twoProjectionValue‘s with each other.
Overriding Methods
In the previous section we’ve seen how to declare a method that interacts with class attributes. The
Accountclass also contains acredit_interest()abstract method:219 @abstractmethod 220 def credit_interest( 221 self 222 ) -> None: 223 224 """ 225 Abstract method that represents an interest crediting mechanism. Inherit and override to implement 226 a custom crediting algorithm (e.g. RILA, separate account crediting, or indexed crediting). 227 228 :return: Nothing. 229 """ 230 231 ...
An abstract method declares that a method should exist, and what arguments the method should take, but doesn’t provide an implementation for the method. Note:
There is an abstractmethod decorator over method name.
The function body is empty and only contains ellipsis.
You can think of an abstract method as a “placeholder” within an abstract class, where the implementation is provided by one or more derived classes.
In this case,
FixedAccountis a derived class that inherits fromAccount:
If we go to
FixedAccount, we seeFixedAccount‘s implementation ofcredit_interest():36 def credit_interest( 37 self 38 ) -> None: 39 40 r""" 41 Credits interest to the sub\-account, where the fixed crediting rate is from 42 :meth:`~src.data_sources.annuity.product.base.crediting_rate.fixed.FixedCreditingRate.crediting_rate`. 43 44 .. math:: 45 interest \, credited = account \, value \times crediting \, rate \times years \, elapsed 46 47 :math:`years \, elapsed` is calculated using :func:`~src.system.date.calc_partial_years`. 48 49 :return: Nothing 50 """ 51 52 crediting_rate = self.data_sources.product.base_product.crediting_rate.fixed.crediting_rate( 53 account_name=self.account_data_source.account_name 54 ) 55 56 partial_years = calc_partial_years( 57 dt1=self.time_steps.t, 58 dt2=self.time_steps.prev_t 59 ) 60 61 crediting_rate *= partial_years 62 63 self.interest_credited[self.time_steps.t] = self.account_value * crediting_rate 64 65 self.account_value[self.time_steps.t] = self.account_value + self.interest_credited
When the
credit_interest()method is called, it will use different implementations, depending on the derived class. We just looked atFixedAccount‘s implementation, but other implementations exist forSeparateAccountandIndexedAccountas well.
Note
Why do we do this?
There is a lot of common logic between the various account types. For example, each account processes premiums, assesses charges, and takes withdrawals in the exact same way. This allows us to put all the common account logic in one “generic” account, then inherit and override to create “specialized” types of accounts.
We can use this generalize / specialize technique for many objects within an actuarial model, and programming in general.
Note
To-Do’s
The underlying model output storage format is a Pandas Dataframe and the model generates its output *.csv files using the DataFrame.to_csv method. However, there are many more output formats that can be implemented by leveraging the power of DataFrames, including:
Projection¶
The Annuity Economic Liability Projection
We’ve defined inputs for our projection and we’ve defined objects for our projection. The last thing to do is link everything together in a
Projection.To see how that works, let’s take a look at the
EconomicLiabilityProjectionclass. Starting from the top:16 class EconomicLiabilityProjection( 17 Projection 18 ):
We can see
EconomicLiabilityProjectionis a type ofProjection, since it inherits fromProjection:
Moving down into the constructor, we declare our top-level
ProjectionEntityobjects:41 self.economy = Economy( 42 time_steps=self.time_steps, 43 data_sources=self.data_sources 44 ) 45 46 self.base_contract = BaseContract( 47 time_steps=self.time_steps, 48 data_sources=self.data_sources 49 )
Defining the Order of Operations
Now that our
ProjectionEntityobjects have been declared in our projection, we can define our projection’s order of operations (within a time step) by overriding theproject_time_step()method. Here’s theoverriden method:99 def project_time_step( 100 self 101 ) -> None: 102 103 """ 104 Annuity Economic Liability Projection transaction order and method calls within a single time step. 105 106 :return: Nothing. 107 """ 108 109 self.economy.age_economy() 110 111 self.base_contract.age_contract() 112 113 self.base_contract.process_premiums() 114 115 self.base_contract.credit_interest() 116 117 self.base_contract.assess_charges() 118 119 self.base_contract.process_withdrawals() 120 121 self.base_contract.update_gmdb_naar() 122 123 self.base_contract.update_cash_surrender_value() 124 125 self.base_contract.annuitants.update_decrements()
The
project_time_step()method calls other methods within the top-level projection entities. This defines the order of operations within a single time step. Then therun_projection()method calls theproject_time_step()at each time step:96 def run_projection( 97 self 98 ) -> None: 99 100 """ 101 Runs the main projection loop, projecting forward one time step at a time. 102 :ref:`Override <inheritance_override>` this method to create a custom projection loop. 103 104 :return: Nothing. 105 """ 106 107 for _ in self.time_steps: 108 109 self.project_time_step() 110 111 if self.halt_projection(): 112 113 break
Which pushes the projection forward in time.
Note
To-Do’s
Projections can currently only be run in:
Single-process mode, where all projections are run on a single CPU core, or
Multi-process mode, where projections are split across multiple CPU cores.
In the future, we can think of leveraging other processing technologies like:
Dask, where projections are distributed across multiple CPU cores on multiple machines in a grid, or
Codon , where projections are compiled with LLVM and distributed across multiple GPU cores.
What’s Next?¶
These tutorials aim to provide a high-level understanding of the modeling framework, but don’t cover every detail or bit of functionality. For a complete reference, please consult the Model Documentation for documentation on the annuity sample model, or System Documentation for documentation on the modeling framework.
Thanks for reading!