Skip to content

compute_effsize: honor eftype for one-sample tests (eta_square was out of range) - #519

Open
AlejandroCoronadoN wants to merge 1 commit into
raphaelvallat:mainfrom
AlejandroCoronadoN:fix-effsize-one-sample-eftype
Open

compute_effsize: honor eftype for one-sample tests (eta_square was out of range)#519
AlejandroCoronadoN wants to merge 1 commit into
raphaelvallat:mainfrom
AlejandroCoronadoN:fix-effsize-one-sample-eftype

Conversation

@AlejandroCoronadoN

Copy link
Copy Markdown

For a one-sample test (scalar y), compute_effsize ignores the eftype argument and always returns the raw Cohen's d. In particular eftype="eta_square" returns a value greater than 1, which is impossible since eta-squared is bounded in [0, 1].

import pingouin as pg
x = [1, 2, 3, 4, 5, 6, 7]
pg.compute_effsize(x, y=0, eftype="eta_square")  # 1.85  (must be <= 1)
pg.compute_effsize(x, y=0, eftype="hedges")      # 1.85  (should be 1.61)
pg.compute_effsize(x, y=0, eftype="odds_ratio")  # 1.85  (should be 28.75)

The two-sample branch already converts Cohen's d to the requested effect size via convert_effsize; the one-sample branch returned d directly and skipped that step. This routes the one-sample d through the same convert_effsize call, so eftype is honored: eta_square 0.462, hedges 1.610, odds_ratio 28.75, and cohen is unchanged. Added a regression test. Happy to add a changelog entry if you'd like.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.36%. Comparing base (2d906c5) to head (2b1880b).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #519   +/-   ##
=======================================
  Coverage   98.36%   98.36%           
=======================================
  Files          19       19           
  Lines        3305     3305           
  Branches      488      488           
=======================================
  Hits         3251     3251           
  Misses         32       32           
  Partials       22       22           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@raphaelvallat raphaelvallat left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks @AlejandroCoronadoN, good catch. See Claude-assisted review below:

The catch is that convert_effsize accepts a narrower set of eftype values than compute_effsize / _check_eftype does, so delegating unconditionally exposes that mismatch: cles / cohen_dz silently become AUC, and r — still documented and supported — now raises. Both leak into pairwise_tests whenever a group reduces to a single observation. Details in the three inline comments; the suggestion on the first one covers all of them.

Two non-blocking notes:

  • The Notes section of compute_effsize (around line 705) still documents the one-sample case as returning d = (X̄ - µ) / σ_X with no mention that eftype is now honored — worth a sentence, plus the caveat that eta_square / odds_ratio assume a two-group contrast.
  • A CHANGELOG entry is still missing.

(The --doctest-modules failures on CI are pre-existing on main — NumPy 2 np.float64(...) reprs — and unrelated to this PR.)

Comment thread src/pingouin/effsize.py
# Case 1: One-sample Test
d = (x.mean() - y) / x.std(ddof=1)
return d
return convert_effsize(d, "cohen", eftype, nx=nx, ny=ny)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

eftype="cles" and eftype="cohen_dz" now silently return an AUC value.

_check_eftype (src/pingouin/utils.py:325) accepts cles and cohen_dz, but convert_effsize has no branch for either, so both fall into the trailing else: # ['auc'] and return norm.cdf(d / sqrt(2)).

>>> pg.compute_effsize([1, 2, 3, 4, 5, 6, 7], y=0, eftype="cles")
0.90478   # identical to eftype="auc"

This also leaks into callers: with a single-observation group,
pg.pairwise_tests(data=df, dv="v", between="g", effsize="CLES") reports 1.996e-05 in the CLES
column — an AUC value, plausible-looking and inside [0, 1], so it will not be noticed.

Suggested fix below also covers the two other comments on this line (the r regression and the
ny=1 degeneration), so it can be applied on its own:

Suggested change
return convert_effsize(d, "cohen", eftype, nx=nx, ny=ny)
if eftype.lower() in ["cohen", "cohen_dz", "r", "cles"]:
# Not supported by convert_effsize: 'r' raises a deprecation error there,
# and 'cles'/'cohen_dz' have no branch and would fall through to AUC.
return d
if eftype.lower() == "pointbiserialr":
# ny=1 degenerates McGrath & Meyer's `a` to nx, which makes the conversion
# sample-size dependent. ny=None uses the intended a = 4.
return convert_effsize(d, "cohen", eftype, nx=nx, ny=None)
return convert_effsize(d, "cohen", eftype, nx=nx, ny=ny)

Comment thread src/pingouin/effsize.py
# Case 1: One-sample Test
d = (x.mean() - y) / x.std(ddof=1)
return d
return convert_effsize(d, "cohen", eftype, nx=nx, ny=ny)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

eftype="r" now raises ValueError in the one-sample branch.

convert_effsize deliberately rejects "r" ("has been deprecated. Please use 'pointbiserialr' instead"), but "r" is still a documented, supported value of compute_effsize (src/pingouin/effsize.py:657) and of pairwise_tests (src/pingouin/pairwise.py:105), and the two-sample branch right below handles it explicitly via pearsonr.

  • pg.compute_effsize(x, y=0, eftype="r") previously returned the d, now raises.
  • pg.pairwise_tests(data=df, dv="v", between="g", effsize="r") now raises that deprecation error from deep inside whenever a group reduces to one observation (an n=1 cell, or one remaining value after nan_policy="pairwise" deletion) — previously it produced a number.

Note that ny == 1 is a size check, not an "is scalar" check, so any length-1 y array takes this path too.

Comment thread src/pingouin/effsize.py
# Case 1: One-sample Test
d = (x.mean() - y) / x.std(ddof=1)
return d
return convert_effsize(d, "cohen", eftype, nx=nx, ny=ny)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Passing ny=1 makes the pointbiserialr conversion sample-size dependent.

convert_effsize computes a = ((nx + ny) ** 2 - 2 * (nx + ny)) / (nx * ny) (line 613), which with ny=1 degenerates to a ~= nx, i.e. r_pb = d / sqrt(d**2 + n) instead of the intended a = 4. For a fixed d = 1.8516:

n pointbiserialr
7 0.577
20 0.383
100 0.182
1000 0.058

A fixed effect size must not map to a correlation that vanishes as the sample grows. Passing ny=None (which falls back to a = 4) or rejecting pointbiserialr on this path both work.

Worth noting that ny=1 is correct for hedges: d * (1 - 3 / (4 * (nx + 1) - 9)) reduces to the standard one-sample J = 1 - 3 / (4n - 5), which is why the 1.6101 value in the PR description checks out. The (d/2) and d * pi / sqrt(3) factors behind eta_square / odds_ratio do assume a two-equal-group contrast, but they stay bounded and monotonic — a documentation caveat rather than a bug.

Comment thread tests/test_effsize.py
eta = compute_effsize(xs, y=0, eftype="eta_square")
assert 0 <= eta <= 1
# other types must match convert_effsize applied to the one-sample d
for eftype in ["hedges", "odds_ratio", "eta_square"]:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Could this loop also cover the eftype values where convert_effsize and _check_eftype disagree? cles, cohen_dz and r are the ones that regress (see the comments on effsize.py), and pointbiserialr is the one whose value depends on n:

        # types that convert_effsize does not handle must still return the d
        for eftype in ["cohen", "cles", "cohen_dz", "r"]:
            assert np.isclose(compute_effsize(xs, y=0, eftype=eftype), d)
        # pointbiserialr must not depend on the sample size
        long_xs = xs * 100
        assert np.isclose(
            compute_effsize(xs, y=0, eftype="pointbiserialr"),
            compute_effsize(long_xs, y=0, eftype="pointbiserialr"),
        )

The second assertion holds only once a = 4 is used, so it pins the fix down.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants