# Copyright (c) 2024-2026 Li Chao, Dong Mengshi and HABIT Contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""L0 pure-math kernels for intraclass correlation coefficients (ICC).
The ICC quantifies how stable a measurement is across repeated observations
(test-retest scans, raters, sites). HABIT uses it to filter unstable imaging
features before modelling. The formulas are the classical Shrout & Fleiss /
McGraw & Wong two-way ANOVA definitions, numerically equivalent to
``pingouin.intraclass_corr`` for the single-measure ICC2/ICC3 rows, kept here
as pure functions so the exact definition is independently reviewable.
"""
from __future__ import annotations
from typing import Tuple
import numpy as np
__all__ = ["two_way_mean_squares", "icc3_1", "icc2_1"]
[docs]
def two_way_mean_squares(data: np.ndarray) -> Tuple[float, float, float, int, int]:
"""
Compute the row/column/error mean squares of a two-way layout.
Args:
data: Matrix of shape ``(n_targets, k_raters)`` with one observation
per cell; NaNs are not allowed.
Returns:
Tuple ``(ms_rows, ms_columns, ms_error, n, k)``.
Raises:
ValueError: If the matrix is not 2-D, has fewer than two rows or
columns, or contains NaN.
"""
data = np.asarray(data, dtype=np.float64)
if data.ndim != 2:
raise ValueError(f"ICC input must be a 2-D matrix; got ndim={data.ndim}.")
n, k = data.shape
if n < 2 or k < 2:
raise ValueError(
f"ICC requires at least 2 targets and 2 raters; got shape {data.shape}."
)
if np.isnan(data).any():
raise ValueError("ICC input must not contain NaN values.")
grand_mean = float(data.mean())
row_means = data.mean(axis=1)
col_means = data.mean(axis=0)
ms_rows = k * float(np.sum((row_means - grand_mean) ** 2)) / (n - 1)
ms_columns = n * float(np.sum((col_means - grand_mean) ** 2)) / (k - 1)
residuals = data - row_means[:, None] - col_means[None, :] + grand_mean
ms_error = float(np.sum(residuals**2)) / ((n - 1) * (k - 1))
return ms_rows, ms_columns, ms_error, n, k
[docs]
def icc3_1(data: np.ndarray) -> float:
"""
Compute ICC(3,1): two-way mixed model, consistency, single measurement.
This is the ICC variant HABIT uses by default for test-retest feature
stability (the v0.1 ``icc3`` configuration default, pingouin's ``ICC3``
row; McGraw & Wong notation ``ICC(C,1)``):
``(MS_R - MS_E) / (MS_R + (k - 1) * MS_E)``.
Args:
data: Matrix of shape ``(n_targets, k_raters)``.
Returns:
The ICC(3,1) value (can be negative when between-target variance is
below the error variance).
"""
ms_rows, _, ms_error, _, k = two_way_mean_squares(data)
denominator = ms_rows + (k - 1) * ms_error
if denominator == 0.0:
# Degenerate (constant) measurement: no variance to attribute.
return 0.0
return float((ms_rows - ms_error) / denominator)
[docs]
def icc2_1(data: np.ndarray) -> float:
"""
Compute ICC(2,1): two-way random model, absolute agreement, single
measurement (pingouin's ``ICC2`` row; McGraw & Wong ``ICC(A,1)``):
``(MS_R - MS_E) / (MS_R + (k - 1) * MS_E + k * (MS_C - MS_E) / n)``.
Args:
data: Matrix of shape ``(n_targets, k_raters)``.
Returns:
The ICC(2,1) value.
"""
ms_rows, ms_columns, ms_error, n, k = two_way_mean_squares(data)
denominator = ms_rows + (k - 1) * ms_error + k * (ms_columns - ms_error) / n
if denominator == 0.0:
return 0.0
return float((ms_rows - ms_error) / denominator)