Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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",
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 @@ -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

Expand Down
2 changes: 2 additions & 0 deletions skpro/distfitter/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,6 +10,7 @@
from skpro.distfitter._uniformfitter import UniformFitter

__all__ = [
"DistfitFitter",
"ExponentialFitter",
"LaplaceFitter",
"ScipyMLEFitter",
Expand Down
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__ = ["pyarchana"]


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": ["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"])

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