diff --git a/.all-contributorsrc b/.all-contributorsrc index 013b57288..b35a05faa 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -280,6 +280,16 @@ "bug" ] }, + { + "login": "pyarchana", + "name": "Archana K.", + "avatar_url": "https://avatars.githubusercontent.com/u/93970069?v=4", + "profile": "https://github.com/pyarchana", + "contributions": [ + "code", + "test" + ] + }, { "login": "Kushagra651", "name": "Kushagra", diff --git a/docs/source/api_reference/distfitter.rst b/docs/source/api_reference/distfitter.rst index f226ca72a..6d62f984a 100644 --- a/docs/source/api_reference/distfitter.rst +++ b/docs/source/api_reference/distfitter.rst @@ -26,6 +26,7 @@ or a distribution from a longer list of distributions, to fit parametrically. :toctree: auto_generated/ :template: class.rst + DistfitFitter ScipyMLEFitter MOMFitter diff --git a/skpro/distfitter/__init__.py b/skpro/distfitter/__init__.py index f83c2c444..8e46bcdc1 100644 --- a/skpro/distfitter/__init__.py +++ b/skpro/distfitter/__init__.py @@ -1,6 +1,7 @@ """Distribution fitter estimators.""" # copyright: skpro developers, BSD-3-Clause License (see LICENSE file) +from skpro.distfitter._distfitfitter import DistfitFitter from skpro.distfitter._exponentialfitter import ExponentialFitter from skpro.distfitter._laplacefitter import LaplaceFitter from skpro.distfitter._mlefitter import ScipyMLEFitter @@ -9,6 +10,7 @@ from skpro.distfitter._uniformfitter import UniformFitter __all__ = [ + "DistfitFitter", "ExponentialFitter", "LaplaceFitter", "ScipyMLEFitter", diff --git a/skpro/distfitter/_distfit_adapter.py b/skpro/distfitter/_distfit_adapter.py new file mode 100644 index 000000000..1db9c820c --- /dev/null +++ b/skpro/distfitter/_distfit_adapter.py @@ -0,0 +1,52 @@ +"""Generic scalar distribution wrapping a named scipy distribution. + +Used to turn the output of ``distfit`` (a scipy distribution name plus +shape/loc/scale parameters) into a fitted ``skpro`` ``BaseDistribution``, +by reusing the existing ``_ScipyAdapter`` machinery. +""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +from skpro.distributions.adapters.scipy import _ScipyAdapter + +__all__ = ["_DistfitDistribution"] + + +class _DistfitDistribution(_ScipyAdapter): + """Scalar distribution wrapping a scipy distribution selected by ``distfit``. + + Parameters + ---------- + dist_name : str + Name of a distribution in ``scipy.stats``, e.g. ``"norm"``, ``"gamma"``. + shape_args : tuple, optional (default=()) + Positional shape parameters for the ``scipy.stats`` distribution. + dist_loc : float, optional (default=0.0) + Location parameter, passed as ``loc`` to the ``scipy.stats`` distribution. + dist_scale : float, optional (default=1.0) + Scale parameter, passed as ``scale`` to the ``scipy.stats`` distribution. + """ + + _tags = { + "authors": ["areychana"], + "capabilities:exact": ["mean", "var", "pdf", "log_pdf", "cdf", "ppf"], + "distr:measuretype": "continuous", + "distr:paramtype": "parametric", + } + + def __init__(self, dist_name, shape_args=(), dist_loc=0.0, dist_scale=1.0): + self.dist_name = dist_name + self.shape_args = shape_args + self.dist_loc = dist_loc + self.dist_scale = dist_scale + + super().__init__(index=None, columns=None) + + def _get_scipy_object(self): + import scipy.stats + + return getattr(scipy.stats, self.dist_name) + + def _get_scipy_param(self): + args = list(self.shape_args) + kwds = {"loc": self.dist_loc, "scale": self.dist_scale} + return args, kwds diff --git a/skpro/distfitter/_distfitfitter.py b/skpro/distfitter/_distfitfitter.py new file mode 100644 index 000000000..1dcd3f533 --- /dev/null +++ b/skpro/distfitter/_distfitfitter.py @@ -0,0 +1,147 @@ +"""Distribution fitter wrapping the distfit package.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +from skpro.distfitter.base import BaseDistFitter + +__author__ = ["pyarchana"] + + +class DistfitFitter(BaseDistFitter): + """Fit a parametric distribution to data using the ``distfit`` package. + + Wraps `distfit `_'s parametric fitting + procedure: a set of candidate ``scipy.stats`` distributions is fitted to the + data, scored by a goodness-of-fit statistic, and the best-scoring + distribution is returned as a fitted ``skpro`` scalar distribution. + + Only ``distfit``'s ``method="parametric"`` mode is currently supported. + ``distfit``'s ``"quantile"``, ``"percentile"``, and ``"discrete"`` modes + produce differently shaped model output that this fitter does not (yet) + convert into a ``skpro`` distribution. + + Parameters + ---------- + distr : str or list of str, optional (default="popular") + Candidate distribution(s) to fit and compare, passed to ``distfit``. + ``"popular"`` tests ``[norm, expon, pareto, dweibull, t, genextreme, + gamma, lognorm, beta, uniform, loggamma]``; ``"full"`` tests all + ``scipy.stats`` continuous distributions; a single name (e.g. + ``"norm"``) or a list of names restricts to those distributions. + stats : str, optional (default="RSS") + Goodness-of-fit statistic used by ``distfit`` to rank candidates. + One of ``"RSS"``, ``"wasserstein"``, ``"ks"``, ``"energy"``, + ``"goodness_of_fit"``. + bins : int or "auto", optional (default="auto") + Histogram bin size used internally by ``distfit``. + random_state : int, optional (default=None) + Random state passed to ``distfit``. + + Attributes + ---------- + dist_name_ : str + Name of the best-fitting ``scipy.stats`` distribution, as chosen by + ``distfit``. + shape_args_ : tuple + Fitted shape parameters for ``dist_name_``, as returned by ``distfit``. + dist_loc_ : float + Fitted location parameter for ``dist_name_``. + dist_scale_ : float + Fitted scale parameter for ``dist_name_``. + fit_summary_ : pandas.DataFrame + Full ``distfit`` summary table of all candidate distributions tried, + with their scores. + + Examples + -------- + >>> import pandas as pd + >>> from skpro.distfitter import DistfitFitter + >>> X = pd.DataFrame([1.0, 2.0, 3.0, 4.0, 5.0, 4.5, 3.5, 2.5, 1.5]) + + >>> fitter = DistfitFitter(distr="norm") + >>> fitter.fit(X) + DistfitFitter(distr='norm') + >>> dist = fitter.proba() + """ + + _tags = { + "authors": ["pyarchana"], + "python_dependencies": ["distfit"], + } + + def __init__(self, distr="popular", stats="RSS", bins="auto", random_state=None): + self.distr = distr + self.stats = stats + self.bins = bins + self.random_state = random_state + + super().__init__() + + def _fit(self, X, C=None): + """Fit the best-scoring distfit distribution to the data. + + Parameters + ---------- + X : pandas DataFrame + Data to fit the distribution to. + C : ignored + + Returns + ------- + self : reference to self + """ + from distfit import distfit as _distfit + + vals = X.values.ravel() + + dfit = _distfit( + method="parametric", + distr=self.distr, + stats=self.stats, + bins=self.bins, + random_state=self.random_state, + verbose="warning", + ) + dfit.fit_transform(vals) + + model = dfit.model + self.dist_name_ = model["name"] + self.shape_args_ = tuple(model["arg"]) + self.dist_loc_ = float(model["loc"]) + self.dist_scale_ = float(model["scale"]) + self.fit_summary_ = dfit.summary + + return self + + def _proba(self): + """Return the best-fitting distribution found by distfit. + + Returns + ------- + dist : skpro BaseDistribution (scalar) + """ + from skpro.distfitter._distfit_adapter import _DistfitDistribution + + return _DistfitDistribution( + dist_name=self.dist_name_, + shape_args=self.shape_args_, + dist_loc=self.dist_loc_, + dist_scale=self.dist_scale_, + ) + + @classmethod + def get_test_params(cls, parameter_set="default"): + """Return testing parameter settings for the estimator. + + Parameters + ---------- + parameter_set : str, default="default" + Name of the set of test parameters to return. + + Returns + ------- + params : dict or list of dict + Parameters to create testing instances of the class. + """ + params1 = {"distr": "norm"} + params2 = {"distr": ["norm", "expon"], "stats": "wasserstein"} + return [params1, params2] diff --git a/skpro/distfitter/tests/test_distfitfitter.py b/skpro/distfitter/tests/test_distfitfitter.py new file mode 100644 index 000000000..d7b56abe2 --- /dev/null +++ b/skpro/distfitter/tests/test_distfitfitter.py @@ -0,0 +1,81 @@ +"""Tests for the DistfitFitter distribution fitter.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +import numpy as np +import pandas as pd +import pytest + +from skpro.distfitter import DistfitFitter +from skpro.distributions.base import BaseDistribution +from skpro.tests.test_switch import run_test_for_class + + +@pytest.mark.skipif( + not run_test_for_class(DistfitFitter), + reason="run test only if softdeps are present and incrementally (if requested)", +) +def test_distfitfitter_fits_known_normal(): + """DistfitFitter restricted to 'norm' recovers close to true mean/scale.""" + rng = np.random.RandomState(42) + X = pd.DataFrame(rng.normal(loc=5.0, scale=2.0, size=1000)) + + fitter = DistfitFitter(distr="norm") + fitter.fit(X) + + assert fitter.dist_name_ == "norm" + + dist = fitter.proba() + + assert isinstance(dist, BaseDistribution) + assert dist.ndim == 0 + + mean = float(dist.mean()) + var = float(dist.var()) + + assert np.isclose(mean, 5.0, atol=0.3) + assert np.isclose(var, 4.0, atol=1.0) + + +@pytest.mark.skipif( + not run_test_for_class(DistfitFitter), + reason="run test only if softdeps are present and incrementally (if requested)", +) +def test_distfitfitter_stores_fit_summary(): + """DistfitFitter stores a summary table of all candidate distributions.""" + rng = np.random.RandomState(0) + X = pd.DataFrame(rng.normal(size=200)) + + fitter = DistfitFitter(distr=["norm", "expon"]) + fitter.fit(X) + + assert fitter.fit_summary_ is not None + assert len(fitter.fit_summary_) == 2 + assert set(fitter.fit_summary_["name"]) == {"norm", "expon"} + + +@pytest.mark.skipif( + not run_test_for_class(DistfitFitter), + reason="run test only if softdeps are present and incrementally (if requested)", +) +def test_distfitfitter_proba_before_fit_raises(): + """Calling proba() before fit() must raise, per BaseDistFitter contract.""" + fitter = DistfitFitter(distr="norm") + + with pytest.raises(ValueError): + fitter.proba() + + +@pytest.mark.skipif( + not run_test_for_class(DistfitFitter), + reason="run test only if softdeps are present and incrementally (if requested)", +) +def test_distfitfitter_get_params_roundtrip(): + """get_params/set_params/clone round-trip as expected for skpro estimators.""" + fitter = DistfitFitter(distr="norm", stats="wasserstein") + params = fitter.get_params() + + assert params["distr"] == "norm" + assert params["stats"] == "wasserstein" + + clone = fitter.clone() + assert clone.get_params() == fitter.get_params()