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:

  1. We inherit all attributes from the super class. In this example, the super class is Account, and we’ve inherited the _balance and deposit attributes.

  2. We’ve declared a new _bonus_rate attribute, that only exists in BonusAccount objects (and not in Account objects).

  3. We’ve overridden the definition of the deposit function. The behavior of deposit changes depending on whether the object is an Account or a BonusAccount.

Using inheritance, we can:

  1. Minimize code re-use by inheriting code that is common between classes.

  2. Extend the functionality of existing classes.

  3. 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

  1. 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 AnnuityDataSources inherits from DataSourcesRoot:

    Inheritance diagram of src.data_sources.annuity.AnnuityDataSources
  2. Annuity Data Sources Attributes

    By itself, a DataSourcesRoot object 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:

    1. A data_source object, which is a Pythonic representation of external data. This is how data is connected to our model. Supported data formats are listed in this module.

    2. A DataSourceNamespace object, which holds:

      1. data_source objects.

      2. Other DataSourceNamespace objects.

      3. DataSourceCollection objects.

      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 DataSourcesRoot inherits from DataSourceNamespace. This is because DataSourcesRoot is a special case of DataSourceNamespace.

    3. A DataSourceCollection, which behaves very similarly to a DataSourceNamespace, 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 AnnuityDataSources object, we declare it as an attribute. For example, including this code in the constructor adds a ModelPoints object named model_points to our AnnuityDataSources object:

    76# Model points
    77self.model_points = ModelPoints(
    78    path=join(
    79        self.path,
    80        'model_points.json'
    81    )
    82)
    
  3. The Model Point File

    We can take a closer look at the ModelPoints object to see exactly how data is loaded into the model. From the class definition:

    10class ModelPoints(
    11    ModelPointsBase
    12):
    

    We see that ModelPoints inherits from ModelPointsBase:

    Inheritance diagram of src.data_sources.annuity.model_points.ModelPoints

    And further up the inheritance diagram, we see that ModelPointsBase inherits from both DataSourceCollection and DataSourceJsonFile.

    From this, we can conclude that ModelPoints:

    1. Reads data from a JSON file.

    2. Maintains a collection of child objects, determined at runtime.

    In our model point file, our child objects are ModelPoint classes. We know this by examining the constructor methods. For ModelPoints:

    35ModelPointsBase.__init__(
    36    self=self,
    37    path=path,
    38    model_point_type=ModelPoint
    39)
    

    The constructor calls the constructor for ModelPointsBase, passing in ModelPoint as the model_point_type parameter.

    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_type by looping through the cache. In this case, (since we’ve inherited from DataSourceJsonFile), the cache contains elements from the JSON file and we’re passing those into the ModelPoint constructor.

  4. A Model Point

    Now let’s take a look at a ModelPoint within the model point file:

    Inheritance diagram of src.data_sources.annuity.model_points.model_point.ModelPoint

    From the inheritance diagram, we see that ModelPoint inherits from ModelPointBase.

    Inside ModelPointBase, we see an id property:

    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:

    1. Write once, use everywhere. Once we’ve written this logic, we can use it everywhere in the model with zero duplicate code.

    2. Abstraction. Other model developers do not need to know:

      1. How the cache is loaded (From a CSV file? A database? An XML file from a REST API?).

      2. 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.

  5. 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;
}

Note

To-Do’s

  • There may be a way to combine DataSourceNamespace and DataSourceCollection into a single class that supports both static and dynamic child objects.

  • Currently, the modeling framework only supports file-based or Python-based DataSourceBase objects, listed in the data_source module. 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

  1. 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;
}

    The annuity model consists of two top-level ProjectionEntity objects:

    • Economy - the economic environment for this projection.

    • BaseContract - the annuity contract in this projection.

    The BaseContract contains several nested ProjectionEntity objects:

    • 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 BaseContract with 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.

  2. Fixed Account Deep Dive

    1. Navigating to the Account Class

      To see how a ProjectionEntity works, let’s take a look at the FixedAccount:

      14class FixedAccount(
      15    Account
      16):
      

      From the inheritance diagram:

      Inheritance diagram of src.projection_entities.products.annuity.base_contract.account.fa.FixedAccount

      We see that a FixedAccount inherits from an Account.

    2. Account Super Classes

      Let’s take a close look at the Account object. Starting with the class definition:

      14class Account(
      15    ProjectionEntity,
      16    ABC
      17):
      

      There are two super classes:

      Inheritance diagram of src.projection_entities.products.annuity.base_contract.account.Account
      • Line 15 states that an Account inherits from ProjectionEntity, so an Account is a type of ProjectionEntity.

      • Line 16 states that an Account also 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.

    3. 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_premiums returns a list of premiums paid at init_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.

    4. Declaring Projection Values

      Further down the constructor, we declare ProjectionValue objects. 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 ProjectionValue objects) represent key values that we’re interested in tracking. In the code above:

      1. premium_cumulative tracks the cumulative premiums paid into the Account. Its initial value is provided by the _calc_total_premium method, and is set at time init_t.

      2. interest_credited tracks the point-in-time interest paid into the Account. Its initial value is set to 0.0 at time init_t.

    5. Declaring Methods

      ProjectionEntity objects typically declare methods that calculate and update ProjectionValue objects.

      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:

      1. Sets the withdrawal_amount ProjectionValue at time t.

        Note

        The withdrawal amount is calculated somewhere outside the method and passed in as a method argument.

      2. Sets the withdrawal_amount ProjectionValue at time t, by subtracting two ProjectionValue ‘s with each other.

    6. Overriding Methods

      In the previous section we’ve seen how to declare a method that interacts with class attributes. The Account class also contains a credit_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:

      1. There is an abstractmethod decorator over method name.

      2. 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, FixedAccount is a derived class that inherits from Account:

      Inheritance diagram of src.projection_entities.products.annuity.base_contract.account.fa.FixedAccount

      If we go to FixedAccount, we see FixedAccount ‘s implementation of credit_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 at FixedAccount ‘s implementation, but other implementations exist for SeparateAccount and IndexedAccount as 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

Projection

  1. 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 EconomicLiabilityProjection class. Starting from the top:

    16  class EconomicLiabilityProjection(
    17      Projection
    18  ):
    

    We can see EconomicLiabilityProjection is a type of Projection, since it inherits from Projection:

    Inheritance diagram of src.projection_entities.products.annuity.base_contract.account.fa.FixedAccount

    Moving down into the constructor, we declare our top-level ProjectionEntity objects:

    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  )
    
  2. Defining the Order of Operations

    Now that our ProjectionEntity objects have been declared in our projection, we can define our projection’s order of operations (within a time step) by overriding the project_time_step() method. Here’s the overriden 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 the run_projection() method calls the project_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!

_images/dfa.png