Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .all-contributorsrc
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,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",
Expand Down
1 change: 1 addition & 0 deletions docs/source/api_reference/distfitter.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Parametric fitters
:toctree: auto_generated/
:template: class.rst

DistfitFitter
MOMFitter
NormalFitter

Expand Down
2 changes: 2 additions & 0 deletions skpro/distfitter/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
52 changes: 52 additions & 0 deletions skpro/distfitter/_distfit_adapter.py
Original file line number Diff line number Diff line change
@@ -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
147 changes: 147 additions & 0 deletions skpro/distfitter/_distfitfitter.py
Original file line number Diff line number Diff line change
@@ -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__ = ["areychana"]


class DistfitFitter(BaseDistFitter):
"""Fit a parametric distribution to data using the ``distfit`` package.

Wraps `distfit <https://github.com/erdogant/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": ["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"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this are the not documentted, Should't we document this?

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]
86 changes: 86 additions & 0 deletions skpro/distfitter/tests/test_distfitfitter.py
Original file line number Diff line number Diff line change
@@ -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()