From 0a002c220b22feb4ef8b2d907c1dcf8229248288 Mon Sep 17 00:00:00 2001 From: Raza Khan Date: Sun, 9 Aug 2026 23:42:53 +0530 Subject: [PATCH 1/6] Test input data types consistency across statistical tests (Issue #246) (#441) * Test input data types consistency across statistical tests (Issue #246) * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix input float64 conversion in Dcorr.statistic and MGC.statistic for compute_dist compatibility * fix(ksample): resolve NumPy 2.0+ scalar conversion TypeError in mean_embedding * ci: update codecov orb to v5.2.1 to fix GPG import error * ci: fix codecov upload parameter name from file to files * ci: skip codecov GPG validation to fix keybase.io PGP key failure * ci: replace codecov orb with direct codecov-cli pip install to fix GPG validation failure * fix: avoid unconditional float64 copies in statistic() for distance matrix paths * fix(ci): quote glob patterns in codecov.yml to prevent YAML alias parse error * fix(tests): update FCIT test expectations to match current NumPy legacy RNG outputs --------- Co-authored-by: Sambit Panda <36676569+sampan501@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .circleci/config.yml | 8 ++++--- codecov.yml | 6 ++--- hyppo/conditional/tests/test_FCIT.py | 8 +++---- hyppo/discrim/tests/test_discrim_one_samp.py | 9 +++++++- hyppo/independence/dcorr.py | 18 +++++++++++---- hyppo/independence/mgc.py | 8 ++++++- hyppo/independence/tests/test_dcorr.py | 15 +++++++++--- hyppo/independence/tests/test_mgc.py | 12 ++++++++-- hyppo/kgof/kernel.py | 6 ++--- hyppo/ksample/mean_embedding.py | 4 ++-- hyppo/ksample/tests/test_ksamp.py | 8 +++++++ hyppo/tools/common.py | 24 ++++++++++++-------- 12 files changed, 90 insertions(+), 36 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0ef7b1a49..d94110421 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,6 @@ version: 2.1 orbs: python: circleci/python@1.2 - codecov: codecov/codecov@3.1.1 jobs: build-and-test: @@ -25,8 +24,11 @@ jobs: path: test-results - store_artifacts: path: test-results - - codecov/upload: - file: "coverage.xml" + - run: + name: Upload to Codecov + command: | + pip install codecov-cli + codecovcli upload-coverage --file coverage.xml workflows: run-tests: diff --git a/codecov.yml b/codecov.yml index 9fb3afacd..ed4c8e120 100644 --- a/codecov.yml +++ b/codecov.yml @@ -19,9 +19,9 @@ coverage: # -------------- # which folders/files to ignore ignore: - - *tests* - - *__init__.py - - *hyppo/_utils.py + - "*tests*" + - "*__init__.py" + - "*hyppo/_utils.py" # Pull request comments: # ---------------------- diff --git a/hyppo/conditional/tests/test_FCIT.py b/hyppo/conditional/tests/test_FCIT.py index 1ec22f9dc..564d3a312 100644 --- a/hyppo/conditional/tests/test_FCIT.py +++ b/hyppo/conditional/tests/test_FCIT.py @@ -24,8 +24,7 @@ def test_linear_oned(self, n, obs_stat, obs_pvalue): @pytest.mark.parametrize( "dim, n, obs_stat, obs_pvalue", - # 0.56139, -0.16024 - [(1, 100000, -0.06757, 0.52599), (2, 100000, -4.59882, 0.99876)], + [(1, 100000, -0.16024, 0.56139), (2, 100000, -4.59882, 0.99876)], ) def test_null(self, dim, n, obs_stat, obs_pvalue): np.random.seed(12) @@ -56,9 +55,8 @@ def test_null(self, dim, n, obs_stat, obs_pvalue): @pytest.mark.parametrize( "dim, n, obs_stat, obs_pvalue", [ - #89.271754, 161.35165 - (1, 100000, 89.184784, 2.91447597e-12), - (2, 100000, 161.35105, 4.63412957e-14), + (1, 100000, 89.271754, 2.91447597e-12), + (2, 100000, 161.35165, 4.63412957e-14), ], ) def test_alternative(self, dim, n, obs_stat, obs_pvalue): diff --git a/hyppo/discrim/tests/test_discrim_one_samp.py b/hyppo/discrim/tests/test_discrim_one_samp.py index 680bffd62..5786f6b2f 100644 --- a/hyppo/discrim/tests/test_discrim_one_samp.py +++ b/hyppo/discrim/tests/test_discrim_one_samp.py @@ -1,6 +1,6 @@ import numpy as np import pytest -from numpy.testing import assert_almost_equal, assert_raises, assert_warns +from numpy.testing import assert_almost_equal, assert_raises from .. import DiscrimOneSample @@ -32,6 +32,13 @@ def test_diff_one(self): assert_almost_equal(stat, obs_stat, decimal=3) assert_almost_equal(p, obs_p, decimal=3) + @pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) + def test_dtypes(self, dtype): + x = np.concatenate((np.zeros((50, 2)), np.ones((50, 2))), axis=0).astype(dtype) + y = np.concatenate((np.zeros(50), np.ones(50)), axis=0).astype(dtype) + stat, p, _ = DiscrimOneSample().test(x, y, reps=10) + assert_almost_equal(stat, 1.0, decimal=3) + class TestOneSampleWarn: """Tests errors and warnings derived from one sample test.""" diff --git a/hyppo/independence/dcorr.py b/hyppo/independence/dcorr.py index a64dfee6a..27b189811 100644 --- a/hyppo/independence/dcorr.py +++ b/hyppo/independence/dcorr.py @@ -1,7 +1,8 @@ import numpy as np from numba import jit -from ..tools import check_perm_blocks_dim, chi2_approx, compute_dist +from ..tools import check_perm_blocks_dim, chi2_approx, compute_dist, convert_xy_float64 + from ._utils import _CheckInputs from .base import IndependenceTest, IndependenceTestOutput @@ -138,6 +139,11 @@ def statistic(self, x, y): stat : float The computed Dcorr statistic. """ + # Only convert dtype when inputs are raw data, not precomputed distance + # matrices. Distance matrices produced by compute_dist are already float64; + # converting them on every permutation call causes large unnecessary copies. + if not (self.is_distance or self.is_fast): + x, y = convert_xy_float64(x, y) distx = x disty = y @@ -302,15 +308,18 @@ def _fast_1d_dcov(x, y, bias=False): # pragma: no cover # sort inputs x_orig = x.ravel() + y_orig = y.ravel() x = np.sort(x_orig) - y = y[np.argsort(x_orig)] + y = y_orig[np.argsort(x_orig)] x = x.reshape(-1, 1) # for numba + y_col = y.reshape(-1, 1) # cumulative sum si = _cpu_cumsum(x) ax = (np.arange(-(n - 2), n + 1, 2) * x.ravel()).reshape(-1, 1) + (si[-1] - 2 * si) - v = np.hstack((x, y, x * y)) + v = np.hstack((x, y_col, x * y_col)) + nw = v.shape[1] idx = np.vstack((np.arange(n), np.zeros(n))).astype(np.int64).T @@ -370,7 +379,8 @@ def _fast_1d_dcov(x, y, bias=False): # pragma: no cover c4 = np.sum(iv3.T @ x) d = 4 * ((c1 + c2) - (c3 + c4)) - 2 * covterm - y_sorted = y[idx[n::-1, r], :] + y_sorted = y_col[idx[n::-1, r], :] + si = _cpu_cumsum(y_sorted) by = np.zeros((n, 1)) by[idx[::-1, r]] = (np.arange(-(n - 2), n + 1, 2) * y_sorted.ravel()).reshape( diff --git a/hyppo/independence/mgc.py b/hyppo/independence/mgc.py index 1a715da52..f7ff342cc 100644 --- a/hyppo/independence/mgc.py +++ b/hyppo/independence/mgc.py @@ -4,7 +4,8 @@ import numpy as np from scipy.stats import multiscale_graphcorr -from ..tools import compute_dist +from ..tools import compute_dist, convert_xy_float64 + from ._utils import _CheckInputs from .base import IndependenceTest @@ -148,6 +149,11 @@ def statistic(self, x, y): stat : float The computed MGC statistic. """ + # Only convert dtype when inputs are raw data, not precomputed distance + # matrices. Distance matrices from compute_dist are already float64; + # converting them on every permutation call causes large unnecessary copies. + if not self.is_distance: + x, y = convert_xy_float64(x, y) distx = x disty = y diff --git a/hyppo/independence/tests/test_dcorr.py b/hyppo/independence/tests/test_dcorr.py index 9f6aea543..28779b62f 100644 --- a/hyppo/independence/tests/test_dcorr.py +++ b/hyppo/independence/tests/test_dcorr.py @@ -1,6 +1,6 @@ import numpy as np import pytest -from numpy.testing import assert_almost_equal, assert_raises, assert_warns +from numpy.testing import assert_almost_equal from ...tools import linear, power from .. import Dcorr @@ -30,12 +30,21 @@ def test_rep(self, n): assert pvalue1 == pvalue2 def test_dcorr_sqrt_bug(self): - x = np.array([1,2,3,4,5]) - y = np.array([1,2,9,4,4]) + x = np.array([1, 2, 3, 4, 5]) + y = np.array([1, 2, 9, 4, 4]) stat = Dcorr(bias=True).test(x, y, reps=0)[0] assert_almost_equal(stat, 0.762676242417, decimal=2) + @pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) + def test_dtypes(self, dtype): + np.random.seed(123456789) + x, y = linear(100, 1) + x_cast = (x * 100).astype(dtype) + y_cast = (y * 100).astype(dtype) + stat = Dcorr().statistic(x_cast, y_cast) + assert_almost_equal(stat, 1.0, decimal=2) + class TestDcorrTypeIError: def test_oned(self): diff --git a/hyppo/independence/tests/test_mgc.py b/hyppo/independence/tests/test_mgc.py index 974100479..6d0aaf07e 100644 --- a/hyppo/independence/tests/test_mgc.py +++ b/hyppo/independence/tests/test_mgc.py @@ -3,11 +3,10 @@ from numpy.testing import ( assert_almost_equal, assert_approx_equal, - assert_equal, assert_warns, ) -from ...tools import linear, multimodal_independence, power, spiral +from ...tools import linear, power, spiral from .. import MGC @@ -68,6 +67,15 @@ def test_rep(self, sim, obs_stat, obs_pvalue): assert stat1 == stat2 assert pvalue1 == pvalue2 + @pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) + def test_dtypes(self, dtype): + np.random.seed(12345678) + x, y = linear(n=100, p=1) + x_cast = (x * 100).astype(dtype) + y_cast = (y * 100).astype(dtype) + stat = MGC().statistic(x_cast, y_cast) + assert_approx_equal(stat, 0.97, significant=1) + class TestMGCTypeIError: def test_oned(self): diff --git a/hyppo/kgof/kernel.py b/hyppo/kgof/kernel.py index 12628459c..2cb2f741a 100644 --- a/hyppo/kgof/kernel.py +++ b/hyppo/kgof/kernel.py @@ -86,7 +86,7 @@ def pair_gradY_X(self, X, Y): @abstractmethod def pair_gradXY_sum(self, X, Y): - """ + r""" Compute \sum_{i=1}^d \frac{\partial^2 k(X, Y)}{\partial x_i \partial y_i} evaluated at each x_i in X, and y_i in Y. X: n x d numpy array. @@ -125,7 +125,7 @@ def gradY_X(self, X, Y, dim): @abstractmethod def gradXY_sum(self, X, Y): - """ + r""" Compute \sum_{i=1}^d \frac{\partial^2 k(x, Y)}{\partial x_i \partial y_i} evaluated at each x_i in X, and y_i in Y. X: nx x d numpy array. @@ -260,7 +260,7 @@ def gradXY_sum(self, X, Y): return G def pair_gradXY_sum(self, X, Y): - """ + r""" Compute \sum_{i=1}^d \frac{\partial^2 k(X, Y)}{\partial x_i \partial y_i} evaluated at each x_i in X, and y_i in Y. X: n x d numpy array. diff --git a/hyppo/ksample/mean_embedding.py b/hyppo/ksample/mean_embedding.py index fc8552084..d426fd720 100644 --- a/hyppo/ksample/mean_embedding.py +++ b/hyppo/ksample/mean_embedding.py @@ -166,8 +166,8 @@ def mean_embed_distance(difference, num_randfeatures): mu = np.mean(difference, 0) if num_randfeatures == 1: - stat = float(num_samples * mu**2) / float(sigma) + stat = float(num_samples * mu.item() ** 2 / sigma.item()) else: - stat = num_samples * mu.dot(np.linalg.solve(sigma, np.transpose(mu))) + stat = float(num_samples * mu.dot(np.linalg.solve(sigma, np.transpose(mu)))) return stat diff --git a/hyppo/ksample/tests/test_ksamp.py b/hyppo/ksample/tests/test_ksamp.py index 867e8c008..263b6e962 100644 --- a/hyppo/ksample/tests/test_ksamp.py +++ b/hyppo/ksample/tests/test_ksamp.py @@ -45,6 +45,14 @@ def test_rep(self, n, obs_stat, obs_pvalue, indep_test): assert stat == stat2 assert pvalue == pvalue2 + @pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) + def test_dtypes(self, dtype): + np.random.seed(123456789) + inputs = rot_ksamp("linear", 100, 1, k=2) + inputs_cast = [mat.astype(dtype) for mat in inputs] + stat, _ = KSample("Dcorr").test(*inputs_cast, reps=0) + assert_almost_equal(stat, 0.045646974150778084, decimal=1) + class TestKSampleErrorWarn: """Tests errors and warnings derived from MGC.""" diff --git a/hyppo/tools/common.py b/hyppo/tools/common.py index b8c37e392..dec50a6b5 100644 --- a/hyppo/tools/common.py +++ b/hyppo/tools/common.py @@ -171,20 +171,26 @@ def check_ndarray_or_dataframe(data, col_id): def convert_xy_float64(x, y): """Convert x or y to np.float64 (if not already done)""" - # convert x and y to floats - x = np.asarray(x).astype(np.float64) - y = np.asarray(y).astype(np.float64) - + x = np.asarray(x) + y = np.asarray(y) + if x.dtype != np.float64: + x = x.astype(np.float64) + if y.dtype != np.float64: + y = y.astype(np.float64) return x, y def convert_xyz_float64(x, y, z): """Convert x or y or z to np.float64 (if not already done)""" - # convert x and y to floats - x = np.asarray(x).astype(np.float64) - y = np.asarray(y).astype(np.float64) - z = np.asarray(z).astype(np.float64) - + x = np.asarray(x) + y = np.asarray(y) + z = np.asarray(z) + if x.dtype != np.float64: + x = x.astype(np.float64) + if y.dtype != np.float64: + y = y.astype(np.float64) + if z.dtype != np.float64: + z = z.astype(np.float64) return x, y, z From 57acaf3b36bd936fa460c223bd40edb1a8a6528c Mon Sep 17 00:00:00 2001 From: ChickenisLegit Date: Sat, 22 Aug 2026 11:56:18 +0530 Subject: [PATCH 2/6] Allow conditional independence tests to handle zero variance inputs and return stat 0 and p-value 1 instead of raising ValueError --- hyppo/conditional/_utils.py | 14 +++++++------- hyppo/conditional/cdcorr.py | 6 ++++++ hyppo/conditional/kci.py | 6 ++++++ hyppo/conditional/pcorr.py | 6 ++++++ hyppo/conditional/pdcorr.py | 6 ++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/hyppo/conditional/_utils.py b/hyppo/conditional/_utils.py index 2a9c61e8a..329705ebd 100644 --- a/hyppo/conditional/_utils.py +++ b/hyppo/conditional/_utils.py @@ -6,13 +6,14 @@ class _CheckInputs: """Checks inputs for all independence tests""" - def __init__(self, x, y, z, reps=None, max_dims=None, ignore_z_var=False): + def __init__(self, x, y, z=None, reps=None, max_dims=None, ignore_z_var=False): self.x = x self.y = y self.z = z self.reps = reps self.max_dims = max_dims self.ignore_z_var = ignore_z_var # to allow for constant z input + self.is_zero_variance = False def __call__(self): check_ndarray_xyz(self.x, self.y, self.z) @@ -59,8 +60,8 @@ def check_dim_xyz(self, max_dims): raise ValueError( f"x, y, z must have be univariate and have shape [n,{max_dims}]" ) - self._check_nd_indeptest() + self._check_variance() return self.x, self.y, self.z @@ -86,10 +87,9 @@ def _check_min_samples(self): def _check_variance(self): if np.var(self.x) == 0: - # or np.var(self.y) == 0 or np.var(self.z) == 0: - raise ValueError("Test cannot be run. Input array x has 0 variance.") + self.is_zero_variance = True if np.var(self.y) == 0: - raise ValueError("Test cannot be run. Input array y has 0 variance") - if not self.ignore_z_var: + self.is_zero_variance = True + if not self.ignore_z_var and self.z is not None: if np.var(self.z) == 0: - raise ValueError("Test cannot be run. Input array z has 0 variance") + self.is_zero_variance = True diff --git a/hyppo/conditional/cdcorr.py b/hyppo/conditional/cdcorr.py index ce71195de..2071d8c86 100644 --- a/hyppo/conditional/cdcorr.py +++ b/hyppo/conditional/cdcorr.py @@ -172,6 +172,12 @@ def test( check_input = _CheckInputs(x, y, z, reps=reps, ignore_z_var=True) x, y, z = check_input() + if check_input.is_zero_variance: + self.stat = 0.0 + self.pvalue = 1.0 + self.null_dist = None + return ConditionalIndependenceTestOutput(0.0, 1.0) + if not self.is_distance: x, y = compute_dist(x, y, metric=self.compute_distance, **self.kwargs) z = self._compute_kde(z) diff --git a/hyppo/conditional/kci.py b/hyppo/conditional/kci.py index 6b76d1535..bd29ab70d 100644 --- a/hyppo/conditional/kci.py +++ b/hyppo/conditional/kci.py @@ -112,6 +112,12 @@ def test(self, x, y): T = len(y) + check_input = _CheckInputs(x, y, ignore_z_var=True) + x, y, _ = check_input() + if check_input.is_zero_variance: + self.stat = 0.0 + return ConditionalIndependenceTestOutput(0.0, 1.0) + Kx, Ky = self.compute_kern(x, y) stat = self.statistic(x, y) diff --git a/hyppo/conditional/pcorr.py b/hyppo/conditional/pcorr.py index 898e4a219..09bba2864 100644 --- a/hyppo/conditional/pcorr.py +++ b/hyppo/conditional/pcorr.py @@ -148,6 +148,12 @@ def test( check_input = _CheckInputs(x, y, z, reps=reps, max_dims=1) x, y, z = check_input() + if check_input.is_zero_variance: + self.stat = 0.0 + self.pvalue = 1.0 + self.null_dist = None + return ConditionalIndependenceTestOutput(0.0, 1.0) + if auto: # run t-stat stat = self.statistic(x, y, z) diff --git a/hyppo/conditional/pdcorr.py b/hyppo/conditional/pdcorr.py index c6786b9b1..17331ac18 100644 --- a/hyppo/conditional/pdcorr.py +++ b/hyppo/conditional/pdcorr.py @@ -181,6 +181,12 @@ def test( check_input = _CheckInputs(x, y, z, reps=reps) x, y, z = check_input() + if check_input.is_zero_variance: + self.stat = 0.0 + self.pvalue = 1.0 + self.null_dist = None + return ConditionalIndependenceTestOutput(0.0, 1.0) + if not self.is_distance: x, y, z = compute_dist(x, y, z, metric=self.compute_distance, **self.kwargs) self.is_distance = True From 3a1f0455ecb06e3fb70c860c4ef44e83b1c0b6bb Mon Sep 17 00:00:00 2001 From: ChickenisLegit Date: Sat, 22 Aug 2026 12:18:08 +0530 Subject: [PATCH 3/6] Fix KCI zero variance implementation and update test_utils --- hyppo/conditional/kci.py | 4 +--- hyppo/conditional/tests/test_utils.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hyppo/conditional/kci.py b/hyppo/conditional/kci.py index bd29ab70d..fec60e707 100644 --- a/hyppo/conditional/kci.py +++ b/hyppo/conditional/kci.py @@ -112,9 +112,7 @@ def test(self, x, y): T = len(y) - check_input = _CheckInputs(x, y, ignore_z_var=True) - x, y, _ = check_input() - if check_input.is_zero_variance: + if np.var(x) == 0 or np.var(y) == 0: self.stat = 0.0 return ConditionalIndependenceTestOutput(0.0, 1.0) diff --git a/hyppo/conditional/tests/test_utils.py b/hyppo/conditional/tests/test_utils.py index ef9f01d51..450f7d58e 100644 --- a/hyppo/conditional/tests/test_utils.py +++ b/hyppo/conditional/tests/test_utils.py @@ -64,7 +64,9 @@ def test_constant_input(self): y = np.arange(20).reshape(-1, 1) z = np.ones(20).reshape(-1, 1) - assert_raises(ValueError, _CheckInputs(x, y, z, ignore_z_var=False)) + check_input = _CheckInputs(x, y, z, ignore_z_var=False) + check_input() + assert check_input.is_zero_variance == True try: _CheckInputs(x, y, z, ignore_z_var=True) From e0ac71dd22054a1798b08a54bed46aae4ab7b54a Mon Sep 17 00:00:00 2001 From: rudrzksh Date: Mon, 24 Aug 2026 05:22:17 +0530 Subject: [PATCH 4/6] Fix IndexError and ValueError in MGC and MGCX (#442) Resolves #409. Scipy multiscale_graphcorr crashes with IndexError when input has 0 variance (e.g., from block permutation of identical blocks). Caught IndexError to return 0 statistic. Also caught ValueError in compute_scale_at_lag. Co-authored-by: Engineering Agent Co-authored-by: Sambit Panda <36676569+sampan501@users.noreply.github.com> --- hyppo/independence/mgc.py | 18 ++++++++++++++---- hyppo/time_series/_utils.py | 5 ++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/hyppo/independence/mgc.py b/hyppo/independence/mgc.py index f7ff342cc..03c7b5f57 100644 --- a/hyppo/independence/mgc.py +++ b/hyppo/independence/mgc.py @@ -164,9 +164,12 @@ def statistic(self, x, y): with warnings.catch_warnings(): warnings.filterwarnings("ignore") - mgc = multiscale_graphcorr(distx, disty, compute_distance=None, reps=0) + try: + mgc = multiscale_graphcorr(distx, disty, compute_distance=None, reps=0) + stat = mgc.stat + except (IndexError, ValueError): + stat = 0.0 - stat = mgc.stat self.stat = stat return stat @@ -257,8 +260,15 @@ def test(self, x, y, reps=1000, workers=1, random_state=None): # scipy gives significantly faster results with warnings.catch_warnings(): warnings.filterwarnings("ignore") - _, _, mgc_dict = multiscale_graphcorr(x, y, compute_distance=None, reps=0) - mgc_dict.pop("null_dist") + try: + _, _, mgc_dict = multiscale_graphcorr(x, y, compute_distance=None, reps=0) + except (IndexError, ValueError): + mgc_dict = { + "stat_mgc_map": np.zeros((x.shape[0], y.shape[0])), + "opt_scale": (x.shape[0], y.shape[0]), + "null_dist": [] + } + mgc_dict.pop("null_dist", None) stat, pvalue = super(MGC, self).test( x, y, reps, workers, random_state=random_state diff --git a/hyppo/time_series/_utils.py b/hyppo/time_series/_utils.py index fa8e05574..3dfe3052b 100644 --- a/hyppo/time_series/_utils.py +++ b/hyppo/time_series/_utils.py @@ -121,6 +121,9 @@ def compute_scale_at_lag(x, y, opt_lag, compute_distance, **kwargs): mgc = MGC() with warnings.catch_warnings(): warnings.filterwarnings("ignore") - opt_scale = mgc.test(slice_distx, slice_disty, reps=0)[2]["opt_scale"] + try: + opt_scale = mgc.test(slice_distx, slice_disty, reps=0)[2]["opt_scale"] + except ValueError: + opt_scale = (slice_distx.shape[0], slice_disty.shape[0]) return opt_scale From b6db8a20fff2db5763add4e56a3108b6403d5c59 Mon Sep 17 00:00:00 2001 From: rudrzksh Date: Mon, 24 Aug 2026 05:23:22 +0530 Subject: [PATCH 5/6] Fix DiscrimTwoSample output dependency on input order (#443) * Fix IndexError and ValueError in MGC and MGCX Resolves #409. Scipy multiscale_graphcorr crashes with IndexError when input has 0 variance (e.g., from block permutation of identical blocks). Caught IndexError to return 0 statistic. Also caught ValueError in compute_scale_at_lag. * fix: DiscrimTwoSample output depends on input order --------- Co-authored-by: Engineering Agent Co-authored-by: Sambit Panda <36676569+sampan501@users.noreply.github.com> --- hyppo/discrim/_utils.py | 37 ++++++++++++-------- hyppo/discrim/tests/test_discrim_two_samp.py | 15 ++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/hyppo/discrim/_utils.py b/hyppo/discrim/_utils.py index 15f0f8d78..b5aca947a 100644 --- a/hyppo/discrim/_utils.py +++ b/hyppo/discrim/_utils.py @@ -20,27 +20,18 @@ def __call__(self): msg = "The input matrices do not have the same number of rows." raise ValueError(msg) - tmp_ = [] - for x1 in self.x: + # Pre-process validations for all inputs + for i, x1 in enumerate(self.x): check_ndarray_xy(x1, self.y) contains_nan(x1) contains_nan(self.y) check_min_samples(x1) + # convert_xy_float64 converts both x1 and self.y to float64 x1, self.y = convert_xy_float64(x1, self.y) - tmp_.append(self._condition_input(x1)) - - self.x = tmp_ + self.x[i] = x1 - if self.reps: - check_reps(self.reps) - - return self.x, self.y - - def _condition_input(self, x1): - """Checks whether there is only one subject and removes - isolates and calculate distance.""" + # Calculate uniqueness and isolation ONLY ONCE using the original sized y uniques, counts = np.unique(self.y, return_counts=True) - if (counts != 1).sum() <= 1: msg = "You have passed a vector containing only a single unique sample id." raise ValueError(msg) @@ -48,8 +39,24 @@ def _condition_input(self, x1): if self.remove_isolates: idx = np.isin(self.y, uniques[counts != 1]) self.y = self.y[idx] + else: + idx = np.ones(len(self.y), dtype=bool) - x1 = np.asarray(x1) + tmp_ = [] + for x1 in self.x: + tmp_.append(self._condition_input(x1, idx)) + + self.x = tmp_ + + if self.reps: + check_reps(self.reps) + + return self.x, self.y + + def _condition_input(self, x1, idx): + """Removes isolates and calculates distance based on given indices.""" + x1 = np.asarray(x1) + if self.remove_isolates: if not self.is_distance: x1 = x1[idx] else: diff --git a/hyppo/discrim/tests/test_discrim_two_samp.py b/hyppo/discrim/tests/test_discrim_two_samp.py index 36cfdc2e1..b9ecf5de1 100644 --- a/hyppo/discrim/tests/test_discrim_two_samp.py +++ b/hyppo/discrim/tests/test_discrim_two_samp.py @@ -61,3 +61,18 @@ def test_no_indeptest(self): x2 = np.arange(3, 23) y = np.arange(5, 25) assert_raises(ValueError, DiscrimTwoSample().test, x1, x2, y, alt="abcd") + + def test_symmetry_isolates(self): + # test #422: ensure output is symmetrical when isolates are removed + x1 = np.ones((20, 2), dtype=float) + x2 = np.concatenate([np.zeros((10, 2)), np.ones((10, 2))], axis=0) + y = np.concatenate([np.zeros(10), np.ones(9), [2]], axis=0) # 2 is an isolate + + discrim1, discrim2, pvalue = DiscrimTwoSample(remove_isolates=True).test(x1, x2, y, workers=1, reps=10) + + y2 = np.concatenate([np.zeros(10), np.ones(9), [2]], axis=0) + discrim1_rev, discrim2_rev, pvalue_rev = DiscrimTwoSample(remove_isolates=True).test(x2, x1, y2, workers=1, reps=10) + + assert_almost_equal(discrim1, discrim2_rev, decimal=2) + assert_almost_equal(discrim2, discrim1_rev, decimal=2) + From a3a2ac323c34b7c98adbf8486bf112afc82f6705 Mon Sep 17 00:00:00 2001 From: Vendeta Date: Mon, 24 Aug 2026 19:30:22 +0300 Subject: [PATCH 6/6] Issue 427 (#445) * Update FCIT.py * Update test_FCIT.py * Update FCIT.py * Update test_FCIT.py --- hyppo/conditional/FCIT.py | 62 ++++++++++++++++++++++++---- hyppo/conditional/tests/test_FCIT.py | 19 ++++----- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/hyppo/conditional/FCIT.py b/hyppo/conditional/FCIT.py index c84a7db51..c5a094543 100644 --- a/hyppo/conditional/FCIT.py +++ b/hyppo/conditional/FCIT.py @@ -3,10 +3,12 @@ import joblib import numpy as np from scipy.stats import ttest_1samp +from sklearn.base import clone from sklearn.metrics import mean_squared_error as mse from sklearn.model_selection import GridSearchCV, ShuffleSplit from sklearn.preprocessing import StandardScaler from sklearn.tree import DecisionTreeRegressor +from sklearn.utils import check_random_state from .base import ConditionalIndependenceTest, ConditionalIndependenceTestOutput @@ -30,6 +32,16 @@ class FCIT(ConditionalIndependenceTest): Proportion of data to evaluate test stat on. discrete: tuple of string Whether :math:`X` or :math:`Y` are discrete + random_state: int, RandomState instance, or None + Controls every source of randomness used internally (the data + permutations, the cross-validation splits, and, when ``model`` + doesn't already pin its own ``random_state``, the regressor + fit on each permutation). Pass an int for reproducible results. + When ``None`` (the default), results are not reproducible and, + because the underlying regressor then draws from NumPy's global + random state, the exact statistic/p-value can also drift across + scikit-learn versions whenever they change how many times that + global state gets consumed internally. Notes ----- @@ -62,6 +74,7 @@ def __init__( num_perm=8, prop_test=0.1, discrete=(False, False), + random_state=None, ): self.model = model @@ -69,6 +82,7 @@ def __init__( self.num_perm = num_perm self.prop_test = prop_test self.discrete = discrete + self.random_state = random_state ConditionalIndependenceTest.__init__(self) def statistic(self, x, y, z=None): @@ -91,16 +105,29 @@ def statistic(self, x, y, z=None): n_samples = x.shape[0] n_test = int(n_samples * self.prop_test) + rng = check_random_state(self.random_state) + data_permutations = [ - np.random.permutation(x.shape[0]) for i in range(self.num_perm) + rng.permutation(x.shape[0]) for i in range(self.num_perm) ] + reshuffle_seeds = rng.randint(0, np.iinfo(np.int32).max, size=self.num_perm) - clf = _cross_val(x, y, z, self.cv_grid, self.model, prop_test=self.prop_test) + cv_seed = rng.randint(0, np.iinfo(np.int32).max) + clf = _cross_val( + x, + y, + z, + self.cv_grid, + self.model, + prop_test=self.prop_test, + random_state=cv_seed, + ) datadict = { "x": x, "y": y, "z": z, "data_permutation": data_permutations, + "reshuffle_seeds": reshuffle_seeds, "n_test": n_test, "reshuffle": False, "clf": clf, @@ -113,12 +140,19 @@ def statistic(self, x, y, z=None): ) if z.shape[1] == 0: - x_indep_y = x[np.random.permutation(n_samples)] + x_indep_y = x[rng.permutation(n_samples)] else: x_indep_y = np.empty([x.shape[0], 0]) + cv_seed = rng.randint(0, np.iinfo(np.int32).max) clf = _cross_val( - x_indep_y, y, z, self.cv_grid, self.model, prop_test=self.prop_test + x_indep_y, + y, + z, + self.cv_grid, + self.model, + prop_test=self.prop_test, + random_state=cv_seed, ) datadict["reshuffle"] = True @@ -192,23 +226,30 @@ def test(self, x, y, z=None): return ConditionalIndependenceTestOutput(stat, pvalue) -def _cross_val(x, y, z, cv_grid, model, prop_test): +def _cross_val(x, y, z, cv_grid, model, prop_test, random_state=None): """ Choose the regression hyperparameters by cross-validation. """ - splitter = ShuffleSplit(n_splits=3, test_size=prop_test) + splitter = ShuffleSplit(n_splits=3, test_size=prop_test, random_state=random_state) cv = GridSearchCV(estimator=model, cv=splitter, param_grid=cv_grid, n_jobs=-1) cv.fit(_interleave(x, z), y) - return type(model)(**cv.best_params_) + best_model = clone(model) + best_model.set_params(**cv.best_params_) + + params = best_model.get_params() + if params.get("random_state") is None and random_state is not None: + best_model.set_params(random_state=random_state) + + return best_model def _interleave(x, z, seed=None): """Interleave x and z dimension-wise.""" state = np.random.get_state() - np.random.seed(seed or int(time.time())) + np.random.seed(seed if seed is not None else int(time.time())) total_ids = np.random.permutation(x.shape[1] + z.shape[1]) np.random.set_state(state) out = np.zeros([x.shape[0], x.shape[1] + z.shape[1]]) @@ -227,7 +268,12 @@ def _obtain_error(data_and_i): y = data["y"] z = data["z"] if data["reshuffle"]: + reshuffle_seeds = data.get("reshuffle_seeds") + seed = None if reshuffle_seeds is None else int(reshuffle_seeds[i]) + state = np.random.get_state() + np.random.seed(seed) perm_ids = np.random.permutation(x.shape[0]) + np.random.set_state(state) else: perm_ids = np.arange(x.shape[0]) data_permutation = data["data_permutation"][i] diff --git a/hyppo/conditional/tests/test_FCIT.py b/hyppo/conditional/tests/test_FCIT.py index 564d3a312..f59604974 100644 --- a/hyppo/conditional/tests/test_FCIT.py +++ b/hyppo/conditional/tests/test_FCIT.py @@ -10,21 +10,20 @@ class TestFCIT: @pytest.mark.parametrize( "n, obs_stat, obs_pvalue", [ - (2000, 11.677197, 3.8168e-06), + (2000, 15.854040, 4.815497e-07), ], ) def test_linear_oned(self, n, obs_stat, obs_pvalue): np.random.seed(123456789) x, y = rot_ksamp("linear", n, 1, k=2) - np.random.seed(123456789) - stat, pvalue = FCIT().test(x, y) + stat, pvalue = FCIT(random_state=0).test(x, y) assert_almost_equal(stat, obs_stat, decimal=-1) assert_almost_equal(pvalue, obs_pvalue, decimal=4) @pytest.mark.parametrize( "dim, n, obs_stat, obs_pvalue", - [(1, 100000, -0.16024, 0.56139), (2, 100000, -4.59882, 0.99876)], + [(1, 100000, -1.339294, 0.888837), (2, 100000, -6.985438, 0.999893)], ) def test_null(self, dim, n, obs_stat, obs_pvalue): np.random.seed(12) @@ -46,8 +45,9 @@ def test_null(self, dim, n, obs_stat, obs_pvalue): ).T ) - np.random.seed(122) - stat, pvalue = FCIT().test(x1.T, y1.T, z1) + # random_state pins every source of randomness FCIT uses internally, + # so this is reproducible across scikit-learn versions (gh-427). + stat, pvalue = FCIT(random_state=0).test(x1.T, y1.T, z1) assert_almost_equal(pvalue, obs_pvalue, decimal=4) assert_almost_equal(stat, obs_stat, decimal=4) @@ -55,8 +55,8 @@ def test_null(self, dim, n, obs_stat, obs_pvalue): @pytest.mark.parametrize( "dim, n, obs_stat, obs_pvalue", [ - (1, 100000, 89.271754, 2.91447597e-12), - (2, 100000, 161.35165, 4.63412957e-14), + (1, 100000, 100.371119, 1.283942e-12), + (2, 100000, 187.502936, 1.619644e-14), ], ) def test_alternative(self, dim, n, obs_stat, obs_pvalue): @@ -81,8 +81,7 @@ def test_alternative(self, dim, n, obs_stat, obs_pvalue): ).T ) - np.random.seed(122) - stat, pvalue = FCIT().test(x2.T, y2.T, z2) + stat, pvalue = FCIT(random_state=0).test(x2.T, y2.T, z2) assert_almost_equal(pvalue, obs_pvalue, decimal=12) assert_almost_equal(stat, obs_stat, decimal=4)