高级用法#

警告

本节内容正在审核中,可能不是最新版本。

本节涵盖前几节未涉及到的更高级modelx概念和技术。

高级概念#

在本节中,我们将介绍更多尚未涉及的概念。其中部分概念会在下一节通过示例进行演示。

空间成员#

空间(Spaces)可以包含单元格(Cells)和其他空间。实际上,空间有三种类型的"成员"。您可以通过属性访问(.)表达式,将这些成员当作包含空间的属性来获取。

Cells

正如我们在前面的例子中看到的,空间包含单元格,而单元格属于空间。一个单元格必须且只能属于一个空间。

Space的cells属性返回一个字典,其中包含所有与其名称关联的单元格。

(Sub)spaces

如前所述,空间可以在另一个空间中创建。位于另一个空间内的空间称为包含空间的子空间。子空间有两种类型:静态子空间和动态子空间。

静态子空间是指手动创建的子空间,就像在模型中创建的那些一样。直接在模型下创建的空间与在空间下创建的静态空间之间没有区别,除了它们的父级类型不同。

您可以通过调用父空间的new_space方法来创建静态子空间:

model, space = new_model(), new_space()

space.new_space('Subspace1')

@defcells
def foo():
    return 123

您可以将子空间作为父空间的属性获取, 或通过访问父空间的 spaces 属性来获取:

>>> space.spaces['Subspace1'].foo()
123

>>> space.Subspace1.foo()
123

另一种子空间类型是动态子空间。 与静态子空间不同,动态子空间只能在空间内创建, 而不能直接在模型中创建。

动态空间是通过对其父空间进行调用(())或下标([])操作时即时创建的参数化空间。我们将在下一节结合空间继承的示例,更深入地探讨动态空间。

References

很多时候,您希望从空间中的单元格公式访问同一空间内除单元格或子空间之外的其他对象。 引用(References)是指绑定到任意对象的名称,这些对象可以从同一空间内访问:

model, space = new_model(), new_space()

@defcells
def bar():
    return 2 * n

bar 单元格引用了尚未定义的 n。 在未定义 n 的情况下调用 bar 会引发错误。 要定义引用 n,只需为 spacen 属性赋值即可:

>>> space.n = 3
>>> bar()
6

refs属性返回一个引用名称到其对象的映射:

>>> list(space.refs.keys())
['__builtins__', 'n', '_self']

默认情况下,__builtins___self 在任何空间中都已定义。实际上,__builtins__ 在模型中默认被定义为"全局"引用。全局引用是指在模型的任何空间中都可访问的名称。除了默认引用外,您还可以通过简单地将值赋给模型的属性来定义自己的引用:

>>> model.z = 4
>>> list(model.refs.keys())
['z', '__builtins__']

>>> list(space.refs.keys())
['z', '__builtins__', 'n', '_self']

__builtins__ 指向Python内置模块。 定义它是为了让单元格公式可以使用内置函数。 _self 指向空间本身。这使得单元格公式 能够访问其父空间。

如前所述,单元格的公式会在与其父空间关联的命名空间中进行计算。

空间的命名空间是将名称映射到空间成员的映射关系。 如前一节所述, 空间成员可以是空间的单元格、空间的子空间, 或是可从空间访问的引用。

下表按类型和子类型分类展示了该命名空间中的所有成员。

cells

self cells

在该空间内定义或重写的单元格

派生单元格

从基础空间之一继承的单元格

空间

自身空间

在空间中定义或覆盖的子空间

派生空间

从基础空间继承而来的子空间

引用 (refs)

自身引用

在空间中定义或覆盖的引用

派生引用

从基础空间之一继承的引用

全局引用

在父模型中定义的全局引用

本地引用

只有_self指向空间本身

parameters

(仅限动态空间) 空间参数

每种成员类型都有"自身"成员和"派生"成员。 这种区分源于下一节将解释的空间继承机制。

空间继承#

modelx中的继承功能类似于面向对象编程语言(如Python)中的类继承。通过充分利用空间继承,您可以将具有相似特征的多个空间组织成空间继承树,从而最大限度地减少重复的公式定义,保持模型的条理性和透明度,同时维护模型的完整性。

继承允许一个空间使用(继承)其他空间作为基础空间。 继承的空间被称为基础空间的派生空间。 基础空间中的单元格会自动复制到派生空间中。 在派生空间中,可以覆盖来自基础空间的单元格公式。 您还可以向派生空间添加单元格,这些单元格在任何基础空间中都不存在。

一个空间可以拥有多个基础空间,这被称为多重继承。 基础空间可以有自己的基础空间,派生空间与基础空间之间的关系构成了一个有向依赖图。 在多重继承的情况下,我们需要一种方法来排序基础空间以确定其优先级。 modelx采用与Python相同的基类排序算法,即C3超类线性化算法(又称C3方法解析顺序或MRO)。 以下链接供您深入了解C3 MRO时参考。

更复杂的示例#

让我们通过一个简单的寿险定价代码来看看继承是如何工作的。 首先,我们将创建一个非常简单的寿险模型作为一个空间,并将其命名为 Life。 然后,我们将在该空间中填充计算死亡人数和按年龄划分的剩余生存人数的单元格。

接下来,为了给定期寿险保单定价,我们将从Life空间派生出一个TermLife空间,并添加一些单元格来计算支付给被保险人的死亡保险金及其现值。

接下来,我们想为一份两全保险建模。由于两全保险除了定期寿险涵盖的身故保险金外,还会支付满期保险金,我们从TermLife派生出一个Endowment空间,并对benefits公式进行剩余调整。

创建Life空间#

以下是我们将构建为Life空间的生命模型的数学表示。

\[\begin{split}&l(x) = l(x - 1) - d(x - 1)\\ &d(x) = l(x) * q\end{split}\]

其中,\(l(x)\)表示年龄x时的存活人数, \(d(x)\)表示年龄x到x+1岁之间的死亡人数,\(q\)表示年死亡率 (为简化起见,我们暂时假设所有年龄段的死亡率恒定为0.003)。 在实际应用中,像l、d、q这样的单字母名称可能过于简短, 但我们在此使用它们仅是为了简化,正如它们经常出现在经典的精算教科书中。 另一个简化之处是,我们将起始年龄x设为50岁, 只是为了缩短输出结果。只要我们使用恒定的死亡率, 起始年龄是0岁还是50岁都不应该影响结果。 以下是这个生命模型的modelx代码:

model, life = new_model(), new_space('Life')

def l(x):
    if x == x0:
        return 100000
    else:
        return l(x - 1) - d(x - 1)

def d(x):
    return l(x) * q

def q():
    return 0.003

l, d, q = defcells(l, d, q)
life.x0 = 50

上面代码的倒数第二行与在每个函数定义前添加@defcells装饰器效果相同。这一行从Life空间中的3个函数创建了3个新单元格,并将名称ldq重新绑定到当前作用域中的这3个单元格。

你一定注意到了l(x)公式中引用了名称x0,但该名称尚未定义。 最后一行代码的作用是将x0定义为Life模型中的投保年龄,并为其赋值。

要检查这个空间,我们可以查看Life中的单元格值,如下所示:

>>> l(60)
97040.17769489168

>>> life.frame
                   l           d      q
x
 50.0  100000.000000  300.000000    NaN
 51.0   99700.000000  299.100000    NaN
 52.0   99400.900000  298.202700    NaN
 53.0   99102.697300  297.308092    NaN
 54.0   98805.389208  296.416168    NaN
 55.0   98508.973040  295.526919    NaN
 56.0   98213.446121  294.640338    NaN
 57.0   97918.805783  293.756417    NaN
 58.0   97625.049366  292.875148    NaN
 59.0   97332.174218  291.996523    NaN
 60.0   97040.177695         NaN    NaN
NaN              NaN         NaN  0.003

推导定期寿险空间#

接下来,我们将了解如何扩展这个空间来表示定期寿险保单。 为了简化问题,这里我们重点关注保额为1(以任意货币单位计)的单一保单。 基于这个假设,如果我们定义benefits(x)为被保险人在年龄x至x+1岁之间所支付保险金的签发时期望值,那么它应该等于被保险人在签发时点处于x至x+1岁之间的死亡概率。用数学表达式表示应为:

\[benefits(x) = d(x) / l(x0)\]

其中 \(l(x)\)\(d(x)\) 沿用前例中的相同定义,\(x0\) 表示保单的投保年龄。 我们进一步将年龄x时的给付现值定义为:

\[pv\_benefits(x) = \sum_{x'=x}^{x0+n}benefits(x')/(1+disc\_rate)^{x'-x}\]

n 表示以年为单位的保单期限,disc_rate 表示用于现值计算的折现率。

接续之前的代码,我们将从Life空间派生出TermLife空间,以添加保险金和现值计算。

term_life = model.new_space(name='TermLife', bases=life)

@defcells
def benefits(x):
    if x < x0 + n:
        return d(x) / l(x0)
    if x <= x0 + n:
        return 0

@defcells
def pv_benefits(x):
    if x < x0:
        return 0
    elif x <= x0 + n:
        return benefits(x) + pv_benefits(x + 1) / (1 + disc_rate)
    else:
        return 0

上面的示例中第一行创建了从Life空间派生的TermLife空间,通过将Life空间作为bases参数传递给模型的new_space方法。此时TermLife空间与其唯一的基础空间Life空间具有相同的单元格。

以下两个单元格定义(带有defcells装饰器的两个函数定义),用于添加Life空间中不存在的单元格。这些公式引用了尚未定义的名称,即ndisc_rate。我们需要在TermLife空间中定义这些名称。引用x0是从Life空间继承而来的。

term_life.n = 10
term_life.disc_rate = 0

通过检查TermLife空间,您将获得以下结果(DataFrame中的列顺序在您的屏幕上可能有所不同):

>>> term_life.pv_benefits(50)
0.02959822305108317

>>> term_life.frame

                d      q              l  pv_benefits  benefits
x
 50.0  300.000000    NaN  100000.000000     0.029598  0.003000
 51.0  299.100000    NaN   99700.000000     0.026598  0.002991
 52.0  298.202700    NaN   99400.900000     0.023607  0.002982
 53.0  297.308092    NaN   99102.697300     0.020625  0.002973
 54.0  296.416168    NaN   98805.389208     0.017652  0.002964
 55.0  295.526919    NaN   98508.973040     0.014688  0.002955
 56.0  294.640338    NaN   98213.446121     0.011733  0.002946
 57.0  293.756417    NaN   97918.805783     0.008786  0.002938
 58.0  292.875148    NaN   97625.049366     0.005849  0.002929
 59.0  291.996523    NaN   97332.174218     0.002920  0.002920
 60.0         NaN    NaN            NaN     0.000000  0.000000
 61.0         NaN    NaN            NaN     0.000000       NaN
NaN           NaN  0.003            NaN          NaN       NaN

可以看到ldq单元格的值与Life空间中的值相同,因为LifeLifeTerm对这些单元格使用了完全相同的公式。但请注意,这些单元格在基础空间和派生空间之间并不共享。

与面向对象编程语言中的类继承不同,空间继承是基于空间实例(或对象)而非类的,因此在创建派生空间时,单元格会从基础空间复制到派生空间。

推导禀赋空间#

我们将创建另一个空间来测试覆盖继承单元格的功能。 我们将从LifeTerm空间派生出Endowment空间。下图展示了这里考虑的3个空间之间的关系。 箭头起始的空间是从箭头指向的空间派生而来的。

../_images/Inheritance1.png

寿险、定期寿险与储蓄寿险#

该寿险保单在保单期限结束时支付1的满期保险金。 我们将benefits单元格定义为保险金的期望值, 因此在LifeTerm空间中考虑的死亡保险金之外, 我们还会通过在Endowment空间中重写benefits定义 来添加满期保险金。实际上,被保险人不会同时获得死亡 和满期保险金,但这里我们考虑的是概率模型, 因此保险金将是死亡和满期保险金期望值的总和:

endowment = model.new_space(name='Endowment', bases=term_life)

@defcells
def benefits(x):
    if x < x0 + n:
        return d(x) / l(x0)
    elif x == x0 + n:
        return l(x) / l(x0)
    else:
        return 0

Endowment空间执行相同操作会产生以下结果:

>>> endowment.pv_benefits(50)
1.0
>>> endowment.frame
       pv_benefits  benefits              l      q           d
x
 50.0     1.000000  0.003000  100000.000000    NaN  300.000000
 51.0     0.997000  0.002991   99700.000000    NaN  299.100000
 52.0     0.994009  0.002982   99400.900000    NaN  298.202700
 53.0     0.991027  0.002973   99102.697300    NaN  297.308092
 54.0     0.988054  0.002964   98805.389208    NaN  296.416168
 55.0     0.985090  0.002955   98508.973040    NaN  295.526919
 56.0     0.982134  0.002946   98213.446121    NaN  294.640338
 57.0     0.979188  0.002938   97918.805783    NaN  293.756417
 58.0     0.976250  0.002929   97625.049366    NaN  292.875148
 59.0     0.973322  0.002920   97332.174218    NaN  291.996523
 60.0     0.970402  0.970402   97040.177695    NaN         NaN
 61.0     0.000000       NaN            NaN    NaN         NaN
NaN            NaN       NaN            NaN  0.003         NaN

你可以看到pv_benefits对应所有年龄段的值,以及benefits对应60岁的值,它们与TermLife显示的值不同,因为我们重写了benefits

pv_benefits(50) 结果为1并不令人意外。在TermLife空间中设置为1的disc_rate也会被Endowment空间继承。收益折现率为1意味着,在计算收益现值时,我们实际上只是将所有未来收益的期望值相加,其结果必然等于1,因为被保险人100%会获得1的赔付。

动态空间#

在许多情况下,您希望将一组计算应用于某个空间或空间树中的不同数据集。通过动态空间的空间继承功能即可实现这一目标。

动态空间是通过参数化的空间,当通过对其父空间的调用(())或下标([])操作请求时即时创建。

要在父空间中定义动态空间,您需要创建一个带有参数函数的空间,该函数的签名用于定义空间参数。参数函数应返回(如果有的话)参数名到其参数的映射,以便在创建动态空间时传递给new_space方法。

为了理解其工作原理,让我们继续前面的示例。 在上一个示例中,我们手动将保单的问题年龄x0设置为50,并将保单期限n设置为10。 我们将扩展这个示例,创建具有不同保单属性的动态空间策略。 假设我们有3份具有以下属性的定期寿险保单:

保单编号

签发年龄

保单期限

1

50

10

2

60

15

3

70

5

我们将创建这个示例数据作为一个嵌套列表:

data = [[1, 50, 10], [2, 60, 15], [3, 70, 5]]

该图展示了我们将要创建的模型设计。 一端带有实心菱形标记的线条表示 Policy 模型是三个动态空间 Policy1Policy2Policy3 的父空间,每个动态空间分别代表 上述三个保单之一。 虽然 Policy 是这三个动态空间的父空间, 但它同时也是它们的基础空间。 Policy 空间从 Term 模型继承其成员,而 Policy 又被这三个动态空间所继承。 这种继承关系由实心菱形旁边的空心箭头表示。

../_images/Inheritance2.png

以下是根据我们上述设计扩展模型的脚本。

def params(policy_id):
    return {'name': 'Policy%s' % policy_id,
            'bases': _self}

policy = model.new_space(name='Policy', bases=term_life, formula=params)

policy.data = data

@defcells
def x0():
    return data[policy_id - 1][1]

@defcells
def n():
    return data[policy_id - 1][2]

params函数作为formula参数的实参传递给Policy空间的构造函数。params函数的签名用于确定动态空间的参数,当创建动态空间时,返回的字典会作为参数传递给new_space。 当您通过调用Policy的第n个元素来创建其动态子空间时,会调用params函数。paramsPolicy的命名空间中被求值。_self是一个指向Policy的特殊引用。

参数 policy_id 在每个动态空间的命名空间内可用。

在每个动态空间中,x0n 的值都是从 data 中为每个保单获取的:

>>> policy(1).pv_benefits(50)
0.02959822305108317

>>> policy(2).pv_benefits(60)
0.04406717516109439

>>> policy(3).pv_benefits(70)
0.014910269595243001

>>> policy(3).frame
         n    x0           d  benefits              l  pv_benefits      q
x
NaN    5.0  70.0         NaN       NaN            NaN          NaN  0.003
 70.0  NaN   NaN  300.000000  0.003000  100000.000000     0.014910    NaN
 71.0  NaN   NaN  299.100000  0.002991   99700.000000     0.011910    NaN
 72.0  NaN   NaN  298.202700  0.002982   99400.900000     0.008919    NaN
 73.0  NaN   NaN  297.308092  0.002973   99102.697300     0.005937    NaN
 74.0  NaN   NaN  296.416168  0.002964   98805.389208     0.002964    NaN
 75.0  NaN   NaN         NaN  0.000000            NaN     0.000000    NaN
 76.0  NaN   NaN         NaN       NaN            NaN     0.000000    NaN

>>> policy.spaces
{'Policy1': <Space Policy[1] in Model1>,
 'Policy2': <Space Policy[2] in Model1>,
 'Policy3': <Space Policy[3] in Model1>}

基础空间的动态空间不会传递给派生空间。 当一个空间从具有动态创建子空间的基础空间派生时,那些动态创建的子空间本身不会传递给派生空间。相反,基础空间的参数函数会被继承,因此派生空间的子空间会在调用(使用())或下标(使用[])操作符时根据参数创建。

读取Excel文件#

您可以将存储在Excel文件中的数据读取到新创建的单元格中。 Space有两种方法new_cells_from_excelnew_space_from_excelnew_space_from_excel在Model上也可用。 您的Python环境中需要安装Openpyxl包才能使用这些方法。

new_cells_from_excel 方法从Excel文件的一个区域读取数值,创建单元格并用该区域中的数值填充它们。

new_space_from_excel 方法从Excel文件的一个范围中读取值, 创建一个空间,并在该空间中, 使用一个或多个索引行和/或列作为空间参数创建动态空间, 并在动态空间中创建单元格,用该范围内的值填充它们。

有关这些方法的具体描述,请参考modelx的参考文档。

导出为Pandas对象#

如果你的Python环境中安装了Pandas,可以将单元格的值导出到Pandas的DataFrame或Series对象中。 空间具有frame属性,该属性会生成一个DataFrame对象,其列名为单元格名称,索引为单元格参数。一个空间中的多个单元格可能具有不同的参数集。生成的