diff --git a/README.rst b/README.rst index 55ff23e..359b779 100644 --- a/README.rst +++ b/README.rst @@ -39,16 +39,41 @@ https://github.com/mynl/aggregate Installation ------------ -:: +To install into a new ``Python>=3.10`` virtual environment:: + + python -m venv path/to/your/venv`` + cd path/to/your/venv + +followed by:: + + \path\to\env\Scripts\activate + +on Windows, or:: + + source /path/to/env/bin/activate - pip install aggregate +on Linux/Unix or MacOS. Finally, install the package:: + pip install aggregate[dev] + +All the code examples have been tested in such a virtual environment and the documentation will build. Version History ----------------- +0.21.4 +~~~~~~~~ + +* Updated requirement using ``pipreqs`` recommendations +* Color graphics in documentation +* Added ``expected_shift_reduce = 16 # Set this to the number of expected shift/reduce conflicts`` to ``parser.py`` + to avoid warnings. The conflicts are resolved in the correct way for the grammar to work. +* Issues: there is a difference between ``dfreq[1]`` and ``1 claim ... fixed``, e.g., + when using spliced severities. These should not occur. + + 0.21.3 ~~~~~~~~ diff --git a/aggregate/__init__.py b/aggregate/__init__.py index b7d813f..2bbf462 100644 --- a/aggregate/__init__.py +++ b/aggregate/__init__.py @@ -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.3" +__version__ = "0.21.4" diff --git a/aggregate/agg/test_suite.agg b/aggregate/agg/test_suite.agg index 98c6db6..6040896 100644 --- a/aggregate/agg/test_suite.agg +++ b/aggregate/agg/test_suite.agg @@ -136,7 +136,6 @@ agg F.Expos01 10 claims sev lognorm 50 cv 0.8 poisson note{specify agg F.Expos02 500 loss sev lognorm 50 cv 0.8 poisson note{specify expected loss, derive number of claims} agg F.Expos03 1000 prem at .5 lr sev lognorm 50 cv 0.8 poisson note{specify premium and loss ratio, derive number of claims} - # Mixed and Spliced severities # ============================ agg G.Mixed00 1 claim 50 xs 0 sev lognorm 10 cv [0.2 0.4 0.6 0.8 1.0] wts [.2 .3 .3 .15 .05] poisson note{no shared mixing} @@ -150,7 +149,6 @@ agg G.Mixed08 1 claim sev [100 200 250 300] * beta [1 200 500 100] [ agg G.Mixed09 8 claim sev 100 * [lognorm expon] [.5 1] wts [0.6 .4] mixed gamma 0.3 note{different severities} agg G.Mixed10 1 claim sev [50 100] * [lognorm expon] [2 1] + 10 wts=2 mixed gamma 0.3 agg G.Mixed11 1 claim sev [50 100] * [lognorm expon] [2 1] + 10 mixed gamma 0.3 -# agg G.Spliced01 1 claim 50 xs 0 sev lognorm 10 cv [0.2 0.4 0.6 0.8 1.0] wts [.2 .3 .3 .15 .05] spliced gamma 0.3 note{shared mixing, compare audit and report dfs} # Limit profiles # ============== @@ -174,7 +172,6 @@ agg I.Blend10 [500 800 200] loss sev lognorm 10 c agg I.Blend11 [1000 2000 500] prem at [.8 .7 .5] lr sev lognorm 10 cv [.2 .35 .5] wts [1/2 3/8 1/8] mixed gamma 0.5 note{log2=17;} agg I.Blend12 [500 800 200] loss sev lognorm 10 cv [.2 .35 .5] wts=3 mixed gamma 0.5 - # Reinsurance # =========== agg J.Re01 5 claims 100 xs 0 sev lognorm 10 cv .75 occurrence net of 50% so 5 xs 0 and 5 po 15 xs 5 and 30 xs 20 poisson diff --git a/aggregate/decl_pygments.py b/aggregate/decl_pygments.py index f3fa5fe..f892332 100644 --- a/aggregate/decl_pygments.py +++ b/aggregate/decl_pygments.py @@ -13,21 +13,6 @@ __all__ = ['AggLexer'] -# def colorize(code): -# # step 2: apply custom style -# # embed Style inside HTML (self-contained, no external CSS-file -# # formatter.noclasses = True # inline style to each element directly -# formatter = HtmlFormatter(style='monokai', full=True) -# return highlight(code, AggLexer(), formatter) - - -# def rawhtml(code): -# formatter = HtmlFormatter(style='monokai', full=False) -# return highlight(code, AggLexer(), formatter) - - -# define custom style -> see older version; don't want to do this - class AggLexer(RegexLexer): """ Aggregate program language lexer. (Based on Python lexer. ) diff --git a/aggregate/distributions.py b/aggregate/distributions.py index 3bedbb1..0f9f713 100644 --- a/aggregate/distributions.py +++ b/aggregate/distributions.py @@ -2160,12 +2160,12 @@ def apply_agg_reins(self, debug=False, padding=1, tilt_vector=None): logger.info(f'Applying agg reins to {self.name}\tOld mean and cv= {_m:,.3f}\t{_m:,.3f}\n' f'New mean and cv = {_m2:,.3f}\t{_cv2:,.3f}') - def reinsurance_description(self, kind='both', width=70): + def reinsurance_description(self, kind='both', width=0): """ Text description of the reinsurance. :param kind: both, occ, or agg - :param width: width of text for textwrap.fill + :param width: width of text for textwrap.fill; omitted if width==0 """ ans = [] if self.occ_reins is not None and kind in ['occ', 'both']: @@ -2173,12 +2173,12 @@ def reinsurance_description(self, kind='both', width=70): ra = [] for (s, y, a) in self.occ_reins: if np.isinf(y): - ra.append(f'{s:,.2%} share of unlimited xs {a:,.2f}') + ra.append(f'{s:,.0%} share of unlimited xs {a:,.0f}') else: if s == y: - ra.append(f'{y:,.2f} xs {a:,.2f}') + ra.append(f'{y:,.0f} xs {a:,.0f}') else: - ra.append(f'{s:,.2%} share of {y:,.2f} xs {a:,.2f}') + ra.append(f'{s:,.0%} share of {y:,.0f} xs {a:,.0f}') ans.append(' and '.join(ra)) ans.append('per occurrence') if self.agg_reins is not None and kind in ['agg', 'both']: @@ -2188,12 +2188,12 @@ def reinsurance_description(self, kind='both', width=70): ra = [] for (s, y, a) in self.agg_reins: if np.isinf(y): - ra.append(f'{s:,.2%} share of unlimited xs {a:,.2f}') + ra.append(f'{s:,.0%} share of unlimited xs {a:,.0f}') else: if s == y: - ra.append(f'{y:,.2f} xs {a:,.2f}') + ra.append(f'{y:,.0f} xs {a:,.0f}') else: - ra.append(f'{s:,.2%} share of {y:,.2f} xs {a:,.2f}') + ra.append(f'{s:,.0%} share of {y:,.0f} xs {a:,.0f}') ans.append(' and '.join(ra)) ans.append('in the aggregate.') if len(ans): @@ -2204,7 +2204,8 @@ def reinsurance_description(self, kind='both', width=70): reins = ' '.join(ans) else: reins = 'No reinsurance' - reins = fill(reins, width) + if width: + reins = fill(reins, width) return reins def reinsurance_kinds(self): diff --git a/aggregate/extensions/test_suite.py b/aggregate/extensions/test_suite.py index 4e934a7..794cc07 100644 --- a/aggregate/extensions/test_suite.py +++ b/aggregate/extensions/test_suite.py @@ -1,101 +1,95 @@ # code for running test cases, producing HTML, etc. from .. import pprint_ex - -# from ..aggregate.utilities import iman_conover, mu_sigma_from_mean_cv -# # from aggregate.utils import rearrangement_algorithm_max_VaR -# from .. aggregate.utilities import random_corr_matrix +from .. import build as build_uw import logging import matplotlib.pyplot as plt from pathlib import Path import re - logger = logging.getLogger(__name__) class TestSuite(object): - p = None - build = None - tests = '' - - @classmethod - def __init__(cls, build=None, out_dir_name=''): + def __init__(self, build_in=None, fn='test_suite.agg', out_dir_name=''): """ Run test suite fn. Create specified objects. Save graphics and info to HTML. Wrap HTML with template. TODO: convert wrapping to Jinja! - To run whole test_suite - :: + To run whole test_suite:: python -m aggregate.extensions.test_suite + :param build_in: build object, allows input custom build object + :param fn: test suite file name, default test_suite.agg + :param out_dir_name: output directory name, default site_dir/generated """ - if build is None: - from .. import build - - cls.build = build + self.build = build_in if build_in else build_uw if out_dir_name != '': - cls.p = Path(out_dir_name) - if cls.p.exists() is False: + self.out_dir = Path(out_dir_name) + if self.out_dir.exists() is False: raise FileExistsError(f'Directory {out_dir_name} does not exist.') else: - cls.p = cls.build.site_dir.parent / 'generated' - cls.p.mkdir(exist_ok=True) - (cls.p / "img").mkdir(exist_ok=True) + self.out_dir = self.build.site_dir.parent / 'generated' + self.out_dir.mkdir(exist_ok=True) + (self.out_dir / "img").mkdir(exist_ok=True) - logger.info(f'Output directory {cls.p.resolve()}') + logger.info(f'Output directory {self.out_dir.resolve()}') - # extract from comments; this is just FYI - fn = 'test_suite.agg' - suite = build.default_dir / fn + suite = self.build.default_dir / fn + assert suite.exists(), f'Requested test suite file {suite} does not exist.' txt = suite.read_text(encoding='utf-8') tests = [i for i in txt.split('\n') if re.match(r'# [A-Z]\.', i)] - cls.tests = [i.replace("# ", "").split('. ') for i in tests] + self.tests = [i.replace("# ", "").split('. ') for i in tests] - @classmethod - def run(cls, regex, title, fig_prefix, fig_format='svg', fig_size=(8,2.4), **kwargs): + def run(self, regex, title, filename, browse=False, fig_format='svg', fig_size=(8,2.4), **kwargs): """ + Run all tests matching regex. Save graphics and info to HTML. + Wrap HTML with template. To run whole test_suite use:: + + python -m aggregate.extensions.test_suite :param regex: regex of tests to run, e.g., 'agg [ABC]\. ' :param title: title for blob - :param fig_prefix: file name prefix for saved immage files (convenience) + :param filename: file name prefix for saved immage files (convenience) + :param browse: open browser to output file :param fig_format: html or markdown (md); html uses svg output, markdown uses pdf :param fig_size: :param kwargs: passed to savefig """ - logger.warning(f'figure prefix = {fig_prefix}') - ans = [] - for n in cls.build.qshow(regex).index: - a = cls.build(n) + for n in self.build.qshow(regex, tacit=False).index: + a = self.build(n) ans.append(a.html_info_blob().replace('h3>', 'h2>')) - ans.append(pprint_ex(a.program, 50, True, True)) - ans.append(cls.style_df(a.describe).to_html()) + ans.append(pprint_ex(a.program, 50, True)) + ans.append(self.style_df(a.describe).to_html()) ans.append('
') - fn = cls.p / f'img/{fig_prefix}_tmp_{hash(a):0x}.{fig_format}' + fn = self.out_dir / f'img/{filename}_tmp_{hash(a):0x}.{fig_format}' a.plot(figsize=fig_size) a.figure.savefig(fn, **kwargs) ans.append(f'') plt.close(a.figure) logger.warning(f'Created {n}, mean {a.agg_m:.2f}') - - blob = '\n'.join(ans) - fn = cls.p / f'{fig_prefix}.html' + blob = '\n'.join([i if type(i)==str else i.data for i in ans]) + fn = self.out_dir / f'{filename}.html' fn.write_text(blob, encoding='utf-8') - fn2 = cls.p / f'{fn.stem}_wrapped.html' - fn3 = cls.build.template_dir / 'test_suite_template.html' + fn2 = self.out_dir / f'{fn.stem}_wrapped.html' + fn3 = self.build.template_dir / 'test_suite_template.html' # TODO JINJA! template = fn3.read_text() template = template.replace('HEADING GOES HERE', title).replace( 'CONTENTHERE', blob) fn2.write_text(template, encoding='utf-8') + logger.info(f'Output written to {fn2.resolve()}') + if browse: + import webbrowser + webbrowser.open(fn2.resolve().as_uri()) @staticmethod def style_df(df): @@ -166,7 +160,8 @@ def run_test_suite(): # run all the aggs # TODO FIX for Portfolios # t.run(regex=r'^C\.', title='C only', fig_prefix="auto", fig_format='png', dpi=300) - t.run(regex=r'^[A-KNO]\.', title='Full Test Suite', fig_prefix="auto", fig_format='png', dpi=300) + t.run(regex=r'^[A-KNO]', title='Full Test Suite', filename='A_tests', browse=True, + fig_format='png', dpi=300) if __name__ == '__main__': diff --git a/aggregate/parser.py b/aggregate/parser.py index 0d74ec4..02e5aee 100644 --- a/aggregate/parser.py +++ b/aggregate/parser.py @@ -199,6 +199,8 @@ class UnderwritingParser(Parser): """ + expected_shift_reduce = 16 # Set this to the number of expected shift/reduce conflicts + debugfile = None # uncomment to write detailed grammar rules # debugfile = Path.home() / 'aggregate/parser/parser.out' diff --git a/aggregate/underwriter.py b/aggregate/underwriter.py index 46fb091..1986352 100644 --- a/aggregate/underwriter.py +++ b/aggregate/underwriter.py @@ -829,10 +829,11 @@ def qlist(self, regex): """ return self.show(regex, kind='', plot=False, describe=False, verbose=True) - def qshow(self, regex): + def qshow(self, regex, tacit=True): """ Wrapper for show to just show (display) elements in knowledge that match ``regex``. - No reutrn value. + No reutrn value if tacit, else returns a dataframe. + """ def ff(x): fs = '{x:120s}' @@ -843,9 +844,12 @@ def ff(x): r' note\{[^}]+\}', '').str.replace(' +', ' ') # , flags=re.MULTILINE) # bit['program'] = bit['program'].str.replace(' ( +)', ' ') #, flags=re.MULTILINE) # bit['program'] = bit['program'].str.replace(r' note\{[^}]+\}$| *', ' ' ) #, flags=re.MULTILINE) - qd(bit, - line_width=160, max_colwidth=130, col_space=15, justify='left', - max_rows=200, formatters={'program': ff}) + if tacit: + qd(bit, + line_width=160, max_colwidth=130, col_space=15, justify='left', + max_rows=200, formatters={'program': ff}) + else: + return bit def show(self, regex, kind='', plot=True, describe=True, logger_level=30, verbose=False, **kwargs): """ diff --git a/aggregate/utilities.py b/aggregate/utilities.py index d271397..bb65e8f 100644 --- a/aggregate/utilities.py +++ b/aggregate/utilities.py @@ -2962,7 +2962,7 @@ def moms_analytic(fz, limit, attachment, n, analytic=True): return ans -def qd(*argv, accuracy=3, align=True, trim=True, **kwargs): +def qd(*argv, accuracy=3, align=True, trim=True, ff=None, **kwargs): """ Endless quest for a robust display format! @@ -2973,13 +2973,24 @@ def qd(*argv, accuracy=3, align=True, trim=True, **kwargs): :param: argv: list of objects to print :param: accuracy: number of decimal places to display :param: align: if True, align columns at decimal point (sEngFormatter) + :param: trim: if True, trim trailing zeros (sEngFormatter) + :param: ff: if not None, use this function to format floats, or 'basic', or 'binary' :kwargs: passed to pd.DataFrame.to_string for dataframes only. e.g., pass dict of formatters by column. """ from .distributions import Aggregate from .portfolio import Portfolio # ff = sEngFormatter(accuracy=accuracy - (2 if align else 0), min_prefix=0, max_prefix=12, align=align, trim=trim) - ff = kwargs.pop('ff', lambda x: f'{x:.5g}') + if ff is None: + ff = lambda x: f'{x:.5g}' + elif ff == 'basic': + ff = lambda x: f'{x:.1%}' if x < 1 else f'{x:12,.0f}' + elif ff == 'int_ratio': + def format_function(x): + ir = np.round(x, 13).as_integer_ratio() + return f'{int(x)}' if x in [0, 1] else f' {ir[0]}/{ir[1]}' + + ff = format_function # split output for x in argv: if isinstance(x, (Aggregate, Portfolio)): diff --git a/docs/conf.py b/docs/conf.py index 2662e27..bc54576 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,6 +14,9 @@ sys.path.insert(0, os.path.abspath('../')) import aggregate as agg +# color graphs +agg.knobble_fonts(True) + # graphics defaults - better res graphics plt.rcParams['figure.dpi'] = 300 diff --git a/pipreq_requirements.txt b/pipreq_requirements.txt new file mode 100644 index 0000000..2200f15 --- /dev/null +++ b/pipreq_requirements.txt @@ -0,0 +1,10 @@ +cycler>=0.12.1 +ipython>=8.17.2 +Jinja2>=3.1.2 +matplotlib>=3.8.2 +numpy>=1.26.3 +pandas>=2.1.4 +psutil>=5.9.6 +Pygments>=2.16.1 +scipy>=1.11.4 +titlecase>=2.4.1 diff --git a/pyproject.toml b/pyproject.toml index aaea639..ea504bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,9 @@ dependencies = [ "numpy>=1.26", "pandas>=2.1", "psutil", + "Pygments", "scipy>=1.11", -# "sly", "titlecase", - "pygments" ] license = {text = "BSD-3-Clause"} requires-python = ">=3.10" @@ -47,7 +46,7 @@ Documentation = "https://aggregate.readthedocs.io/en/latest/" [project.optional-dependencies] dev = [ - "docutils==0.16", + "docutils<0.17", "jupyter-sphinx", "nbsphinx", "pickleshare",