Source code for scitex_stats.tests.agreement._test_kendalls_w

#!/usr/bin/env python3
# Timestamp: "2026-06-03 10:15:00 (ywatanabe)"
# File: scitex_stats/tests/agreement/_test_kendalls_w.py
# ----------------------------------------
from __future__ import annotations

"""
Functionalities:
  - Compute Kendall's coefficient of concordance W (Kendall & Babington
    Smith 1939) on a (n_subjects, k_raters) matrix of scores
  - Return W, chi-squared significance approximation, and degrees of
    freedom in a uniform result dict / DataFrame

Dependencies:
  - packages: numpy, pandas, scipy

IO:
  - input: 2-D matrix of scores (rows = subjects, cols = raters), or a
    long-format DataFrame with subj/rater/score columns
  - output: result dict / DataFrame with W, chi2, dof, pvalue, n, k
"""

import os
from typing import Literal, Optional, Union

import numpy as np
import pandas as pd
from scipy.stats import chi2 as _chi2_dist
from scipy.stats import rankdata

from scitex_stats._logging import getLogger
from scitex_stats._utils._formatters import fmt_stat, fmt_sym, p2stars
from scitex_stats._utils._normalizers import convert_results, force_dataframe

__FILE__ = __file__
__DIR__ = os.path.dirname(__FILE__)

logger = getLogger(__name__)


def interpret_kendalls_w(W: float) -> str:
    """Interpret Kendall's W magnitude.

    Common rough guideposts (Schmidt 1997, citing Landis & Koch):
        W < 0.1   : negligible agreement
        0.1-0.3   : weak
        0.3-0.5   : moderate
        0.5-0.7   : strong
        ≥ 0.7     : very strong / unusually high
    """
    w_abs = abs(W)
    if w_abs < 0.1:
        return "negligible"
    if w_abs < 0.3:
        return "weak"
    if w_abs < 0.5:
        return "moderate"
    if w_abs < 0.7:
        return "strong"
    return "very strong"


def _as_matrix(
    data: Union[np.ndarray, pd.DataFrame, dict],
    *,
    subj_col: Optional[str] = None,
    rater_col: Optional[str] = None,
    score_col: Optional[str] = None,
) -> np.ndarray:
    """Coerce input to a 2-D (n_subjects, k_raters) ndarray."""
    if isinstance(data, np.ndarray):
        mat = data
    elif isinstance(data, pd.DataFrame):
        if (subj_col and rater_col and score_col
                and {subj_col, rater_col, score_col}.issubset(data.columns)):
            mat = (data.pivot_table(index=subj_col, columns=rater_col,
                                    values=score_col)
                       .to_numpy())
        else:
            mat = data.to_numpy()
    elif isinstance(data, dict):
        mat = pd.DataFrame(data).to_numpy()
    else:
        mat = np.asarray(data)
    if mat.ndim != 2:
        raise ValueError(
            "Kendall's W requires a 2-D (n_subjects, k_raters) matrix;"
            f" got shape {mat.shape}"
        )
    return mat.astype(float)


[docs] def test_kendalls_w( data: Union[np.ndarray, pd.DataFrame, dict], *, subj_col: Optional[str] = None, rater_col: Optional[str] = None, score_col: Optional[str] = None, use_abs: bool = False, alpha: float = 0.05, return_as: Literal["dict", "dataframe"] = "dict", decimals: int = 3, verbose: bool = False, ) -> Union[dict, pd.DataFrame]: """ Kendall's coefficient of concordance W. Parameters ---------- data : ndarray | DataFrame | dict 2-D matrix of scores (rows = subjects, cols = raters), or a long-format DataFrame with (subj, rater, score) triples (pass ``subj_col``, ``rater_col``, ``score_col`` to pivot). subj_col, rater_col, score_col : str, optional Column names for the long-format input. Ignored if ``data`` is already a wide matrix. use_abs : bool, default False If True, rank ``|score|`` instead of ``score``. Useful when the sign of the score is irrelevant to the agreement question (e.g. ranking channels by |effect size|). alpha : float, default 0.05 Significance level (used only for the formatted summary). return_as : {"dict", "dataframe"}, default "dict" Output container. decimals : int, default 3 Rounding for the formatted summary. verbose : bool, default False Returns ------- dict | DataFrame Keys: name, statistic, W, S, n, k, dof, chi2, pvalue, alpha, significant, formatted, sym, stars, effect_size, effect_size_label, interpretation. Notes ----- W is computed on the within-rater ranks of each subject. Ties are handled by ``scipy.stats.rankdata`` (average ranks). With k raters and n subjects: .. math:: S = \\sum_{i=1}^{n} \\Big(R_i - \\bar{R}\\Big)^2, \\qquad W = \\frac{12 S}{k^2 (n^3 - n)} The significance approximation uses Friedman's chi-square statistic :math:`\\chi^2 = k(n-1) W` with :math:`n-1` degrees of freedom. The approximation is reasonable for :math:`n \\ge 5`. References ---------- Kendall, M. G. & Babington Smith, B. (1939). The Problem of m Rankings. Annals of Mathematical Statistics, 10(3), 275-287. Schmidt, F. (1997). Managing Project Risk and Uncertainty. Legendre, P. (2005). Species associations: the Kendall coefficient of concordance revisited. Journal of Agricultural, Biological, and Environmental Statistics, 10(2), 226-245. """ mat = _as_matrix(data, subj_col=subj_col, rater_col=rater_col, score_col=score_col) if np.isnan(mat).any(): keep = ~np.isnan(mat).any(axis=1) dropped = int(mat.shape[0] - keep.sum()) if verbose and dropped: logger.info( "test_kendalls_w: dropping %d subjects with NaN ratings", dropped, ) mat = mat[keep] n, k = mat.shape if n < 2 or k < 2: result = { "name": "Kendall's W (coefficient of concordance)", "statistic": float("nan"), "W": float("nan"), "S": float("nan"), "n": int(n), "k": int(k), "dof": 0, "chi2": float("nan"), "pvalue": float("nan"), "alpha": alpha, "significant": False, "formatted": "Kendall's W: insufficient data", "sym": "", "stars": "", "effect_size": float("nan"), "effect_size_label": "Kendall's W", "interpretation": "n/a (insufficient data)", } return convert_results(result, return_as=return_as) target = np.abs(mat) if use_abs else mat ranks = np.apply_along_axis(rankdata, 0, target) R = ranks.sum(axis=1) S = float(((R - R.mean()) ** 2).sum()) W = 12.0 * S / (k ** 2 * (n ** 3 - n)) chi2_stat = k * (n - 1) * W dof = n - 1 pvalue = float(1.0 - _chi2_dist.cdf(chi2_stat, dof)) interp = interpret_kendalls_w(W) significant = pvalue < alpha stars = p2stars(pvalue) sym = fmt_sym("W") stat_str = fmt_stat("W", W, fmt=f".{decimals}f", stars=stars) formatted = ( f"Kendall's W = {W:.{decimals}f}, " f"χ²({dof}) = {chi2_stat:.{decimals}f}, " f"p = {pvalue:.{decimals}f} {stars} " f"(n = {n}, k = {k}, {interp})" ) result = { "name": "Kendall's W (coefficient of concordance)", "statistic": float(W), "W": float(W), "S": S, "n": int(n), "k": int(k), "dof": int(dof), "chi2": float(chi2_stat), "pvalue": pvalue, "alpha": alpha, "significant": significant, "formatted": formatted, "stat_str": stat_str, "sym": sym, "stars": stars, "effect_size": float(W), "effect_size_label": "Kendall's W", "interpretation": interp, } if verbose: logger.info(formatted) return convert_results(result, return_as=return_as)