Skip to content

Commit 79ccd2a

Browse files
authored
Cashflow Interface and Models (#40)
* working cashflow setup * add `get`, skipping name gathering step * Automated version bump to 0.0.22 * fix type hints * fix model name recovery * fix ruff errors * clean up cashflow model code * add some cashflow unit tests --------- Co-authored-by: KSafran <[email protected]>
1 parent 8b7c119 commit 79ccd2a

File tree

8 files changed

+348
-7
lines changed

8 files changed

+348
-7
lines changed

ledger_analytics/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.0.21'
1+
__version__ = '0.0.22'

ledger_analytics/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from .__about__ import __version__
22
from .api import AnalyticsClient
33
from .autofit import AutofitControl
4+
from .cashflow import CashflowModel
45
from .development import GMCL, ChainLadder, ManualATA, MeyersCRC, TraditionalChainLadder
56
from .forecast import AR1, SSM, TraditionalGCC
6-
from .interface import ModelInterface, TriangleInterface
7+
from .interface import CashflowInterface, ModelInterface, TriangleInterface
78
from .model import DevelopmentModel, ForecastModel, TailModel
89
from .requester import Requester
910
from .tail import ClassicalPowerTransformTail, GeneralizedBondy, Sherman

ledger_analytics/api.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from abc import ABC
55
from collections import namedtuple
66

7-
from .interface import ModelInterface, TriangleInterface
7+
from .interface import CashflowInterface, ModelInterface, TriangleInterface
88
from .requester import Requester
99

1010
DEFAULT_HOST = "https://api.ldgr.app/analytics/"
@@ -78,6 +78,11 @@ def __init__(
7878
"forecast_model", self.host, self._requester, self.asynchronous
7979
)
8080
)
81+
cashflow_model = property(
82+
lambda self: CashflowInterface(
83+
"cashflow_model", self.host, self._requester, self.asynchronous
84+
)
85+
)
8186

8287
def test_endpoint(self) -> str:
8388
self._requester.get(self.host + "triangle")

ledger_analytics/cashflow.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from typing import Dict
5+
6+
from requests import Response
7+
from rich.console import Console
8+
9+
from .config import JSONDict, ValidationConfig
10+
from .interface import CashflowInterface, TriangleInterface
11+
from .requester import Requester
12+
from .triangle import Triangle
13+
14+
15+
class CashflowModel(CashflowInterface):
16+
def __init__(
17+
self,
18+
id: str,
19+
name: str,
20+
dev_model_name: str,
21+
tail_model_name: str,
22+
model_class: str,
23+
endpoint: str,
24+
requester: Requester,
25+
asynchronous: bool = False,
26+
) -> None:
27+
super().__init__(model_class, endpoint, requester, asynchronous)
28+
29+
self._endpoint = endpoint
30+
self._id = id
31+
self._name = name
32+
self._dev_model_name = dev_model_name
33+
self._tail_model_name = tail_model_name
34+
self._model_class = model_class
35+
self._fit_response: Response | None = None
36+
self._predict_response: Response | None = None
37+
self._get_response: Response | None = None
38+
39+
id = property(lambda self: self._id)
40+
name = property(lambda self: self._name)
41+
dev_model_name = property(lambda self: self._dev_model_name)
42+
tail_model_name = property(lambda self: self._tail_model_name)
43+
model_class = property(lambda self: self._model_class)
44+
endpoint = property(lambda self: self._endpoint)
45+
fit_response = property(lambda self: self._fit_response)
46+
predict_response = property(lambda self: self._predict_response)
47+
get_response = property(lambda self: self._get_response)
48+
delete_response = property(lambda self: self._delete_response)
49+
50+
@classmethod
51+
def get(
52+
cls,
53+
id: str,
54+
name: str,
55+
dev_model_name: str,
56+
tail_model_name: str,
57+
model_class: str,
58+
endpoint: str,
59+
requester: Requester,
60+
asynchronous: bool = False,
61+
) -> CashflowModel:
62+
console = Console()
63+
with console.status("Retrieving...", spinner="bouncingBar") as _:
64+
console.log(f"Getting model '{name}' with ID '{id}'")
65+
get_response = requester.get(endpoint, stream=True)
66+
67+
self = cls(
68+
id,
69+
name,
70+
dev_model_name,
71+
tail_model_name,
72+
model_class,
73+
endpoint,
74+
requester,
75+
asynchronous,
76+
)
77+
self._get_response = get_response
78+
return self
79+
80+
@classmethod
81+
def fit_from_interface(
82+
cls,
83+
name: str,
84+
dev_model_name: str,
85+
tail_model_name: str,
86+
model_class: str,
87+
endpoint: str,
88+
requester: Requester,
89+
asynchronous: bool = False,
90+
) -> CashflowModel:
91+
"""This method fits a new model and constructs a CashflowModel instance.
92+
It's intended to be used from the `ModelInterface` class mainly,
93+
and in the future will likely be superseded by having separate
94+
`create` and `fit` API endpoints.
95+
"""
96+
97+
post_data = {
98+
"development_model_name": dev_model_name,
99+
"tail_model_name": tail_model_name,
100+
"name": name,
101+
"model_config": {},
102+
}
103+
fit_response = requester.post(endpoint, data=post_data)
104+
id = fit_response.json()["model"]["id"]
105+
self = cls(
106+
id=id,
107+
name=name,
108+
dev_model_name=dev_model_name,
109+
tail_model_name=tail_model_name,
110+
model_class=model_class,
111+
endpoint=endpoint + f"/{id}",
112+
requester=requester,
113+
asynchronous=asynchronous,
114+
)
115+
116+
self._fit_response = fit_response
117+
118+
return self
119+
120+
def predict(
121+
self,
122+
triangle: str | Triangle,
123+
config: JSONDict | None = None,
124+
initial_loss_triangle: Triangle | str | None = None,
125+
prediction_name: str | None = None,
126+
timeout: int = 300,
127+
) -> Triangle:
128+
triangle_name = triangle if isinstance(triangle, str) else triangle.name
129+
config = {
130+
"triangle_name": triangle_name,
131+
"predict_config": self.PredictConfig(**(config or {})).__dict__,
132+
}
133+
if prediction_name:
134+
config["prediction_name"] = prediction_name
135+
136+
if isinstance(initial_loss_triangle, Triangle):
137+
config["predict_config"]["initial_loss_name"] = initial_loss_triangle.name
138+
elif isinstance(initial_loss_triangle, str):
139+
config["predict_config"]["initial_loss_name"] = initial_loss_triangle
140+
141+
url = self.endpoint + "/predict"
142+
self._predict_response = self._requester.post(url, data=config)
143+
144+
if self._asynchronous:
145+
return self
146+
147+
task_id = self.predict_response.json()["modal_task"]["id"]
148+
task_response = self._poll_remote_task(
149+
task_id=task_id,
150+
task_name=f"Predicting from model '{self.name}' on triangle '{triangle_name}'",
151+
timeout=timeout,
152+
)
153+
if task_response.get("status") != "success":
154+
raise ValueError(f"Task failed: {task_response['error']}")
155+
triangle_id = self.predict_response.json()["predictions"]
156+
triangle = TriangleInterface(
157+
host=self.endpoint.replace(f"{self.model_class_slug}/{self.id}", ""),
158+
requester=self._requester,
159+
).get(id=triangle_id)
160+
return triangle
161+
162+
def delete(self) -> CashflowModel:
163+
self._delete_response = self._requester.delete(self.endpoint)
164+
return self
165+
166+
def _poll(self, task_id: str) -> JSONDict:
167+
endpoint = self.endpoint.replace(
168+
f"{self.model_class_slug}/{self.id}", f"tasks/{task_id}"
169+
)
170+
return self._requester.get(endpoint)
171+
172+
def _poll_remote_task(
173+
self, task_id: str, task_name: str = "", timeout: int = 300
174+
) -> dict:
175+
start = time.time()
176+
status = ["CREATED"]
177+
console = Console()
178+
with console.status("Working...", spinner="bouncingBar") as _:
179+
while time.time() - start < timeout:
180+
task = self._poll(task_id).json()
181+
modal_status = (
182+
"FINISHED" if task["task_response"] is not None else "PENDING"
183+
)
184+
status.append(modal_status)
185+
if status[-1] != status[-2]:
186+
console.log(f"{task_name}: {status[-1]}")
187+
if status[-1].lower() == "finished":
188+
return task["task_response"]
189+
raise TimeoutError(f"Task '{task}' timed out")
190+
191+
class PredictConfig(ValidationConfig):
192+
"""Cashflow model configuration class.
193+
194+
Attributes:
195+
use_bf: Whether or not to use Bornhuetter-Ferguson method to adjust reserve estimates.
196+
use_reverse_bf: Whether or not to use the Reverse B-F method to adjust reserve
197+
estimates.
198+
gamma: Gamma parameter in the Reverse B-F method.
199+
min_reserve: Minimum reserve amounts as a function of development lag.
200+
seed: Seed to use for model sampling. Defaults to ``None``, but it is highly recommended
201+
to set.
202+
"""
203+
204+
use_bf: bool = True
205+
use_reverse_bf: bool = True
206+
gamma: float = 0.7
207+
min_reserve: Dict[float, float] | None
208+
seed: int | None = None

ledger_analytics/development.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ class Config(ValidationConfig):
7171
industry-informed priors for best results.
7272
autofit_override: override the MCMC autofitting procedure arguments. See the documentation
7373
for a fully description of options in the User Guide.
74+
sigma_volume: Boolean indicating whether to use a volume parameter in the variance
75+
function.
7476
prior_only: should a prior predictive simulation be run?
7577
seed: Seed to use for model sampling. Defaults to ``None``, but it is highly recommended
7678
to set.
@@ -85,6 +87,7 @@ class Config(ValidationConfig):
8587
informed_priors_version: str | None = None
8688
use_multivariate: bool = False
8789
autofit_override: dict[str, float | int | None] = None
90+
sigma_volume: bool = False
8891
prior_only: bool = False
8992
seed: int | None = None
9093

ledger_analytics/interface.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,111 @@ def _get_details_from_id_name(
207207
)
208208
raise ValueError(f"No model found with {name_or_id}.")
209209
return models[0]
210+
211+
212+
class CashflowInterface(metaclass=ModelRegistry):
213+
"""The CashflowInterface class allows basic CRUD operations
214+
on for Cashflow endpoints and objects."""
215+
216+
def __init__(
217+
self,
218+
model_class: str,
219+
host: str,
220+
requester: Requester,
221+
asynchronous: bool = False,
222+
) -> None:
223+
self._model_class = model_class
224+
self._host = host
225+
self._endpoint = host + self.model_class_slug
226+
self._requester = requester
227+
self._asynchronous = asynchronous
228+
229+
model_class = property(lambda self: self._model_class)
230+
endpoint = property(lambda self: self._endpoint)
231+
232+
def create(
233+
self,
234+
dev_model: str | "DevelopmentModel",
235+
tail_model: str | "TailModel",
236+
name: str,
237+
):
238+
dev_model_name = dev_model if isinstance(dev_model, str) else dev_model.name
239+
tail_model_name = tail_model if isinstance(tail_model, str) else tail_model.name
240+
return ModelRegistry.REGISTRY["cashflow_model"].fit_from_interface(
241+
name=name,
242+
dev_model_name=dev_model_name,
243+
tail_model_name=tail_model_name,
244+
model_class=self.model_class,
245+
endpoint=self.endpoint,
246+
requester=self._requester,
247+
asynchronous=self._asynchronous,
248+
)
249+
250+
def get(self, name: str | None = None, id: str | None = None):
251+
model_obj = self._get_details_from_id_name(name, id)
252+
endpoint = self.endpoint + f"/{model_obj['id']}"
253+
dev_interface = ModelInterface(
254+
"development-model", self._host, self._requester, self._asynchronous
255+
)
256+
dev_model_name = dev_interface.get(id=model_obj["development_model"]).name
257+
tail_interface = ModelInterface(
258+
"tail-model", self._host, self._requester, self._asynchronous
259+
)
260+
tail_model_name = tail_interface.get(id=model_obj["tail_model"]).name
261+
return ModelRegistry.REGISTRY["cashflow_model"].get(
262+
id=model_obj["id"],
263+
name=model_obj["name"],
264+
dev_model_name=dev_model_name,
265+
tail_model_name=tail_model_name,
266+
model_class=self.model_class,
267+
endpoint=endpoint,
268+
requester=self._requester,
269+
asynchronous=self._asynchronous,
270+
)
271+
272+
def predict(
273+
self,
274+
triangle: str | Triangle,
275+
config: JSONDict | None = None,
276+
initial_loss_triangle: str | Triangle | None = None,
277+
timeout: int = 300,
278+
name: str | None = None,
279+
id: str | None = None,
280+
):
281+
model = self.get(name, id)
282+
return model.predict(
283+
triangle,
284+
config=config,
285+
initial_loss_triangle=initial_loss_triangle,
286+
timeout=timeout,
287+
)
288+
289+
def delete(self, name: str | None = None, id: str | None = None) -> None:
290+
model = self.get(name, id)
291+
return model.delete()
292+
293+
def list(self) -> list[JSONDict]:
294+
return self._requester.get(self.endpoint, stream=True).json()
295+
296+
def list_model_types(self) -> list[JSONDict]:
297+
url = self.endpoint + "-type"
298+
return self._requester.get(url).json()
299+
300+
@property
301+
def model_class_slug(self):
302+
return self.model_class.replace("_", "-")
303+
304+
def _get_details_from_id_name(
305+
self, model_name: str | None = None, model_id: str | None = None
306+
) -> str:
307+
models = [
308+
result
309+
for result in self.list().get("results")
310+
if result.get("name") == model_name or result.get("id") == model_id
311+
]
312+
if not len(models):
313+
name_or_id = (
314+
f"name '{model_name}'" if model_id is None else f"ID '{model_id}'"
315+
)
316+
raise ValueError(f"No model found with {name_or_id}.")
317+
return models[0]

ledger_analytics/model.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,6 @@ def fit_from_interface(
110110
"model_config": cls.Config(**config).__dict__,
111111
}
112112
fit_response = requester.post(endpoint, data=config)
113-
if not fit_response.ok:
114-
fit_response.raise_for_status()
115113
id = fit_response.json()["model"]["id"]
116114
self = cls(
117115
id=id,
@@ -162,8 +160,6 @@ def predict(
162160

163161
url = self.endpoint + "/predict"
164162
self._predict_response = self._requester.post(url, data=config)
165-
if not self._predict_response.ok:
166-
self._predict_response.raise_for_status()
167163

168164
if self._asynchronous:
169165
return self

0 commit comments

Comments
 (0)