From 0fd82bd2e6b5f734a990e0b4bca162af9b4199e1 Mon Sep 17 00:00:00 2001 From: Stephen Mildenhall Date: Mon, 15 Jan 2024 21:22:00 +0000 Subject: [PATCH] Updated dependences --- .gitignore | 1 + README.rst | 22 +++- aggregate/__init__.py | 4 +- aggregate/bounds.py | 4 +- aggregate/distributions.py | 3 + aggregate/extensions/figures.py | 152 ++++++++++++++++++++++- aggregate/extensions/risk_progression.py | 45 ++++--- aggregate/spectral.py | 55 +++++++- docs/index.rst | 3 +- pyproject.toml | 9 +- 10 files changed, 267 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 6b47114..07d5b35 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,4 @@ venv.bak/ doc/index - Copy.xrst cheat-sheets/preamble.fmt +*.png diff --git a/README.rst b/README.rst index 0658d1b..55ff23e 100644 --- a/README.rst +++ b/README.rst @@ -4,13 +4,14 @@ ----- -aggregate: a powerful Python actuarial modeling library -======================================================== +aggregate: a powerful actuarial modeling library +================================================== Purpose ----------- -``aggregate`` solves insurance, risk management, and actuarial problems using realistic models that reflect +``aggregate`` builds approximations to compound (aggregate) probability distributions quickly and accurately. +It can be used to solve insurance, risk management, and actuarial problems using realistic models that reflect underlying frequency and severity. It delivers the speed and accuracy of parametric distributions to situations that usually require simulation, making it as easy to work with an aggregate (compound) probability distribution as the lognormal. ``aggregate`` includes an expressive language called DecL to describe aggregate distributions @@ -47,6 +48,19 @@ Installation Version History ----------------- + +0.21.3 +~~~~~~~~ + +* Risk progression, defaults to linear allocation. +* Added ``g_insurance_statistics`` to ``extensions`` to plot insurance statistics from a distortion ``g``. +* Added ``g_risk_appetite`` to ``extensions`` to plot risk appetite from a distortion ``g`` (value, loss ratio, + return on capital, VaR and TVaR weights). +* Corrected Wang distortion derivative. +* Vectorized ``Distortion.g_prime`` calculation for proportional hazard +* Added ``tvar_weights`` function to ``spectral`` to compute the TVaR weights of a distortion. (Work in progress) +* Updated dependencies in pyproject.toml file. + 0.21.2 ~~~~~~~~ @@ -447,4 +461,4 @@ Social media: https://www.reddit.com/r/AggregateDistribution/. .. |twitter| image:: https://img.shields.io/twitter/follow/mynl.svg?label=follow&style=flat&logo=twitter&logoColor=4FADFF :target: https://twitter.com/SJ2Mi - :alt: Twitter Follow \ No newline at end of file + :alt: Twitter Follow diff --git a/aggregate/__init__.py b/aggregate/__init__.py index fce92e2..b7d813f 100644 --- a/aggregate/__init__.py +++ b/aggregate/__init__.py @@ -20,7 +20,7 @@ integral_by_doubling, logarithmic_theta, block_iman_conover, make_var_tvar, test_var_tvar, kaplan_meier, kaplan_meier_np, more, parse_note, parse_note_ex, introspect, explain_validation) -from .spectral import Distortion, approx_ccoc +from .spectral import Distortion, approx_ccoc, tvar_weights from .distributions import Frequency, Severity, Aggregate from .portfolio import Portfolio, make_awkward from .underwriter import Underwriter, build @@ -49,7 +49,7 @@ __email__ = "steve@convexrisk.com" __status__ = "beta" # only need to change here, feeds conf.py (docs) and pyproject.toml (build) -__version__ = "0.21.2" +__version__ = "0.21.3" diff --git a/aggregate/bounds.py b/aggregate/bounds.py index 28fd648..43cb220 100644 --- a/aggregate/bounds.py +++ b/aggregate/bounds.py @@ -60,8 +60,8 @@ def __init__(self, distribution_spec): # hack for beta distribution, you want to force 1 to be in tvar ps, but Fp = 1 # TODO figure out why p_star grinds to a halt if you input b < inf self.add_one = True - logger.warning('Deprecatation warning. The kind argument is now ignored. Functionality ' - 'is equivalent to kind="tail", which was the most accurate method.') + # logger.warning('Deprecatation warning. The kind argument is now ignored. Functionality ' + # 'is equivalent to kind="tail", which was the most accurate method.') def __repr__(self): """ diff --git a/aggregate/distributions.py b/aggregate/distributions.py index e183fc7..3bedbb1 100644 --- a/aggregate/distributions.py +++ b/aggregate/distributions.py @@ -2769,6 +2769,9 @@ def q(self, p, kind='lower'): return self._var_tvar_function[kind](p) + # for consistency with scipy + ppf = q + def _make_var_tvar(self, ser): dict_ans = {} qf = make_var_tvar(ser) diff --git a/aggregate/extensions/figures.py b/aggregate/extensions/figures.py index 41621a4..0615498 100644 --- a/aggregate/extensions/figures.py +++ b/aggregate/extensions/figures.py @@ -9,7 +9,7 @@ import matplotlib as mpl from .. import build, qd from .. constants import FIG_H, FIG_W, PLOT_FACE_COLOR -from .. spectral import Distortion +from .. spectral import Distortion, tvar_weights def adjusting_layer_losses(): @@ -366,4 +366,152 @@ def exact_cdf(x): ['p_total']]/a.bs df.index = [f'{x: 6.0f}' for x in df.index] df.index.name = 'x' - qd(df.iloc[:, [1,0,2,3,4, 5]], ff=lambda x: f'{x:11.3e}') \ No newline at end of file + qd(df.iloc[:, [1,0,2,3,4, 5]], ff=lambda x: f'{x:11.3e}') + + +def g_insurance_statistics(axi, dist, c='C0', ls='-', lw=1, diag=False, grid=False): + """ + Six part plot with EL, premium, loss ratio, profit, layer ROE and P:S for g + axi = axis iterator with 6 axes + dist = distortion + Used to create PIR Figs 11.6 and 11.7 + """ + g = dist.g + N = 1000 + ps = np.linspace(1 / (2 * N), 1, N, endpoint=False) + gs = g(ps) + + dn = dist.name + + # FIG: six panel display + for i, ys, key in zip(range(1, 7), + [gs, ps / gs, gs / ps, gs - ps, (gs - ps) / (1 - ps), gs / (1 - gs)], + ['Premium', 'Loss Ratio', 'Markup', + 'Margin', 'Discount Rate', 'Premium Leverage']): + a = next(axi) + if i == 1: + a.plot(ps, ys, ls=ls, c=c, lw=lw, label=dn) + else: + a.plot(ps, ys, ls=ls, lw=lw, c=c) + if i in [1] and diag: + a.plot(ps, ps, color='k', linewidth=0.25) + a.set(title=key) + if i == 3 or i == 6: + a.axis([0, 1, 0, 5]) + a.set(aspect=1 / 5) + elif i == 5: + # discount + a.axis([0, 1, 0, .5]) + a.set(aspect=1 / .5) + else: + a.axis([0, 1, 0, 1]) + a.set(aspect=1) + if i == 1: + a.legend(loc='lower right') + if grid: + a.grid(lw=0.25) + + +def g_risk_appetite(axi, dist, c='C0', ls='-', N=1000, lw=1, grid=False, xlabel=True, title=True, add_tvar=False): + """ + Plot to illustrate the risk appetite associated with a distortion g. + Derived from ``g_insurance_statistics``. + Plots premium, loss ratio, margin, return (easier to understand than discount), + VaR wts and optionally TVaR wts + axi = axis iterator with 6 axes + dist = distortion + Used to create PIR Figs 11.6 and 11.7 + """ + g = dist.g + ps = np.linspace(1 / (2 * N), 1, N, endpoint=False) + gs = g(ps) + + # var weight + gp = dist.g_prime(ps) + + # tvar weight + wt_fun = tvar_weights(dist) + ps01 = np.linspace(0, 1, N+1) + tvar = wt_fun(ps01) + + # labels etc. + dn = dist.name.replace(' ', '\n').replace('aCC', 'CC') + isccoc = dist.name.lower().find('ccoc') >= 0 + istvar = dist.name.lower().find('tvar') >= 0 + + for i, ys, key in zip(range(6), + [gs, + ps / gs, + gs - ps, + (gs - ps) / (1 - gs), + gp, + tvar], + ['Premium', + 'Loss ratio', + 'Margin', + 'Return on capital', + 'VaR weight', + 'TVaR p weight']): + if i == 5 and not add_tvar: + # skip tvar weights + continue + a = next(axi) + if key == 'VaR weight' and istvar: + ds = 'steps-post' + elif key == 'TVaR p weight' and (istvar or isccoc): + ds = 'steps-mid' + else: + ds = 'default' + kwargs = {'ls': ls, 'c': c, 'lw': lw, 'ds': ds} + if i == 0: + a.plot(ps, ys, label=dn, **kwargs) + a.legend(loc='lower right', fontsize=10) + elif i < 5: + a.plot(ps, ys, **kwargs) + else: + a.plot(ps01, ys, **kwargs) + if title: + a.set(title=key) + if i in (1, 3, 4): + mn, mx = a.get_ylim() + mx = max(1.025, min(5, mx * 1.1)) + # a.set_ylim(0) + a.axis([-0.025, 1.025, -0.025, mx]) + a.set(aspect=1 / mx) + elif i == 2: + a.axis([-0.025, 1.025, -0.025, .525]) + a.set(aspect=2) + elif i in (0,1): + a.axis([-0.025, 1.025, -0.025, 1.025]) + a.set(aspect='equal') + elif i == 5: + pass + else: + a.axis([-0.025, 1.025, -0.025, 1.025]) + a.set(aspect=1) + if isccoc: + if key == 'Premium': + # add zero point + a.plot(0, 0, 'o', c=c) + if key == 'VaR weight': + # add var mass + mx = 2.2 + line, = a.plot(0, mx, '*', c=c) # Point above the plot area + # Allow plotting outside of the plot area + line.set_clip_on(False) + if key == 'Return on capital': + # add var mass + mx = a.get_ylim()[1] * 1.1 + line, = a.plot(0, mx, '*', c=c) # Point above the plot area + # Allow plotting outside of the plot area + line.set_clip_on(False) + if grid: + a.grid(lw=0.25) + if xlabel: + if i == 0: + a.set(xlabel='Exceedance probability\n(large losses on left)') + elif i == 5: + a.set(xlabel='p value\n(mean on left, max on right)') + else: + a.set(xlabel='Exceedance probability') + # print(np.sum(gp)) \ No newline at end of file diff --git a/aggregate/extensions/risk_progression.py b/aggregate/extensions/risk_progression.py index f8e21d5..df406bd 100644 --- a/aggregate/extensions/risk_progression.py +++ b/aggregate/extensions/risk_progression.py @@ -146,9 +146,9 @@ def make_up_down(ser): u = np.maximum(0, dy).cumsum() d = -np.minimum(0, dy).cumsum() c = u - d - u.name = 'up' - d.name = 'down' - c.name = 'recreated' + u.name = 'Ins' + d.name = 'Fin' + c.name = 'Ins - Fin' return u, d, c @@ -197,13 +197,14 @@ def plot_up_down(self, udd, axs): # left and middle plots for unit, ax, recreated_c in zip(self.unit_names, axs.flat, ['C0', 'C1']): - ax = self.density_df[f'exeqa_{unit}'].plot(ax=ax, lw=.5, c='C7', drawstyle='steps-mid') + ax = self.density_df[f'exeqa_{unit}'].plot(ax=ax, lw=.5, c='C7', drawstyle='steps-mid', + label=f'E[{unit} | X]') (udd.up_functions[unit] - udd.down_functions[unit] - ).plot(ax=ax, lw=1.5, ls='-', c=recreated_c, label='recreated', drawstyle='steps-post') + ).plot(ax=ax, lw=1.5, ls='-', c=recreated_c, label='Ins - Fin', drawstyle='steps-post') udd.up_functions[unit].plot(ax=ax, c='C3', drawstyle='steps-post', lw=1, ls='--') udd.down_functions[unit].plot(ax=ax, c='C5', drawstyle='steps-post', lw=1, ls='-.') ax.legend() - ax.set(xlabel='loss', ylabel='up or down function') + ax.set(xlabel='X, total', ylabel='Unit or conditional loss') # plot ud distributions (right hand plot) ax = axs.flat[-1] @@ -241,7 +242,7 @@ def price_work(dn, series, names_ex): return bit -def price_compare(self, dn, projection_dists, ud_dists): +def price_compare(self, dn, projection_dists, ud_dists, allocation='linear'): """ Build out pricing comparison waterfall @@ -251,9 +252,9 @@ def price_compare(self, dn, projection_dists, ud_dists): # linear natural allocation pricing # KLUDGE - lna_pricea = self.price(self.q(1), dn, view='ask') + lna_pricea = self.price(self.q(1), dn, allocation=allocation, view='ask') # display(lna_pricea.df) - lna_priceb = self.price(self.q(1), dn, view='bid') + lna_priceb = self.price(self.q(1), dn, allocation=allocation, view='bid') na_price = lna_pricea.df[['L', 'P']] na_price.columns = ['el', 'ask'] # if this is nan it gets dropped from stack?! @@ -302,7 +303,7 @@ def price_compare(self, dn, projection_dists, ud_dists): return compare -def full_monty(self, dn, truncate=True, smooth=16, plot=True): +def full_monty(self, dn, truncate=True, smooth=16, plot=True, allocation='linear'): """ One-stop shop for a Portfolio self Unlimited assets @@ -342,10 +343,26 @@ def full_monty(self, dn, truncate=True, smooth=16, plot=True): axs1 = axs[3, :] plot_up_down(self, ud_dists, axs1) - compare = price_compare(self, dn, projection_dists, ud_dists) + compare = price_compare(self, dn, projection_dists, ud_dists, allocation=allocation) compare['umd'] = compare['up'] - compare['down'] - - RiskProgression = namedtuple('RiskProgression', ['compare_df', 'projection_dists', 'ud_dists']) - ans = RiskProgression(compare, projection_dists, ud_dists) + compare2 = compare.copy() + for l in compare2.index.levels[0]: + m = compare2.loc[(l, 'ask')] - compare2.loc[(l, 'el')] + compare2.loc[(l, 'margin'), :] = m + compare2 = compare2.sort_index() + compare2 = compare2.rename(columns={'lna': 'Linear NA' , + 'sa': 'Standalone', + 'proj_sa': 'Projection SA', + 'up': 'Ins', + 'down': 'Fin', + 'umd': 'Ins - Fin'}) + # or + # {'Linear NA': 'NA(Xi)', 'Standalone': 'ρ(Xi)', + # 'Projection SA': 'ρ(E[Xi | X])', + # 'Ins': 'ρ(Ins)', 'Fin': 'ρ(Fin)', 'Ins - Fin': 'ρ(Ins) - ρ(Fin)' + + RiskProgression = namedtuple('RiskProgression', + ['compare_df', 'compare_df2', 'projection_dists', 'ud_dists']) + ans = RiskProgression(compare, compare2, projection_dists, ud_dists) return ans diff --git a/aggregate/spectral.py b/aggregate/spectral.py index 94b9494..3c7c643 100644 --- a/aggregate/spectral.py +++ b/aggregate/spectral.py @@ -119,7 +119,7 @@ def g_inv(x): return x ** rhoinv def g_prime(x): - return rho * x ** (rho - 1.0) if x > 0 else np.inf + return np.where(x > 0, rho * x ** (rho - 1.0), np.inf) elif self._name == 'wang': lam = self.shape @@ -133,7 +133,7 @@ def g_inv(x): return n.cdf(n.ppf(x) - lam) def g_prime(x): - return n.pdf(n.ppf(x) - lam) / n.pdf(n.ppf(x) + lam) + return n.pdf(n.ppf(x) + lam) / n.pdf(n.ppf(x)) elif self._name == 'tt': lam = self.shape @@ -760,6 +760,7 @@ def price2(self, ser, a=None, S_calculation='forwards'): # no longer guaranteed that a is in ser.index return ans.iloc[ans.index.get_indexer([a], method='nearest')] + def approx_ccoc(roe, eps=1e-14, display_name=None): """ Create a continuous approximation to the CCoC distortion with return roe. @@ -774,3 +775,53 @@ def approx_ccoc(roe, eps=1e-14, display_name=None): else display_name ) + +def tvar_weights(d): + """ + Return tvar weight function for a distortion d. Use np.gradient to differentiate g' but + adjust for certain distortions. The returned function expects a numpy array of p + values. + + :param: d distortion + """ + + shape = d.shape + r0 = d.r0 + nm = d.name + + if nm.lower().find('ccoc') >= 0: + nm = 'ccoc' + v = shape + def wf(p): + return np.where(p==0, 1 - v, + # use nan to try and distinguish mass from density + np.where(p == 1, v, np.nan)) + elif nm == 'ph': + # this is easy, do by hand + def wf(p): + return np.where( + p==1, shape, # really a mass! + -shape * (shape - 1) * (1 - p) ** (shape - 1) + ) + elif nm == 'tvar': + def wf(p): + # something that will plot reasonably + dp = p[1] - p[0] + return np.where(np.abs(p - shape) < dp, 1, 0) + + else: + # numerical approximation + def wf(p): + gprime = d.g_prime(1 - p) + wt = (1 - p) * np.gradient(gprime, p) + return wt + + # adjust for endpoints in certain situations (where is a mass at 0 or a kink + # s=1 (slope approaching s=1 is > 0) + + if nm == 'wang': + pass + elif nm == 'dual': + pass + + return wf #noqa \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 45c8fcf..fe93a13 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,7 +24,8 @@ aggregate Documentation Introduction **************** -:mod:`aggregate` solves insurance, risk management, and actuarial problems using realistic models that reflect underlying frequency and severity. +:mod:``aggregate`` builds approximations to compound (aggregate) probability distributions quickly and accurately. +It can be used to solve insurance, risk management, and actuarial problems using realistic models that reflect underlying frequency and severity. It delivers the speed and accuracy of parametric distributions to situations that usually require simulation, making it as easy to work with an aggregate (compound) probability distribution as the lognormal. :mod:`aggregate` includes an expressive language called DecL to describe aggregate distributions and is implemented in Python under an open source BSD-license. diff --git a/pyproject.toml b/pyproject.toml index 8f69a62..aaea639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,10 +30,10 @@ dependencies = [ "ipykernel", "jinja2", "matplotlib>=3.5", - "numpy", - "pandas", + "numpy>=1.26", + "pandas>=2.1", "psutil", - "scipy", + "scipy>=1.11", # "sly", "titlecase", "pygments" @@ -50,9 +50,10 @@ dev = [ "docutils==0.16", "jupyter-sphinx", "nbsphinx", + "pickleshare", "recommonmark>=0.7.1", "setuptools>=62.3.2", - "sphinx>=1.4", + "sphinx>=5.0", "sphinx-panels", "sphinx-rtd-dark-mode", "sphinxcontrib-bibtex",