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
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Publish to PyPI

on:
release:
types: [published]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: python -m pip install --upgrade build twine
- run: python -m build
- run: twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish-to-pypi:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # required for OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
58 changes: 58 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Tests

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v7

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]

- name: Run tests
run: pytest --cov=./ --cov-report=xml

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
fail_ci_if_error: false

lint:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]

- name: Run ruff
run: ruff check .

- name: Run mypy
run: mypy PyNonpar
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
build/
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
.idea/
dist/

# AI assistant configuration/context files - not part of the published project
CLAUDE.md
.claude/
19 changes: 0 additions & 19 deletions .travis.yml

This file was deleted.

33 changes: 33 additions & 0 deletions PyNonpar/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Shared ``typing.Literal`` aliases and input coercion helpers used across PyNonpar."""

from typing import Literal

import numpy as np
from numpy.typing import ArrayLike

Alternative = Literal["two.sided", "less", "greater"]
Quantile = Literal["t", "normal"]
TiesMethod = Literal["average", "min", "max"]
Method = Literal["asymptotic", "exact"]
HettmanspergerAlternative = Literal["increasing", "decreasing", "custom"]
Distribution = Literal["F", "Chisq"]


def as_list(x: ArrayLike, name: str) -> list:
"""
Coerce array-like input (list, tuple, numpy array, pandas Series, ...) to a plain list.

Args:
x (array_like): input to coerce \n
name (str): parameter name, used in the error message on failure \n

Returns:
plain Python list (list)
"""
try:
arr = np.asarray(x)
except ValueError as exc:
raise TypeError(f"{name} must be array-like (list, tuple, numpy array, or pandas Series) of a consistent shape") from exc
if arr.ndim != 1:
raise TypeError(f"{name} must be one-dimensional array-like (list, tuple, numpy array, or pandas Series)")
return arr.tolist()
106 changes: 65 additions & 41 deletions PyNonpar/multisample.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,40 @@
.. moduleauthor:: Martin Happ <martin.happ@aon.at>
"""

import pandas as pd
import numpy as np
import PyNonpar.pseudorank as ps
import math
from typing import Any, NamedTuple, Optional

import numpy as np
import pandas as pd
import scipy
import scipy.stats
from collections import namedtuple
from numpy.typing import ArrayLike

import PyNonpar.pseudorank
import PyNonpar.pseudorank as ps
from PyNonpar._types import HettmanspergerAlternative, as_list


def kruskal_wallis_test(data, group, pseudoranks = True):
class KruskalWallisResult(NamedTuple):
statistic: float
pvalue: float


class HettmanspergerNortonResult(NamedTuple):
alternative: str
weight: Any
statistic: float
pvalue: float


def kruskal_wallis_test(data: ArrayLike, group: ArrayLike, pseudoranks: bool = True) -> KruskalWallisResult:
"""
Function to calculate the Kruskal-Wallis test. It is recommended to use pseudo-ranks as ranks may lead to paradoxical results.\n
Null hypothesis H_0: F_1 = ... F_a

Args:
data (list(float)): data from all groups \n
group (list(int)): group factor \n
data (array_like): data from all groups \n
group (array_like): group factor \n
pseudoranks (bool): True if pseudo-ranks instead of ranks are used \n

Returns:
Expand All @@ -33,8 +50,13 @@ def kruskal_wallis_test(data, group, pseudoranks = True):
p-value (float)
"""

# Check inputs
data = as_list(data, "data")
group = as_list(group, "group")
if not isinstance(pseudoranks, bool):
raise TypeError("pseudoranks must be a bool")

N = len(data)
ranks = [0 for i in range(N)]
if pseudoranks:
ranks = PyNonpar.pseudorank.psrank(data, group)
else:
Expand All @@ -47,38 +69,31 @@ def kruskal_wallis_test(data, group, pseudoranks = True):
a = len(np.unique(df['grp']))

# numerator
rank_means = [0.0 for i in range(a)]
n = [0 for i in range(a)]
grouped = df.groupby('grp')['data']
n_series = grouped.size()
rank_means_series = grouped.mean()
numerator = float(np.sum(n_series.to_numpy() * ( rank_means_series.to_numpy() - (N + 1)*1/2 ) ** 2))

numerator = 0.0
for i in range(a):
n[i] = len(df[df['grp'] == i]['data'])
rank_means[i] = np.mean( df[df['grp'] == i]['data'] )
numerator += n[i] * ( rank_means[i] - (N + 1)*1/2 ) ** 2

denominator = 0.0
for i in range(N):
denominator += ( df['data'][i] - (N + 1)*1/2 ) ** 2
denominator = float(np.sum(( df['data'].to_numpy() - (N + 1)*1/2 ) ** 2))

Q_N = numerator*1/denominator*(N - 1)

p_value = 1 - scipy.stats.chi2.cdf(Q_N, a - 1)

result = namedtuple('KruskalWallisResult', ('statistic', 'pvalue'))
output = result(Q_N, p_value)
p_value = float(1 - scipy.stats.chi2.cdf(Q_N, a - 1))

return output
return KruskalWallisResult(Q_N, p_value)


def hettmansperger_norton_test(data, group, alternative = "increasing", trend = None):
def hettmansperger_norton_test(
data: ArrayLike, group: ArrayLike, alternative: HettmanspergerAlternative = "increasing", trend: Optional[ArrayLike] = None
) -> HettmanspergerNortonResult:
"""
Function to calculate the Hettmansperger-Norton test.

Args:
data (list(float)): data from all groups \n
group (list(int)): group factor \n
data (array_like): data from all groups \n
group (array_like): group factor \n
alternative (str): either 'increasing', 'decreasing' or 'custom' \n
trend (list(float)): a vector specifying the alternative; only used, if alternative = 'custom' \n
trend (array_like): a vector specifying the alternative; only used, if alternative = 'custom' \n

Returns:
namedtuple('HettmanspergerNortonResult', ('alternative', 'weight', 'statistic', 'pvalue')): \n
Expand All @@ -88,9 +103,23 @@ def hettmansperger_norton_test(data, group, alternative = "increasing", trend =
one sided p-value (float)

References:
Hettmansperger, T. P., & Norton, R. M. (1987). Tests for patterned alternatives in k-sample problems. Journal of the American Statistical Association, 82(397), 292-299.
Hettmansperger, T. P., & Norton, R. M. (1987). Tests for patterned alternatives in
k-sample problems. Journal of the American Statistical Association, 82(397), 292-299.
"""

# Check inputs
data = as_list(data, "data")
group = as_list(group, "group")
if (not isinstance(alternative, str)) or (alternative not in ['increasing', 'decreasing', 'custom']):
raise TypeError("alternative must be either 'increasing', 'decreasing' or 'custom'")
a_groups = len(np.unique(group))
if alternative == "custom":
if trend is None:
raise TypeError("trend must be array-like when alternative is 'custom'")
trend = as_list(trend, "trend")
if len(trend) != a_groups:
raise ValueError("trend must have one entry per group")

d = {'data': ps.psrank(data, group, ties_method = "average"), 'grp': group}
df = pd.DataFrame(data=d)
df['grp'] = df['grp'].astype(pd.api.types.CategoricalDtype(categories=np.unique(df['grp'].tolist()), ordered=True))
Expand All @@ -105,15 +134,15 @@ def hettmansperger_norton_test(data, group, alternative = "increasing", trend =
N = len(df['data'])
a = len(np.unique(df['codes']))
n = [0 for x in range(a)]
p_hat = [0 for x in range(a)]
p_hat = [0.0 for x in range(a)]

for i in range(0, a):
tmp = df[df['codes'] == i]
n[i] = len(tmp)
p_hat[i] = 1/N*(np.mean(tmp['data']) - 0.5)
p_hat[i] = 1/N*(float(np.mean(tmp['data'])) - 0.5)

# define weight function for hypothesis testing
w = np.arange(1, (a + 1))
w: Any = np.arange(1, (a + 1))

if alternative == "decreasing":
w = w[::-1]
Expand All @@ -129,19 +158,14 @@ def hettmansperger_norton_test(data, group, alternative = "increasing", trend =
W = np.dot( mS, ( mI - np.dot( 1/N*mJ, mS ) ) )

# variance estimator
sum_psranks_squared = 0
for i in range(N):
sum_psranks_squared += (df['data'].tolist()[i] - (N + 1)/2) ** 2
sum_psranks_squared = float(np.sum((df['data'].to_numpy() - (N + 1)/2) ** 2))
sum_psranks_squared = (1/N **2)*1/(N-1)*sum_psranks_squared

sigma_part = np.dot(W, w)
sigma_hat_squared = N*sum_psranks_squared*np.dot(np.dot( sigma_part.transpose(), np.linalg.inv(mS) ), sigma_part )

# test statistic
test_hettmansperger = math.sqrt(N)*np.dot(sigma_part.transpose(), p_hat)*1/math.sqrt(sigma_hat_squared)
p_value = 1 - scipy.stats.norm.cdf(test_hettmansperger)

result = namedtuple('HettmanspergerNortonResult', ('alternative', 'weight', 'statistic', 'pvalue'))
output = result(alternative, w, test_hettmansperger, p_value)
test_hettmansperger = float(math.sqrt(N)*np.dot(sigma_part.transpose(), p_hat)*1/math.sqrt(sigma_hat_squared))
p_value = float(1 - scipy.stats.norm.cdf(test_hettmansperger))

return output
return HettmanspergerNortonResult(alternative, w, test_hettmansperger, p_value)
20 changes: 11 additions & 9 deletions PyNonpar/pseudorank.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@

"""

import pandas as pd
import numpy as np

import numba as nu
import numpy as np
import pandas as pd
from numpy.typing import ArrayLike

from PyNonpar._types import TiesMethod, as_list


@nu.jit(nopython=True)
Expand Down Expand Up @@ -128,24 +132,22 @@ def _psrank_min(data: list, group: list, N: int, n: list, a: int) -> list:
return lpsrank


def psrank(data, group, ties_method = "average"):
def psrank(data: ArrayLike, group: ArrayLike, ties_method: TiesMethod = "average") -> list[float]:
"""
Function to calculate pseudo-ranks.

Args:
data (list(float)): values to be ranked \n
group (list(int)): group factor \n
data (array_like): values to be ranked \n
group (array_like): group factor \n
ties_method (str): either 'average', 'max' or 'min' for mid, max or min pseudo-ranks \n

Returns:
pseudo-ranks (list(float))
"""

# Check inputs
if not isinstance(data, list):
raise TypeError("data must be a list")
if not isinstance(group, list):
raise TypeError("group must be a list")
data = as_list(data, "data")
group = as_list(group, "group")
if (not isinstance(ties_method, str)) or (ties_method not in ['average', 'min', 'max']):
raise TypeError("ties_method must be either 'average', 'min' or 'max'")

Expand Down
File renamed without changes.
Loading