From 56954e169b96c9ad676530ba6f4cf8e2c860328f Mon Sep 17 00:00:00 2001 From: Alejandro Schuler Date: Mon, 26 Oct 2026 22:23:06 -0700 Subject: [PATCH] Add df to the params of the T distributions The params property of T, TFixedDf and TFixedDfFixedVar returned only loc and scale, although each of these distributions has a df. So scipy.stats.t(**dist.params) raised a TypeError, and any tool that reads params could not see the degrees of freedom. The example notebook read dists.df directly to work around this. params now includes df for all three classes. Cauchy and CauchyFixedVar inherit the change, with df equal to 1. The existing keys and their values do not change. The example notebook now passes dists[i].params to scipy.stats.t.pdf. Found while assessing #168. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- RELEASE_NOTES.md | 1 + ngboost/distns/t.py | 6 +++--- notebooks/example_t.ipynb | 2 +- tests/test_distns.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a7ae2fcf..5bc7b392 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -8,6 +8,7 @@ * Replace the deprecated `friedman_mse` criterion with `squared_error` for the default tree learner and distribution tests (issue #408, PR #409) * Support a distinct base learner for each distribution parameter, including scikit-learn nested parameters and feature importances for all-tree configurations (issue #338, PR #411) * Fix the `CRPScore` metric of `Normal`, `NormalFixedMean` and `LogNormal`, which was the correct metric multiplied by the scale. It was too large where the predicted scale is above 1 and too small where it is below 1. Fits that use `CRPScore` with these distributions change wherever the predicted scale is not 1 (issue #421) +* Add `df` to the `params` of `T`, `TFixedDf`, `TFixedDfFixedVar` and the `Cauchy` classes built on them, so that `scipy.stats.t(**dist.params)` rebuilds the predicted distribution. Before, `params` held only `loc` and `scale` ## Version 0.5.10 diff --git a/ngboost/distns/t.py b/ngboost/distns/t.py index 4657890a..0b704411 100644 --- a/ngboost/distns/t.py +++ b/ngboost/distns/t.py @@ -77,7 +77,7 @@ def __getattr__(self, name): @property def params(self): - return {"loc": self.loc, "scale": self.scale} + return {"loc": self.loc, "scale": self.scale, "df": self.df} class TFixedDfLogScore(LogScore): @@ -141,7 +141,7 @@ def __getattr__(self, name): @property def params(self): - return {"loc": self.loc, "scale": self.scale} + return {"loc": self.loc, "scale": self.scale, "df": self.df} class TFixedDfFixedVarLogScore(LogScore): @@ -199,4 +199,4 @@ def __getattr__(self, name): @property def params(self): - return {"loc": self.loc, "scale": self.scale} + return {"loc": self.loc, "scale": self.scale, "df": self.df} diff --git a/notebooks/example_t.ipynb b/notebooks/example_t.ipynb index 5367108a..b0293899 100644 --- a/notebooks/example_t.ipynb +++ b/notebooks/example_t.ipynb @@ -596,7 +596,7 @@ "ax = axes[1]\n", "x = np.linspace(-5, 12, 300)\n", "for i in [0, 50, 100]:\n", - " pdf = scipy.stats.t.pdf(x, df=dists.df[i], loc=dists.loc[i], scale=dists.scale[i])\n", + " pdf = scipy.stats.t.pdf(x, **dists[i].params)\n", " ax.plot(x, pdf, label=f\"sample {i}\")\n", " ax.axvline(Y_test[i], color=ax.lines[-1].get_color(), linestyle=\":\", alpha=0.5)\n", "ax.set_xlabel(\"y\")\n", diff --git a/tests/test_distns.py b/tests/test_distns.py index 3f7fd9c3..9325e9fa 100644 --- a/tests/test_distns.py +++ b/tests/test_distns.py @@ -2,6 +2,7 @@ import numpy as np import pytest +import scipy.stats from sklearn.datasets import fetch_california_housing, load_breast_cancer from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeRegressor @@ -25,6 +26,7 @@ Weibull, k_categorical, ) +from ngboost.distns.cauchy import CauchyFixedVar from ngboost.scores import CRPScore, LogScore, Score # test all the dist methods and score implementation methods,# @@ -224,3 +226,34 @@ def test_multivariatenormal(k: int, learner): mean = y_dist.mean sample = y_dist.rv() scipy_list = y_dist.scipy_distribution() + + +@pytest.mark.parametrize( + "dist_class, expected_df", + [ + (T, None), + (TFixedDf, 3.0), + (TFixedDfFixedVar, 3.0), + (Cauchy, 1.0), + (CauchyFixedVar, 1.0), + ], +) +def test_t_family_params_match_scipy(dist_class, expected_df): + rng = np.random.default_rng(0) + n = 20 + raw_params = rng.normal(size=(dist_class.n_params, n)) + dist = dist_class(raw_params) + params = dist.params + + # df is reported next to loc and scale, and loc is the first raw parameter + assert set(params) == {"loc", "scale", "df"} + np.testing.assert_allclose(params["loc"], raw_params[0]) + np.testing.assert_allclose(params["df"], dist.df) + if expected_df is not None: + np.testing.assert_allclose(params["df"], expected_df) + + # the params are enough to rebuild the same distribution in scipy + y = rng.normal(size=n) + frozen = scipy.stats.t(**params) + np.testing.assert_allclose(frozen.logpdf(y), dist.logpdf(y)) + np.testing.assert_allclose(frozen.ppf(0.9), dist.ppf(0.9))