Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ API
stats.ensure_2d
stats.q_profile
stats.q_gen
stats.log_chi2_sf
stats.bonferroni
stats.fdr

Expand Down
70 changes: 42 additions & 28 deletions pymare/estimators/combination.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
from abc import abstractmethod

import numpy as np
import scipy.stats as ss
from scipy.special import log_ndtr, ndtr
from scipy.special import log_ndtr, ndtri_exp

from ..results import CombinationTestResults
from ..stats import encode_groups, normalize_group_weights
from ..stats import encode_groups, log_chi2_sf, normalize_group_weights
from .estimators import BaseEstimator


Expand Down Expand Up @@ -85,12 +84,17 @@ def __init__(self, mode="directed"):
self.mode = mode

@abstractmethod
def p_value(self, z, *args, **kwargs):
"""Calculate p-values."""
def log_p_value(self, z, *args, **kwargs):
"""Calculate natural logarithms of the p-values."""
pass

def _z_to_p(self, z):
return ndtr(-z)
def p_value(self, z, *args, **kwargs):
"""Calculate p-values.

Underflows to zero where the combined evidence exceeds what a double can
represent; :meth:`log_p_value` is the same quantity without that limit.
"""
return np.exp(self.log_p_value(z, *args, **kwargs))

def fit(self, z, *args, **kwargs):
"""Fit the estimator to z-values."""
Expand All @@ -102,18 +106,23 @@ def fit(self, z, *args, **kwargs):
# aggregation) while evaluating the two directed tails.
ose = copy.copy(self)
ose.mode = "directed"
p1 = ose.p_value(z, *args, **kwargs)
p2 = ose.p_value(-z, *args, **kwargs)
p = np.minimum(1, 2 * np.minimum(p1, p2))
z_calc = ss.norm.isf(p)
z_calc[p2 < p1] *= -1
log_p1 = ose.log_p_value(z, *args, **kwargs)
log_p2 = ose.log_p_value(-z, *args, **kwargs)
# Doubling the smaller tail and capping at 1, in logs: the
# correction for two tests is an added log(2), the cap a minimum
# against log(1).
log_p = np.minimum(0.0, np.log(2.0) + np.minimum(log_p1, log_p2))
z_calc = -ndtri_exp(log_p)
z_calc[log_p2 < log_p1] *= -1
else:
if self.mode == "undirected":
z = np.abs(z)
p = self.p_value(z, *args, **kwargs)
z_calc = ss.norm.isf(p)
log_p = self.log_p_value(z, *args, **kwargs)
# ``norm.isf(p)`` instead would saturate to +/-inf the moment p
# underflowed or hit 1; the inverse of the log CDF does not.
z_calc = -ndtri_exp(log_p)

self.params_ = {"p": p, "z": z_calc}
self.params_ = {"p": np.exp(log_p), "logp": log_p, "z": z_calc}
return self

def summary(self):
Expand All @@ -124,8 +133,12 @@ def summary(self):
"This {} instance hasn't been fitted yet. Please "
"call fit() before summary().".format(name)
)
# p is exactly ``exp(logp)``, which the container derives itself, so
# passing it would store a second copy of one quantity. z is not
# derivable here: in concordant mode it carries the sign of whichever
# tail won, which the log p-value alone does not record.
return CombinationTestResults(
self, self.dataset_, z=self.params_["z"], p=self.params_["p"]
self, self.dataset_, z=self.params_["z"], logp=self.params_["logp"]
)


Expand Down Expand Up @@ -345,8 +358,8 @@ def fit(self, z, w=None, g=None, corr=None):
self.corr_ = corr
return super().fit(z, w=w, g=g, corr=corr)

def p_value(self, z, w=None, g=None, corr=None):
"""Calculate p-values."""
def log_p_value(self, z, w=None, g=None, corr=None):
"""Calculate natural logarithms of the p-values."""
if w is None:
w = np.ones_like(z)
else:
Expand All @@ -370,7 +383,7 @@ def p_value(self, z, w=None, g=None, corr=None):
group_z, group_w = self._group_statistics(z, w, g, corr=corr)
variance = np.square(group_w).sum(axis=0)
cz = (group_z * group_w).sum(axis=0) / np.sqrt(variance)
return ss.norm.sf(cz)
return log_ndtr(-cz)

if g is None and corr is not None:
warnings.warn("Correlation matrix provided without groups. Ignoring.")
Expand All @@ -385,7 +398,10 @@ def p_value(self, z, w=None, g=None, corr=None):
variance = (w**2).sum(0) + sigma

cz = (z * w).sum(0) / np.sqrt(variance)
return ss.norm.sf(cz)
# log_ndtr, not norm.sf: the combined z is a weighted *sum*, so it grows
# with the number of observations and passes 38 -- where a double-
# precision p-value is exactly zero -- on datasets of very ordinary size.
return log_ndtr(-cz)


class FisherCombinationTest(CombinationTest):
Expand Down Expand Up @@ -649,15 +665,13 @@ def fit(self, z, g=None, corr=None, w=None):
self.corr_ = corr
return super().fit(z, g=g, corr=corr, w=w)

def p_value(self, z, g=None, corr=None, w=None):
"""Calculate p-values."""
def log_p_value(self, z, g=None, corr=None, w=None):
"""Calculate natural logarithms of the p-values."""
g, corr = self._validate_dependence_inputs(z, g, corr)

# Work in log space throughout. Going via p underflows to exactly 0
# around z = 38, after which log(p) is -inf and the combined result
# collapses to p = 0 with z = inf, no matter how many other inputs
# argue otherwise. log_ndtr is accurate far into that tail, so a single
# extreme z no longer destroys the statistic.
# Work in log space throughout. Going
# via p underflows to exactly 0 around z = 38, after which log(p) is
# -inf and the combined result collapses to p = 0 with z = inf.
log_p = log_ndtr(-z)
weights = self._group_weights(g, z.shape[0], w=w)
if g is None and w is None:
Expand All @@ -682,4 +696,4 @@ def p_value(self, z, g=None, corr=None, w=None):
scale = variance / (2.0 * expectation)
dof = 2.0 * expectation**2 / variance

return ss.chi2.sf(chi2 / scale, dof)
return log_chi2_sf(chi2 / scale, dof)
109 changes: 70 additions & 39 deletions pymare/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import numpy as np
import pandas as pd
import scipy.stats as ss
from scipy.special import log_ndtr, ndtri_exp

try:
import arviz as az
Expand All @@ -25,6 +26,7 @@
collapse_groups,
collapse_groups_by_n,
encode_groups,
log_chi2_sf,
q_gen,
q_profile,
)
Expand Down Expand Up @@ -273,7 +275,8 @@ def get_fe_stats(self, alpha=0.05):
est The parameter estimate for the regressor.
se The standard error of the estimate.
z The z score of the estimate.
p The p value the estimate.
p The two-tailed p value of the estimate.
logp The natural logarithm of ``p``.
ci_l/ci_u Lower and upper bounds of the estimate.
=========== ==========================================================================

Expand All @@ -296,49 +299,61 @@ def get_fe_stats(self, alpha=0.05):
how uncertain the coefficients are. The ``undefined`` branch below turns
that into a NaN p-value rather than a maximally significant one.

The tail is computed in logs because ``p`` cannot express the
transform's own input: a two-tailed normal p is exactly zero from
``|z| = 38.5`` on, and every deviate past that would come back
infinite. Via ``logp``, ``z`` grows like ``sqrt(2 |logp|)`` without
bound.

.. versionchanged:: 0.0.11
``p`` is no longer floored at ``numpy.finfo(float).eps``. The floor
read a limit of precision as one of magnitude, truncating ``z`` at
``norm.isf(eps / 2) = 8.21`` for every estimate below 2.2e-16,
however far below it fell.

References
----------
.. footbibliography::

"""
beta, se = self.fe_params, self.fe_se
epsilon = np.finfo(beta.dtype).eps
z = beta / se

# Cluster-robust standard errors are asymptotic in the number of
# groups, so refer them to a t distribution rather than a normal.
dof = self.fe_dof
if dof is None:
crit = ss.norm.ppf(1 - alpha / 2)
p = 1 - np.abs(0.5 - ss.norm.cdf(z)) * 2
else:
crit = ss.t.ppf(1 - alpha / 2, dof)
p = 2 * ss.t.sf(np.abs(z), dof)
# A zero standard error divides to +-inf, or to NaN if the estimate is
# zero too. Both are answered by the ``undefined`` mask below rather
# than by a warning, so the arithmetic is allowed to produce them.
with np.errstate(invalid="ignore", divide="ignore"):
stat = beta / se
if dof is None:
crit = ss.norm.ppf(1 - alpha / 2)
log_tail = ss.norm.logsf(np.abs(stat))
z = stat
else:
crit = ss.t.ppf(1 - alpha / 2, dof)
log_tail = ss.t.logsf(np.abs(stat), dof)
z = -np.sign(stat) * ndtri_exp(log_tail)

p = np.asarray(p, dtype=float)
p[p == 0] += epsilon
# Two tails, so an added log(2). The cap is against log(1), which
# the sum can exceed only by rounding at a statistic of essentially
# zero; it is not a floor on the tail.
logp = np.minimum(log_tail + math.log(2.0), 0.0)
p = np.exp(logp)

# A standard error that is zero or non-finite carries no information,
# but "est / 0" is +-inf and yields p = 0 -- maximally significant. Mark
# those entries undefined instead of maximally certain.
undefined = ~np.isfinite(se) | (se <= 0)
if np.any(undefined):
p = np.where(undefined, np.nan, p)

if dof is not None:
# ``est / se`` is a t statistic here, so reporting it as "z" would
# leave the two entries disagreeing: thresholding on the z values
# and on the p values would select different results. Report the
# z that carries the same tail probability instead.
z = np.sign(z) * ss.norm.isf(np.clip(p, epsilon, 1.0) / 2)

stats = {
"est": beta,
"se": se,
"ci_l": np.where(undefined, np.nan, beta - crit * se),
"ci_u": np.where(undefined, np.nan, beta + crit * se),
"z": np.where(undefined, np.nan, z),
"p": p,
"p": np.where(undefined, np.nan, p),
"logp": np.where(undefined, np.nan, logp),
}

return stats
Expand Down Expand Up @@ -491,6 +506,8 @@ def get_heterogeneity_stats(self):
freedom, where n is the number of independent observations and k is the
number of regressors.
p(Q) P values associated with the Cochran's Q values.
logp(Q) Natural logarithms of the ``p(Q)`` values, computed directly and
therefore still finite where ``p(Q)`` has underflowed to zero.
I^2 The proportion of the variance in input estimates that is due to heterogeneity
instead of sampling error :footcite:p:`higgins2002quantifying`.
This measure is bounded from 0 to 100.
Expand Down Expand Up @@ -518,12 +535,18 @@ def get_heterogeneity_stats(self):
# zero and I^2 / H are 100% / inf from pure rounding noise, which
# reads as total heterogeneity rather than no information.
nan = np.full(np.shape(q_fe), np.nan)
return {"Q": nan, "p(Q)": nan.copy(), "I^2": nan.copy(), "H": nan.copy()}
return {
"Q": nan,
"p(Q)": nan.copy(),
"logp(Q)": nan.copy(),
"I^2": nan.copy(),
"H": nan.copy(),
}

i2 = np.maximum(100.0 * (q_fe - df) / q_fe, 0.0)
h = np.maximum(np.sqrt(q_fe / df), 1.0)
p = ss.chi2.sf(q_fe, df)
return {"Q": q_fe, "p(Q)": p, "I^2": i2, "H": h}
logp = log_chi2_sf(q_fe, df)
return {"Q": q_fe, "p(Q)": np.exp(logp), "logp(Q)": logp, "I^2": i2, "H": h}

def to_df(self, alpha=0.05):
"""Return a pandas DataFrame summarizing fixed effect results.
Expand Down Expand Up @@ -554,6 +577,7 @@ def to_df(self, alpha=0.05):
se The standard error of the estimate.
z-score The z score of the estimate.
p-value The p value the estimate.
-log10(p) The p value on a base-10 log scale.
ci_+ Lower and upper bounds of the estimate. There will be two columns, with
names based on the ``alpha`` value. For example, if ``alpha = 0.05``,
the CI columns will be ``"ci_0.025"`` and ``"ci_0.975"``.
Expand All @@ -573,10 +597,11 @@ def to_df(self, alpha=0.05):
fe_stats = self.get_fe_stats(alpha).items()
df = pd.DataFrame({k: v.ravel() for k, v in fe_stats})
df["name"] = self.dataset.X_names
df = df.loc[:, ["name", "est", "se", "z", "p", "ci_l", "ci_u"]]
df["logp"] = np.abs(df["logp"]) / math.log(10.0)
df = df.loc[:, ["name", "est", "se", "z", "p", "logp", "ci_l", "ci_u"]]
ci_l = "ci_{:.6g}".format(alpha / 2)
ci_u = "ci_{:.6g}".format(1 - alpha / 2)
df.columns = ["name", "estimate", "se", "z-score", "p-value", ci_l, ci_u]
df.columns = ["name", "estimate", "se", "z-score", "p-value", "-log10(p)", ci_l, ci_u]
return df

def permutation_test(self, n_perm=1000):
Expand Down Expand Up @@ -752,30 +777,42 @@ class CombinationTestResults:
Array of z-scores. Default = None.
p : :obj:`numpy.ndarray`, optional
Array of right-tailed p-values. Default = None.
logp : :obj:`numpy.ndarray`, optional
Array of natural logarithms of the right-tailed p-values. Default = None.
"""

def __init__(self, estimator, dataset, z=None, p=None):
def __init__(self, estimator, dataset, z=None, p=None, logp=None):
self.estimator = estimator
self.dataset = dataset
if p is None and z is None:
raise ValueError("One of 'z' or 'p' must be provided.")
if p is None and z is None and logp is None:
raise ValueError("One of 'z', 'p' or 'logp' must be provided.")
self._z = z
self._p = p
self._logp = logp

@property
@lru_cache(maxsize=1)
def logp(self):
"""Natural logarithms of the p-values."""
if self._logp is None:
with np.errstate(divide="ignore"):
self._logp = log_ndtr(-self._z) if self._z is not None else np.log(self._p)
return self._logp

@property
@lru_cache(maxsize=1)
def z(self):
"""Z-values."""
if self._z is None:
self._z = ss.norm.isf(self.p)
self._z = -ndtri_exp(self.logp)
return self._z

@property
@lru_cache(maxsize=1)
def p(self):
"""P-values."""
if self._p is None:
self._p = ss.norm.sf(self.z)
self._p = np.exp(self.logp)
return self._p

def permutation_test(self, n_perm=1000):
Expand Down Expand Up @@ -904,14 +941,8 @@ def permutation_test(self, n_perm=1000):
kwargs["corr"] = permutation_corr
params = est.fit(**kwargs).params_

# Compare on p-values, not on z. The reported z is
# ``norm.isf(p)``, which saturates to +/-inf as soon as p hits 0
# or 1 -- routine for concordant mode, where p is capped at 1.
# Every permutation then ties at -inf and the comparison degrades
# to "always significant". p is the actual test statistic in every
# mode, is monotone in the evidence, and never overflows.
observed = np.ravel(self.p)[i]
null = np.ravel(params["p"])
observed = np.ravel(self.logp)[i]
null = np.ravel(params["logp"])
p_p[i] = (null <= observed).mean()

# p-values can't be smaller than 1/n_perm
Expand Down
Loading
Loading