Skip to content

Commit 1dbf3df

Browse files
author
Morgan Squire
committed
Initial build
1 parent 21599a1 commit 1dbf3df

13 files changed

+356
-0
lines changed

Diff for: README.md

+39
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,41 @@
11
# pyrateer
22
Python rating engine
3+
4+
# Structure
5+
6+
## Expression Tree
7+
8+
Pyrateer converts a rating formula into a type of binary tree called an algebraic expression tree where terminal nodes are one of three rating factor classes: NumericFactor, CategoricalFactor, or Constant Factor. All non-terminal nodes are CompoundFactors that apply an arithmetic operator. For example, the formula `Asset Size * (Limit - Retention) * Industry * Loss Cost Factor` gets converted to the following expression tree.
9+
10+
```mermaid
11+
graph TB
12+
p1[Product] --> p2[Product]
13+
p1 --> LC[Loss Cost Factor]
14+
p2 --> p3[Product]
15+
p2 --> I[Industry]
16+
p3 --> A[Asset Size]
17+
p3 --> D[Difference]
18+
D --> R[Retention]
19+
D --> L[Limit]
20+
```
21+
When the `calculate()` method is called on the root object, it calls the `calculate()` method on its child nodes. When a terminal node is reached, the factor is returned and when a non-terminal node is reached, the CompoundFactor calls the `calculate()` method on its children.
22+
23+
## Rating Factors
24+
25+
Each rating factor must be initialized with a lookup table in the form of a dictionary to map input values to a factor value. For example, asset size would be initalized with a dictionary that lists base rates for each asset size. For numeric inputs, values between listed values can be calculated using linear interpolation. Categorical factors must have a value in the lookup that matches the input. Constant factors don't require an input and will always return the same factor.
26+
27+
# Implementation
28+
29+
Including the full lookup tables in the script for each factor can be cumbersome and create unnecessarily long Python files. To allow for reuse of a rate plan after it is defined, there are a few options
30+
31+
## Pickling
32+
33+
Use the pickle package to serialize the rate plan object for quick import and use. The `example.py` file creates the rate plan and rates a JSON string input. It then saves the rate plan to a pickle file. The `example_pickle.py` script loads the pickled rate plan, and rates the same JSON string.
34+
35+
## JSON Rate Plans
36+
37+
Future development could implement a JSON format for saving rate plan information. This is better than pickling because when the pyrateer package is updated, it could make it difficult to unpickle files that were created with older versions of pyrateer (or older versions of pickle). The JSON format is just data so updated versions of pyrateer would import the JSON into the updated package with fewer issues.
38+
39+
## Rate Database
40+
41+
Future development could also allow for pyrateer to read rating factor tables from a database and create the rate plan from those tables. Using this strategy would allow for the rating tables to be accessed for other purposes whereas the JSON could be more difficult to use in different applications. However, issues with versioning the rate tables would need to be solved and this may end up being more complicated.

Diff for: environment.yml

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
name: pyrateer
2+
dependencies:
3+
- python>=3.11
4+
- pip
5+
- pip:
6+
- -r requirements.txt
7+
- -r requirements-dev.txt

Diff for: example.py

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from pyrateer.factors import NumericFactor, CategoricalFactor, ConstantFactor
2+
import pickle
3+
4+
5+
asset_size = NumericFactor(
6+
"Asset Size",
7+
{
8+
1: 1065,
9+
1_000_000: 1_819,
10+
2_500_000: 3_966,
11+
5_000_000: 3_619,
12+
10_000_000: 4_291,
13+
15_000_000: 4_905,
14+
20_000_000: 5_120,
15+
25_000_000: 5_499,
16+
50_000_000: 6_279,
17+
75_000_000: 6_966,
18+
100_000_000: 7_156,
19+
250_000_000: 8_380,
20+
}
21+
)
22+
23+
limit_retention_dict = {
24+
0: -0.760,
25+
1_000: -0.600,
26+
2_500: -0.510,
27+
5_000: -0.406,
28+
7_500: -0.303,
29+
10_000: -0.231,
30+
15_000: -0.128,
31+
20_000: -0.064,
32+
25_000: 0.000,
33+
35_000: 0.105,
34+
50_000: 0.175,
35+
75_000: 0.277,
36+
100_000: 0.350,
37+
125_000: 0.406,
38+
150_000: 0.452,
39+
175_000: 0.491,
40+
200_000: 0.525,
41+
225_000: 0.555,
42+
250_000: 0.581,
43+
275_000: 0.605,
44+
300_000: 0.627,
45+
325_000: 0.648,
46+
350_000: 0.666,
47+
375_000: 0.684,
48+
400_000: 0.700,
49+
425_000: 0.715,
50+
450_000: 0.730,
51+
475_000: 0.743,
52+
500_000: 0.756,
53+
525_000: 0.807,
54+
550_000: 0.819,
55+
575_000: 0.831,
56+
600_000: 0.842,
57+
625_000: 0.853,
58+
650_000: 0.864,
59+
675_000: 0.874,
60+
700_000: 0.883,
61+
725_000: 0.893,
62+
750_000: 0.902,
63+
775_000: 0.910,
64+
800_000: 0.919,
65+
825_000: 0.927,
66+
850_000: 0.935,
67+
875_000: 0.943,
68+
900_000: 0.950,
69+
925_000: 0.957,
70+
950_000: 0.964,
71+
975_000: 0.971,
72+
1_000_000: 1.000,
73+
2_000_000: 1.415,
74+
2_500_000: 1.526,
75+
3_000_000: 1.637,
76+
4_000_000: 1.820,
77+
5_000_000: 1.986,
78+
}
79+
80+
limit = NumericFactor("Limit", limit_retention_dict)
81+
retention = NumericFactor("Retention", limit_retention_dict)
82+
industry = CategoricalFactor(
83+
"Industry",
84+
{
85+
"Hazard Group 1": 1.0,
86+
"Hazard Group 2": 1.25,
87+
"Hazard Group 3": 1.5,
88+
}
89+
)
90+
loss_cost = ConstantFactor("Loss Cost", 1.7)
91+
92+
rate_plan = asset_size * (limit - retention) * industry * loss_cost
93+
94+
json_input = {
95+
"Asset Size": 1_200_000,
96+
"Limit": 5_000_000,
97+
"Retention": 1_000_000,
98+
"Industry": "Hazard Group 2",
99+
}
100+
101+
rate = rate_plan.calculate(**json_input)
102+
103+
print(rate)
104+
105+
with open("rate_plan.pkl", "wb") as f:
106+
pickle.dump(rate_plan, f)

Diff for: example_pickle.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import pickle
2+
3+
with open("rate_plan.pkl", "rb") as fp:
4+
rate_plan = pickle.load(fp)
5+
6+
json_input = {
7+
"Asset Size": 1_200_000,
8+
"Limit": 5_000_000,
9+
"Retention": 1_000_000,
10+
"Industry": "Hazard Group 2",
11+
}
12+
13+
rate = rate_plan.calculate(**json_input)
14+
15+
print(rate)

Diff for: pyproject.toml

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[project]
2+
name = "pyrateer"
3+
version = "0.0.1"
4+
authors = [
5+
{name="Morgan Squire", email="[email protected]"},
6+
]
7+
description = "Python rating engine"
8+
readme = "README.md"
9+
requires-python = ">=3.11"
10+
classifiers = [
11+
"Programming Language :: Python :: 3",
12+
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
13+
]
14+
dynamic = ["dependencies"]
15+
16+
[project.urls]
17+
"Homepage" = "https://github.com/mosquire/pyrateer"
18+
19+
[tool.setuptools.dynamic]
20+
dependencies = {file = ["requirements.txt"]}

Diff for: rate_plan.pkl

1.24 KB
Binary file not shown.

Diff for: requirements-dev.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
build
2+
pytest
3+
black

Diff for: requirements.txt

Whitespace-only changes.

Diff for: setup.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from setuptools import setup
2+
3+
setup()

Diff for: src/pyrateer/__init__.py

Whitespace-only changes.

Diff for: src/pyrateer/factors.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from abc import ABC, abstractmethod
2+
3+
4+
class RatingFactor(ABC):
5+
def __init__(self, name, lookup):
6+
self.name = name
7+
self.lookup = lookup
8+
9+
@abstractmethod
10+
def calculate(self):
11+
pass
12+
13+
def __add__(self, other):
14+
return CompoundFactor(self, other, operator="+")
15+
16+
def __sub__(self, other):
17+
return CompoundFactor(self, other, operator="-")
18+
19+
def __mul__(self, other):
20+
return CompoundFactor(self, other, operator="*")
21+
22+
23+
class NumericFactor(RatingFactor):
24+
def calculate(self, input):
25+
if input in self.lookup:
26+
return self.lookup[input]
27+
elif input < min(self.lookup.keys()):
28+
return self.lookup[min(self.lookup.keys())]
29+
elif input > max(self.lookup.keys()):
30+
return self.lookup[max(self.lookup.keys())]
31+
else:
32+
return linterp(input, self.lookup)
33+
34+
35+
class CategoricalFactor(RatingFactor):
36+
def calculate(self, input):
37+
return self.lookup[input]
38+
39+
40+
class ConstantFactor(RatingFactor):
41+
def calculate(self, input=None):
42+
if input is not None:
43+
raise ValueError("Constant factor should not have an input")
44+
return self.lookup
45+
46+
47+
class CompoundFactor(RatingFactor):
48+
def __init__(self, lhs, rhs, operator):
49+
self.lhs = lhs
50+
self.rhs = rhs
51+
self.operator = operator
52+
53+
def calculate(self, **rate_params):
54+
if isinstance(self.lhs, CompoundFactor):
55+
lhs_value = self.lhs.calculate(**rate_params)
56+
else:
57+
lhs_value = self.lhs.calculate(rate_params.get(self.lhs.name))
58+
if isinstance(self.rhs, CompoundFactor):
59+
rhs_value = self.rhs.calculate(**rate_params)
60+
else:
61+
rhs_value = self.rhs.calculate(rate_params.get(self.rhs.name))
62+
if self.operator == "+":
63+
return lhs_value + rhs_value
64+
elif self.operator == "-":
65+
return lhs_value - rhs_value
66+
elif self.operator == "*":
67+
return lhs_value * rhs_value
68+
else:
69+
raise NotImplementedError(f"Operator {self.operator} not implemented")
70+
71+
72+
def linterp(input, lookup):
73+
keys = sorted(lookup.keys())
74+
for i in range(len(keys) - 1):
75+
if keys[i] <= input < keys[i + 1]:
76+
pct_traveled = (input - keys[i]) / (keys[i + 1] - keys[i])
77+
return lookup[keys[i]] + pct_traveled * (
78+
lookup[keys[i + 1]] - lookup[keys[i]]
79+
)

Diff for: test/conftest.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pytest
2+
from pyrateer.factors import CategoricalFactor, NumericFactor, ConstantFactor
3+
4+
@pytest.fixture
5+
def cat_factor():
6+
cat_factor = CategoricalFactor(
7+
name = "cat",
8+
lookup = {"a": 1.0, "b": 1.25, "c": 1.5}
9+
)
10+
return cat_factor
11+
12+
@pytest.fixture
13+
def num_factor():
14+
num_factor = NumericFactor(
15+
name = "num",
16+
lookup = {100_000: 1.0, 250_000: 1.25, 500_000: 1.5}
17+
)
18+
return num_factor
19+
20+
@pytest.fixture
21+
def const_factor():
22+
const_factor = ConstantFactor(
23+
name="const",
24+
lookup=10
25+
)
26+
return const_factor

Diff for: test/test_rating_factor.py

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import pytest
2+
from pyrateer.factors import CompoundFactor
3+
4+
5+
class TestCategoricalFactor():
6+
def test_cat_factor_lookup(self, cat_factor):
7+
assert cat_factor.calculate("a") == 1.0
8+
assert cat_factor.calculate("b") == 1.25
9+
assert cat_factor.calculate("c") == 1.5
10+
11+
class TestNumericFactor():
12+
def test_num_factor_lookup_values(self, num_factor):
13+
assert num_factor.calculate(50_000) == 1.0
14+
assert num_factor.calculate(200_000) == pytest.approx(1.1666667)
15+
assert num_factor.calculate(250_000) == 1.25
16+
assert num_factor.calculate(700_000) == 1.5
17+
18+
class TestConstantFactor():
19+
def test_const_factor_return_value(self, const_factor):
20+
assert const_factor.calculate() == 10
21+
22+
class TestCompoundFactor():
23+
def test_compound_constructor(self, cat_factor, num_factor):
24+
sum_factor = cat_factor + num_factor
25+
assert isinstance(sum_factor, CompoundFactor)
26+
assert sum_factor.operator == "+"
27+
assert sum_factor.lhs == cat_factor
28+
assert sum_factor.rhs == num_factor
29+
30+
def test_sum_numeric_cat(self, cat_factor, num_factor):
31+
sum_factor = cat_factor + num_factor
32+
rate_params = {"cat": "c", "num": 130_000}
33+
assert sum_factor.calculate(**rate_params) == 2.55
34+
35+
def test_sum_numeric_const(self, num_factor, const_factor):
36+
sum_factor = num_factor + const_factor
37+
rate_params = {"num": 130_000}
38+
assert sum_factor.calculate(**rate_params) == 11.05
39+
40+
def test_product_numeric_cat(self, cat_factor, num_factor):
41+
product_factor = cat_factor * num_factor
42+
rate_params = {"cat": "c", "num": 250_000}
43+
assert product_factor.calculate(**rate_params) == 1.875
44+
45+
def test_product_numeric_const(self, num_factor, const_factor):
46+
sum_factor = num_factor * const_factor
47+
rate_params = {"num": 130_000}
48+
assert sum_factor.calculate(**rate_params) == 10.5
49+
50+
def test_product_numeric_cat(self, cat_factor, num_factor):
51+
difference_factor = cat_factor - num_factor
52+
rate_params = {"cat": "c", "num": 250_000}
53+
assert difference_factor.calculate(**rate_params) == 0.25
54+
55+
def test_product_numeric_const(self, num_factor, const_factor):
56+
difference_factor = num_factor - const_factor
57+
rate_params = {"num": 130_000}
58+
assert difference_factor.calculate(**rate_params) == -8.95

0 commit comments

Comments
 (0)