Repository: casact/chainladder-python

Issue:
[ENH] Validate cl.load_sample()
I'll add some validation to cl.load_sample(), the reason why, is I tried using some nonsense key to see what kind of traceback we'd get:

```
cl.load_sample('foo')
```
```
Traceback (most recent call last):
  File "/home/ubuntu/anaconda3/envs/cl_dev/lib/python3.12/site-packages/IPython/core/interactiveshell.py", line 3549, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-5-a91138af3da2>", line 1, in <module>
    cl.load_sample('foo')
  File "/home/ubuntu/Repos/chainladder-python/chainladder/utils/utility_functions.py", line 92, in load_sample
    df = pd.read_csv(os.path.join(path, "data", key.lower() + ".csv"))
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/anaconda3/envs/cl_dev/lib/python3.12/site-packages/pandas/io/parsers/readers.py", line 1026, in read_csv
    return _read(filepath_or_buffer, kwds)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/anaconda3/envs/cl_dev/lib/python3.12/site-packages/pandas/io/parsers/readers.py", line 620, in _read
    parser = TextFileReader(filepath_or_buffer, **kwds)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/anaconda3/envs/cl_dev/lib/python3.12/site-packages/pandas/io/parsers/readers.py", line 1620, in __init__
    self._engine = self._make_engine(f, self.engine)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/anaconda3/envs/cl_dev/lib/python3.12/site-packages/pandas/io/parsers/readers.py", line 1880, in _make_engine
    self.handles = get_handle(
                   ^^^^^^^^^^^
  File "/home/ubuntu/anaconda3/envs/cl_dev/lib/python3.12/site-packages/pandas/io/common.py", line 873, in get_handle
    handle = open(
             ^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/home/ubuntu/Repos/chainladder-python/chainladder/utils/data/foo.csv'
``` 

So, something simpler will do, like "Invalid key provided, please refer to the chainladder documentation on available sample data sets."

Discussion:

Solution patch:
diff --git a/chainladder/core/base.py b/chainladder/core/base.py
index fa42c03..e50e2a6 100644
--- a/chainladder/core/base.py
+++ b/chainladder/core/base.py
@@ -487,8 +487,10 @@ class TriangleBase(
                 "Array backend is invalid or not properly set. Supported backends are: " + ', '.join([*modules])
             ) from e
 
-    def _auto_sparse(self):
-        """Auto sparsifies at 30Mb or more and 20% density or less"""
+    def _auto_sparse(self) -> None:
+        """
+        Auto sparsifies at 30Mb or more and 20% density or less.
+        """
         if not options.AUTO_SPARSE:
             return self
         n = np.prod(list(self.shape) + [8 / 1e6])
diff --git a/chainladder/core/common.py b/chainladder/core/common.py
index 54296a2..ccb95bc 100644
--- a/chainladder/core/common.py
+++ b/chainladder/core/common.py
@@ -133,8 +133,15 @@ class Common:
     def pipe(self, func, *args, **kwargs):
         return func(self, *args, **kwargs)
 
-    def set_backend(self, backend, inplace=False, deep=False, **kwargs):
-        """ Converts triangle array_backend.
+    def set_backend(
+            self,
+            backend: str,
+            inplace: bool = False,
+            deep: bool = False,
+            **kwargs
+    ):
+        """
+        Converts triangle array_backend.
 
         Parameters
         ----------
@@ -149,10 +156,10 @@ class Common:
             Triangle with updated array_backend
         """
         if hasattr(self, "array_backend"):
-            old_backend = self.array_backend
+            old_backend: str = self.array_backend
         else:
             if hasattr(self, "ldf_"):
-                old_backend = self.ldf_.array_backend
+                old_backend: str = self.ldf_.array_backend
             else:
                 raise ValueError("Unable to determine array backend.")
         if inplace:
@@ -160,9 +167,9 @@ class Common:
             # going to dask  -
             if old_backend == "dask" and backend != "dask":
                 self = self.compute()
-                old_backend = self.array_backend
+                old_backend: str = self.array_backend
             if backend in ["numpy", "sparse", "cupy", "dask"]:
-                lookup = {
+                lookup: dict = {
                     "numpy": {
                         "sparse": lambda x: x.todense(),
                         "cupy": lambda x: cp.asnumpy(x),
diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py
index abe87a3..26a9e6e 100644
--- a/chainladder/core/pandas.py
+++ b/chainladder/core/pandas.py
@@ -1,17 +1,28 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at https://mozilla.org/MPL/2.0/.
-import pandas as pd
+from __future__ import annotations
+
 import numpy as np
-import warnings
-from sklearn.utils import deprecated
+import pandas as pd
+
 from chainladder.utils.utility_functions import num_to_nan
+from typing import TYPE_CHECKING
+
 
 try:
     import dask.bag as db
-except:
+except ImportError:
     db = None
 
+if TYPE_CHECKING:
+    from chainladder import Triangle
+    from collections.abc import Callable
+    from numpy.typing import ArrayLike
+    from types import ModuleType
+    from typing import Type
+
+
 
 class TriangleGroupBy:
     def __init__(self, obj, by, axis=0, **kwargs):
@@ -152,16 +163,43 @@ class TrianglePandas:
             df.index = df.index.to_timestamp(how="s")
         return df.hvplot(*args, **kwargs)
 
-    def _get_axis(self, axis):
+    @staticmethod
+    def _get_axis(axis: str | int| None) -> int:
+        """
+        Returns the integer representation of the requested axis.
+
+        Parameters
+        ----------
+        axis: str | int | None
+            String or integer representation of the requested axis. If
+            supplied as a string, returns the integer representation. If
+            supplied as an integer, returns the same integer.
+
+        Returns`
+        -------
+        The integer representation of the requested axis
+
+        """
+
         ax = {
             **{0: 0, 1: 1, 2: 2, 3: 3},
             **{-1: 3, -2: 2, -3: 1, -4: 0},
             **{"index": 0, "columns": 1, "origin": 2, "development": 3},
         }
-        return ax.get(axis, 0)
+
+        try:
+            return ax[axis]
+        except KeyError:
+            if axis is None:
+                return 0
+            else:
+                raise ValueError(
+                    "Invalid axis specified. Please specify the correct string or "
+                    "integer representation of the desired axis."
+                )
 
     def dropna(self):
-        """Method that removes orgin/development vectors from edge of a
+        """Method that removes origin/development vectors from edge of a
         triangle that are all missing values. This may come in handy for a
         new line of business that doesn't have origins/developments of an
         existing line in the same triangle.
@@ -344,30 +382,63 @@ class TrianglePandas:
         return round(self, decimals)
 
 
-def add_triangle_agg_func(cls, k, v):
-    """Aggregate Overrides in Triangle"""
+def add_triangle_agg_func(
+        cls: Type[TrianglePandas],
+        k: str,
+        v: str
+):
+    """
+    Aggregate Overrides in Triangle
+    """
+
+    def agg_func(
+            self: Triangle,
+            axis: str | int | None = None,
+            *args,
+            **kwargs
+    ) -> Triangle | np.ndarray:
+        """
+        Applies the aggregation function specified by k from the outer function.
 
-    def agg_func(self, axis=None, *args, **kwargs):
+        Parameters
+        ----------
+
+        self: Triangle
+            The triangle to which the aggregation function will be applied.
+
+        axis: str | int | None
+            The axis of the triangle to which the aggregation function will be applied.
+
+        Returns
+        -------
+
+        An aggregated Triangle or ndarray.
+        """
         keepdims = kwargs.get("keepdims", None)
         obj = self.copy()
-        auto_sparse = kwargs.pop("auto_sparse", True)
+        auto_sparse: bool = kwargs.pop("auto_sparse", True)
         if axis is None:
             if max(obj.shape) == 1:
-                axis = 0
+                axis: int = 0
             else:
-                axis = min([num for num, _ in enumerate(obj.shape) if _ != 1])
+                axis: int = min([num for num, _ in enumerate(obj.shape) if _ != 1])
         else:
             axis = self._get_axis(axis)
-        xp = obj.get_array_module()
-        func = getattr(xp, v)
+        xp: ModuleType = obj.get_array_module()
+        func: Callable = getattr(xp, v)
         kwargs.update({"keepdims": True})
+        # Apply the function on the requested axis.
         obj.values = func(obj.values, axis=axis, *args, **kwargs)
+
+        # Aggregation function will collapse a dimension, so
+        # adjust the dimensions of the original object to match that of the aggregation.
         if axis == 0 and obj.values.shape[axis] == 1 and len(obj.kdims) > 1:
             obj.kdims = np.array([["(All)"] * len(obj.key_labels)])
         if axis == 1 and obj.values.shape[axis] == 1 and len(obj.vdims) > 1:
             obj.vdims = np.array([0])
         if axis == 2 and obj.values.shape[axis] == 1 and len(obj.odims) > 1:
             obj.odims = obj.odims[0:1]
+        # If axis is development, set the ddims to be the valuation date.
         if axis == 3 and obj.values.shape[axis] == 1 and len(obj.ddims) > 1:
             obj.ddims = pd.DatetimeIndex(
                 [self.valuation_date], dtype="datetime64[ns]", freq=None
@@ -375,7 +446,7 @@ def add_triangle_agg_func(cls, k, v):
         obj._set_slicers()
         if auto_sparse:
             obj = obj._auto_sparse()
-        obj.values = num_to_nan(obj.values)
+        obj.values: ArrayLike = num_to_nan(obj.values)
         if not keepdims and obj.shape == (1, 1, 1, 1):
             return obj.values[0, 0, 0, 0]
         else:
@@ -384,7 +455,7 @@ def add_triangle_agg_func(cls, k, v):
     set_method(cls, agg_func, k)
 
 
-def add_groupby_agg_func(cls, k, v):
+def add_groupby_agg_func(cls, k: str, v: str):
     """Aggregate Overrides in GroupBy"""
 
     def agg_func(self, *args, **kwargs):
@@ -443,7 +514,11 @@ def add_groupby_agg_func(cls, k, v):
             obj = obj._auto_sparse()
         return obj
 
-    set_method(cls, agg_func, k)
+    set_method(
+        cls=cls,
+        func=agg_func,
+        k=k
+    )
 
 
 def add_df_passthru(cls, k):
@@ -455,8 +530,31 @@ def add_df_passthru(cls, k):
     set_method(cls, df_passthru, k)
 
 
-def set_method(cls, func, k):
-    """Assigns methods to a class"""
+def set_method(
+        cls: Type[TrianglePandas | TriangleGroupBy],
+        func: Callable,
+        k: str
+) -> None:
+    """
+    Assigns methods to a class.
+
+    Parameters
+    ----------
+
+    cls: Type[TrianglePandas | TriangleGroupBy]
+        Class to be modified.
+
+    func: Callable
+        Method to be added to the class supplied to parameter cls.
+
+    k: str
+        Name of the method to be added to the class supplied to parameter cls.
+
+    Returns
+    -------
+
+    None
+    """
     func.__doc__ = "Refer to pandas for ``{}`` functionality.".format(k)
     func.__name__ = k
     setattr(cls, func.__name__, func)
diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py
index 23e2546..3aeeeab 100644
--- a/chainladder/core/slice.py
+++ b/chainladder/core/slice.py
@@ -14,7 +14,9 @@ if TYPE_CHECKING:
     from chainladder import Triangle
 
 class _LocBase:
-    """ Base class for pandas style loc/iloc indexing """
+    """
+    Base class for pandas style loc/iloc indexing.
+    """
 
     def __init__(self, obj):
         self.obj = obj
@@ -177,7 +179,9 @@ class Location(_LocBase):
         super().__setitem__(self.key_to_slice(key), values)
 
 class Ilocation(_LocBase):
-    """ class to generate .iloc[] functionality """
+    """
+    Class to generate .iloc[] functionality.
+    """
 
     def __getitem__(self, key):
         return self.get_idx(self._normalize_index(key))
@@ -270,7 +274,7 @@ class TriangleSlicer:
         obj.values = obj.values[slicer]
         return obj
 
-    def _set_slicers(self):
+    def _set_slicers(self) -> None:
         """ Call any time the shape of index or column changes """
         self.iloc, self.loc = Ilocation(self), Location(self)
         self.iat, self.at = Iat(self), At(self)
@@ -334,7 +338,7 @@ class Iat(Ilocation):
 
 
 class VirtualColumns:
-    def __init__(self, triangle: Triangle, columns=None):
+    def __init__(self, triangle: TriangleSlicer, columns=None):
         self.triangle = triangle
         self.columns = {} if not columns else columns
 
diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py
index 7910583..21a9c20 100644
--- a/chainladder/core/triangle.py
+++ b/chainladder/core/triangle.py
@@ -128,7 +128,7 @@ class Triangle(TriangleBase):
         origin_format: Optional[str] = None,
         development_format: Optional[str] = None,
         cumulative: Optional[bool] = None,
-        array_backend=None,
+        array_backend: str = None,
         pattern=False,
         trailing: bool = True,
         *args,
@@ -310,12 +310,15 @@ class Triangle(TriangleBase):
                 ),
             )
         )
-        # Deal with array backend
+        # Deal with array backend.
         self.array_backend = "sparse"
         if array_backend is None:
-            array_backend = options.ARRAY_BACKEND
+            array_backend: str = options.ARRAY_BACKEND
         if not options.AUTO_SPARSE or array_backend == "cupy":
-            self.set_backend(array_backend, inplace=True)
+            self.set_backend(
+                backend=array_backend,
+                inplace=True
+            )
         else:
             self = self._auto_sparse()
         self._set_slicers()
@@ -337,7 +340,7 @@ class Triangle(TriangleBase):
     def _split_ult(
         data: DataFrame, index: list, columns: list, origin: list, development: list
     ) -> tuple[DataFrame, Triangle]:
-        """Deal with triangles with ultimate values"""
+        """Deal with triangles with ultimate values."""
         ult = None
         if (
             development
@@ -360,11 +363,11 @@ class Triangle(TriangleBase):
         return data, ult
 
     @property
-    def index(self):
+    def index(self) -> DataFrame:
         return pd.DataFrame(list(self.kdims), columns=self.key_labels)
 
     @index.setter
-    def index(self, value):
+    def index(self, value) -> None:
         self._len_check(self.index, value)
         if type(value) is pd.DataFrame:
             self.kdims = value.values
@@ -453,7 +456,16 @@ class Triangle(TriangleBase):
         return type(self.ddims) == pd.DatetimeIndex
 
     @property
-    def is_full(self):
+    def is_full(self) -> bool:
+        """
+        Property that in indicates whether lower half of Triangle has been filled in.
+
+        Returns
+        -------
+
+        bool
+        """
+
         return self.nan_triangle.sum().sum() == np.prod(self.shape[-2:])
 
     @property
@@ -461,13 +473,28 @@ class Triangle(TriangleBase):
         return sum(self.valuation >= options.ULT_VAL[:4]) > 0
 
     @property
-    def latest_diagonal(self):
-        return self[self.valuation == self.valuation_date].sum("development")
+    def latest_diagonal(self) -> Triangle:
+        """
+        The latest diagonal of the triangle.
+        """
+        return self[self.valuation == self.valuation_date].sum(axis="development")
 
     @property
-    def link_ratio(self):
+    def link_ratio(self) -> Triangle:
+        """
+        Displays age-to-age ratios for the triangle. If the calling Triangle object already has the
+        self.is_pattern set to true (i.e., it is already a set of link ratios or development patterns),
+        this property simply returns itself.
+
+        Returns
+        -------
+
+        Triangle object in link ratio form.
+        """
+
+        # Case where triangle is not a set of link ratios or development patterns.
         if not self.is_pattern:
-            obj = (1 / self.iloc[..., :-1]) * self.iloc[..., 1:].values
+            obj: Triangle = (1 / self.iloc[..., :-1]) * self.iloc[..., 1:].values
             if not obj.is_full:
                 obj = obj[obj.valuation < obj.valuation_date]
             if hasattr(obj, "w_"):
@@ -477,6 +504,7 @@ class Triangle(TriangleBase):
             obj.is_cumulative = False
             obj.values = num_to_nan(obj.values)
             return obj
+        # Case where triangle already is a set of link ratios or development patterns.
         else:
             return self
 
diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py
index 3e97627..3f95080 100644
--- a/chainladder/utils/utility_functions.py
+++ b/chainladder/utils/utility_functions.py
@@ -25,39 +25,78 @@ from typing import (
 )
 
 if TYPE_CHECKING:
+    from chainladder import Triangle
     from numpy.typing import ArrayLike
     from sparse import COO
     from types import ModuleType
+    from typing import AnyStr
 
 
-def load_sample(key: str, *args, **kwargs):
-    """Function to load datasets included in the chainladder package.
+def load_sample(
+        key: str,
+        *args,
+        **kwargs
+) -> Triangle:
+    """Function to load datasets included in the chainladder package. These consist of CSV
+    files located in the repository directory chainladder/utils/data.
 
     Parameters
     ----------
     key: str
-        The name of the dataset, e.g. RAA, ABC, UKMotor, GenIns, etc.
+        The name of the dataset, e.g. RAA, ABC, UKMotor, GenIns, etc. The name should match the
+        file name, without extension, of one of the files in the sample data folder.
 
     Returns
     -------
-        pandas.DataFrame of the loaded dataset.
+        chainladder.Triangle of the loaded dataset.
 
     """
     from chainladder import Triangle
 
-    path = os.path.dirname(os.path.abspath(__file__))
-    origin = "origin"
-    development = "development"
-    columns = ["values"]
-    index = None
-    cumulative = True
-    if key.lower() in ["mcl", "usaa", "quarterly", "auto", "usauto", "tail_sample"]:
-        columns = ["incurred", "paid"]
+
+    # Set base path to be the parent directory of this file, e.g., the utils folder.
+    utils_path: AnyStr = os.path.dirname(os.path.abspath(__file__))
+
+    # Validate that the file indicated by the key argument exists.
+    dataset_path: str = os.path.join(utils_path, "data", key.lower() + ".csv")
+
+    if not os.path.exists(dataset_path):
+        raise ValueError(
+            """
+            Invalid key supplied. The key should match the name, without extension, of one of the file names
+            in the sample data set folder. Please refer to the documentation page on sample data sets to see 
+            what data are available.
+            """
+         )
+
+    # Set initial values for arguments to Triangle __init__. These may be overridden by
+    # values specific to the data set.
+    origin: str = "origin"
+    development: str = "development"
+    columns: list = ["values"]
+    index: list | None = None
+    cumulative: bool = True
+
+    if key.lower() in [
+        "mcl",
+        "usaa",
+        "quarterly",
+        "auto",
+        "usauto",
+        "tail_sample"
+    ]:
+        columns: list = [
+            "incurred",
+            "paid"
+        ]
     if key.lower() == "clrd":
-        origin = "AccidentYear"
-        development = "DevelopmentYear"
-        index = ["GRNAME", "LOB"]
-        columns = [
+        origin: str = "AccidentYear"
+        development: str = "DevelopmentYear"
+        index: list = [
+            "GRNAME",
+            "LOB"
+        ]
+        columns: list = [
             "IncurLoss",
             "CumPaidLoss",
             "BulkLoss",
@@ -66,28 +105,61 @@ def load_sample(key: str, *args, **kwargs):
             "EarnedPremNet",
         ]
     if key.lower() == "berqsherm":
-        origin = "AccidentYear"
-        development = "DevelopmentYear"
-        index = ["LOB"]
-        columns = ["Incurred", "Paid", "Reported", "Closed"]
+        origin: str = "AccidentYear"
+        development: str = "DevelopmentYear"
+        index: list = ["LOB"]
+        columns: list = [
+            "Incurred",
+            "Paid",
+            "Reported",
+            "Closed"
+        ]
     if key.lower() == "xyz":
-        origin = "AccidentYear"
-        development = "DevelopmentYear"
-        columns = ["Incurred", "Paid", "Reported", "Closed", "Premium"]
-    if key.lower() in ["liab", "auto"]:
-        index = ["lob"]
-    if key.lower() in ["cc_sample", "ia_sample"]:
-        columns = ["loss", "exposure"]
+        origin: str = "AccidentYear"
+        development: str = "DevelopmentYear"
+        columns: list = [
+            "Incurred",
+            "Paid",
+            "Reported",
+            "Closed",
+            "Premium"
+        ]
+    if key.lower() in [
+        "liab",
+        "auto"
+    ]:
+        index: list = ["lob"]
+    if key.lower() in [
+        "cc_sample",
+        "ia_sample"
+    ]:
+        columns: list = [
+            "loss",
+            "exposure"
+        ]
     if key.lower() in ["prism"]:
-        columns = ["reportedCount", "closedPaidCount", "Paid", "Incurred"]
-        index = ["ClaimNo", "Line", "Type", "ClaimLiability", "Limit", "Deductible"]
-        origin = "AccidentDate"
-        development = "PaymentDate"
-        cumulative = False
-    df = pd.read_csv(os.path.join(path, "data", key.lower() + ".csv"))
+        columns: list = [
+            "reportedCount",
+            "closedPaidCount",
+            "Paid",
+            "Incurred"
+        ]
+        index: list = [
+            "ClaimNo",
+            "Line",
+            "Type",
+            "ClaimLiability",
+            "Limit",
+            "Deductible"
+        ]
+        origin: str = "AccidentDate"
+        development: str = "PaymentDate"
+        cumulative: bool = False
+
+    df = pd.read_csv(filepath_or_buffer=dataset_path)
 
     return Triangle(
-        df,
+        data=df,
         origin=origin,
         development=development,
         index=index,
diff --git a/docs/getting_started/tutorials/triangle-tutorial.ipynb b/docs/getting_started/tutorials/triangle-tutorial.ipynb
index b4efe06..eee74db 100644
--- a/docs/getting_started/tutorials/triangle-tutorial.ipynb
+++ b/docs/getting_started/tutorials/triangle-tutorial.ipynb
@@ -6,9 +6,9 @@
    "source": [
     "# Working with Triangles\n",
     "## Getting Started\n",
-    "Welcome! We drafted these tutorials to help you get familiar with some of the common functionalities that most actuaries can use in their day-to-day responsibilities. The package also comes with pre-installed datasets that you can play with, which are also used in the tutorials here.\n",
+    "Welcome! We drafted these tutorials to help you get familiar with some of the common functionalities that most actuaries can use in their day-to-day responsibilities. The package also comes with [pre-installed datasets](https://chainladder-python.readthedocs.io/en/latest/library/sample_data.html#sample-dataset) that you can play with, which are also used in the tutorials here.\n",
     "\n",
-    "The tutorials assume that you have the basic understanding of commonly used actuarial terms, and can independently perform an actuarial analysis in another tool, such as Microsoft Excel or another actuarial software. Furthermore, it is assumed that you already have some familiarity with Python, and that you have the basic knowledge and experience in using some common packages that are popular in the Python community, such as `pandas` and `numpy`."
+    "The tutorials assume that you have the basic understanding of commonly used actuarial terms, and can independently perform an actuarial analysis in another tool, such as Microsoft Excel or another actuarial software. Furthermore, it is assumed that you already have some familiarity with Python, and that you have the basic knowledge and experience in using some common packages that are popular in the Python community, such as `pandas` and `numpy`."
    ]
   },
   {
@@ -18,6 +18,13 @@
     "This tutorial is linted using [black](https://github.com/psf/black) via [nb_black](https://github.com/dnanhkhoa/nb_black). This step is optional."
    ]
   },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "outputs": [],
+   "execution_count": null,
+   "source": ""
+  },
   {
    "cell_type": "code",
    "execution_count": 1,
@@ -31,9 +38,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "All tutorials and exercises rely on chainladder v0.8.12 and later. It is highly recomended that you keep your packages up-to-date.\n",
+    "All tutorials and exercises rely on chainladder v0.8.12 and later. It is highly recommended that you run your project in a virtual environment to facilitate package management and to avoid dependency conflicts with other projects.\n",
     "\n",
-    "For more info on how to update your pakages, visit [Keeping Packages Updated](https://chainladder-python.readthedocs.io/en/latest/library/install.html#keeping-packages-updated)."
+    "For more info on installing chainladder and managing its dependencies, visit [Installation](https://chainladder-python.readthedocs.io/en/latest/getting_started/install.html)."
    ]
   },
   {
@@ -3720,23 +3727,23 @@
     {
      "data": {
       "text/plain": [
-       "\u001b[0;31mInit signature:\u001b[0m\n",
-       "\u001b[0mcl\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mTriangle\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mdata\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0morigin\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mdevelopment\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mcolumns\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mindex\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0morigin_format\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mdevelopment_format\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mcumulative\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0marray_backend\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mpattern\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mFalse\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0mtrailing\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m    \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
-       "\u001b[0;34m\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
-       "\u001b[0;31mDocstring:\u001b[0m     \n",
+       "\u001B[0;31mInit signature:\u001B[0m\n",
+       "\u001B[0mcl\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mTriangle\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mdata\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0morigin\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mdevelopment\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mcolumns\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mindex\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0morigin_format\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mdevelopment_format\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mcumulative\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0marray_backend\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mpattern\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mFalse\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0mtrailing\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mTrue\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0;34m*\u001B[0m\u001B[0margs\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m    \u001B[0;34m**\u001B[0m\u001B[0mkwargs\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\n",
+       "\u001B[0;34m\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
+       "\u001B[0;31mDocstring:\u001B[0m     \n",
        "The core data structure of the chainladder package\n",
        "\n",
        "Parameters\n",
@@ -3819,9 +3826,9 @@
        "T: Triangle\n",
        "    Transpose index and columns of object.  Only available when Triangle is\n",
        "    convertible to DataFrame.\n",
-       "\u001b[0;31mFile:\u001b[0m           /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/site-packages/chainladder/core/triangle.py\n",
-       "\u001b[0;31mType:\u001b[0m           type\n",
-       "\u001b[0;31mSubclasses:\u001b[0m     \n"
+       "\u001B[0;31mFile:\u001B[0m           /Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/site-packages/chainladder/core/triangle.py\n",
+       "\u001B[0;31mType:\u001B[0m           type\n",
+       "\u001B[0;31mSubclasses:\u001B[0m     \n"
       ]
      },
      "metadata": {},


Test patch:
diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py
index 0fbd020..cb693d3 100644
--- a/chainladder/utils/tests/test_utilities.py
+++ b/chainladder/utils/tests/test_utilities.py
@@ -1,3 +1,5 @@
+import pytest
+
 import chainladder as cl
 from chainladder.utils.cupy import cp
 import numpy as np
@@ -174,3 +176,10 @@ def test_concat_immutability(raa):
     u_new = copy.deepcopy(u)
     cl.concat((l, u), axis=3)
     assert u == u_new
+
+def test_invalid_sample() -> None:
+    """
+    Test that an invalid sample name provided to cl.load_sample() raises an error.
+    """
+    with pytest.raises(ValueError):
+        cl.load_sample(key='not_a_real_sample_38473743')
\ No newline at end of file