From 89582c0e6c180cfb3b7a2a5186d2cb384ddc671f Mon Sep 17 00:00:00 2001 From: areychana <93970069+areychana@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:52:16 +0530 Subject: [PATCH 1/5] Add DistfitFitter distribution fitter wrapping the distfit package --- skpro/distfitter/__init__.py | 2 + skpro/distfitter/_distfit_adapter.py | 52 +++++++ skpro/distfitter/_distfitfitter.py | 141 +++++++++++++++++++ skpro/distfitter/tests/test_distfitfitter.py | 86 +++++++++++ 4 files changed, 281 insertions(+) create mode 100644 skpro/distfitter/_distfit_adapter.py create mode 100644 skpro/distfitter/_distfitfitter.py create mode 100644 skpro/distfitter/tests/test_distfitfitter.py diff --git a/skpro/distfitter/__init__.py b/skpro/distfitter/__init__.py index af5da2d5d..d47e7a4c9 100644 --- a/skpro/distfitter/__init__.py +++ b/skpro/distfitter/__init__.py @@ -1,10 +1,12 @@ """Distribution fitter estimators.""" # copyright: skpro developers, BSD-3-Clause License (see LICENSE file) +from skpro.distfitter._distfitfitter import DistfitFitter from skpro.distfitter._momfitter import MOMFitter from skpro.distfitter._normalfitter import NormalFitter __all__ = [ + "DistfitFitter", "NormalFitter", "MOMFitter", ] 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..e4020679a --- /dev/null +++ b/skpro/distfitter/_distfitfitter.py @@ -0,0 +1,141 @@ +"""Distribution fitter wrapping the distfit package.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +from skpro.distfitter.base import BaseDistFitter + +__author__ = ["areychana"] + + +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``. + 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": ["areychana"], + "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..0fa88e019 --- /dev/null +++ b/skpro/distfitter/tests/test_distfitfitter.py @@ -0,0 +1,86 @@ +"""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 skbase.utils.dependencies import _check_soft_dependencies + +from skpro.distributions.base import BaseDistribution + +DISTFIT_AVAILABLE = _check_soft_dependencies("distfit", severity="none") + + +@pytest.mark.skipif( + not DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" +) +def test_distfitfitter_fits_known_normal(): + """DistfitFitter restricted to 'norm' recovers close to true mean/scale.""" + from skpro.distfitter import DistfitFitter + + 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 DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" +) +def test_distfitfitter_stores_fit_summary(): + """DistfitFitter stores a summary table of all candidate distributions.""" + from skpro.distfitter import DistfitFitter + + 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 DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" +) +def test_distfitfitter_proba_before_fit_raises(): + """Calling proba() before fit() must raise, per BaseDistFitter contract.""" + from skpro.distfitter import DistfitFitter + + fitter = DistfitFitter(distr="norm") + + with pytest.raises(ValueError): + fitter.proba() + + +@pytest.mark.skipif( + not DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" +) +def test_distfitfitter_get_params_roundtrip(): + """get_params/set_params/clone round-trip as expected for skpro estimators.""" + from skpro.distfitter import DistfitFitter + + 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() From dcb2966506978b3af052e5b46b8856e6c24d349c Mon Sep 17 00:00:00 2001 From: areychana <93970069+areychana@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:16:52 +0530 Subject: [PATCH 2/5] Add areychana to contributors list --- .all-contributorsrc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 538b6b063..780c70c20 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -271,6 +271,16 @@ "contributions": [ "bug" ] + }, + { + "login": "areychana", + "name": "Archana K.", + "avatar_url": "https://avatars.githubusercontent.com/u/93970069?v=4", + "profile": "https://github.com/areychana", + "contributions": [ + "code", + "test" + ] } ] } From 87fac97bcb26642d45ac94eb37819e8e8b535578 Mon Sep 17 00:00:00 2001 From: areychana <93970069+areychana@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:45:17 +0530 Subject: [PATCH 3/5] update username to pyarchana in contributors list --- .all-contributorsrc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 780c70c20..c7fcadc46 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -273,10 +273,10 @@ ] }, { - "login": "areychana", + "login": "pyarchana", "name": "Archana K.", "avatar_url": "https://avatars.githubusercontent.com/u/93970069?v=4", - "profile": "https://github.com/areychana", + "profile": "https://github.com/pyarchana", "contributions": [ "code", "test" From e0b5df0e97af60f7a46f8819a1053d02e5c8b366 Mon Sep 17 00:00:00 2001 From: areychana <93970069+areychana@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:35:35 +0530 Subject: [PATCH 4/5] Document fitted attributes and add DistfitFitter to API reference --- docs/source/api_reference/distfitter.rst | 1 + skpro/distfitter/_distfitfitter.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/docs/source/api_reference/distfitter.rst b/docs/source/api_reference/distfitter.rst index d73b48bb2..a1c078f49 100644 --- a/docs/source/api_reference/distfitter.rst +++ b/docs/source/api_reference/distfitter.rst @@ -21,6 +21,7 @@ Parametric fitters :toctree: auto_generated/ :template: class.rst + DistfitFitter MOMFitter NormalFitter diff --git a/skpro/distfitter/_distfitfitter.py b/skpro/distfitter/_distfitfitter.py index e4020679a..018972e12 100644 --- a/skpro/distfitter/_distfitfitter.py +++ b/skpro/distfitter/_distfitfitter.py @@ -41,6 +41,12 @@ class DistfitFitter(BaseDistFitter): 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. From 444b52eb315c910bb9a03849b1fd54bc8253b033 Mon Sep 17 00:00:00 2001 From: pyarchana <93970069+areychana@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:16:12 +0530 Subject: [PATCH 5/5] use run_test_for_class in distfitter tests --- skpro/distfitter/_distfitfitter.py | 4 ++-- skpro/distfitter/tests/test_distfitfitter.py | 25 ++++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/skpro/distfitter/_distfitfitter.py b/skpro/distfitter/_distfitfitter.py index 018972e12..1dcd3f533 100644 --- a/skpro/distfitter/_distfitfitter.py +++ b/skpro/distfitter/_distfitfitter.py @@ -3,7 +3,7 @@ from skpro.distfitter.base import BaseDistFitter -__author__ = ["areychana"] +__author__ = ["pyarchana"] class DistfitFitter(BaseDistFitter): @@ -64,7 +64,7 @@ class DistfitFitter(BaseDistFitter): """ _tags = { - "authors": ["areychana"], + "authors": ["pyarchana"], "python_dependencies": ["distfit"], } diff --git a/skpro/distfitter/tests/test_distfitfitter.py b/skpro/distfitter/tests/test_distfitfitter.py index 0fa88e019..d7b56abe2 100644 --- a/skpro/distfitter/tests/test_distfitfitter.py +++ b/skpro/distfitter/tests/test_distfitfitter.py @@ -4,20 +4,18 @@ import numpy as np import pandas as pd import pytest -from skbase.utils.dependencies import _check_soft_dependencies +from skpro.distfitter import DistfitFitter from skpro.distributions.base import BaseDistribution - -DISTFIT_AVAILABLE = _check_soft_dependencies("distfit", severity="none") +from skpro.tests.test_switch import run_test_for_class @pytest.mark.skipif( - not DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" + 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.""" - from skpro.distfitter import DistfitFitter - rng = np.random.RandomState(42) X = pd.DataFrame(rng.normal(loc=5.0, scale=2.0, size=1000)) @@ -39,12 +37,11 @@ def test_distfitfitter_fits_known_normal(): @pytest.mark.skipif( - not DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" + 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.""" - from skpro.distfitter import DistfitFitter - rng = np.random.RandomState(0) X = pd.DataFrame(rng.normal(size=200)) @@ -57,12 +54,11 @@ def test_distfitfitter_stores_fit_summary(): @pytest.mark.skipif( - not DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" + 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.""" - from skpro.distfitter import DistfitFitter - fitter = DistfitFitter(distr="norm") with pytest.raises(ValueError): @@ -70,12 +66,11 @@ def test_distfitfitter_proba_before_fit_raises(): @pytest.mark.skipif( - not DISTFIT_AVAILABLE, reason="skip test if required soft dependency not present" + 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.""" - from skpro.distfitter import DistfitFitter - fitter = DistfitFitter(distr="norm", stats="wasserstein") params = fitter.get_params()