2025, Nov 10 00:01
Корреляция Спирмена со столбцами матрицы: spearmanr, NumPy и обработка связок
Почему spearmanr даёт квадратную матрицу и как получить постолбцовую корреляцию Спирмена быстро: срез нужного столбца, ранги в NumPy, точный учёт связок.
Когда нужно посчитать корреляцию Спирмена между опорным вектором и каждым столбцом матрицы, очевидный цикл работает, но медленно. Попытка «векторизовать» расчёт, подав в scipy.stats.spearmanr целые массивы, часто неожиданно возвращает большую квадратную матрицу. Разберёмся, почему так происходит и как получить именно постолбцовые корреляции эффективно — включая вариант на чистом NumPy, который избегает лишних попарных вычислений.
Постановка задачи
Пусть у вас есть матрица данных с 37 наблюдениями и N столбцами (образцами), а также одномерный опорный вектор из тех же 37 наблюдений. Цель — вычислить корреляцию Спирмена между каждым столбцом и опорным вектором, получив результат формы (N, 1).
Ниже — простой вариант, который даёт правильный ответ:
from scipy.stats import spearmanr
import numpy as np
prng = np.random.default_rng()
X = prng.standard_normal((37, 100))
ref_vec = prng.standard_normal((37, 1))
corr_list = []
for col in X.T:
corr_list.append(spearmanr(col, ref_vec).statistic)
result = np.array(corr_list)
print(result.shape) # (100,)
Он делает именно то, что нужно, но обрабатывает данные по одному столбцу. Попытка вызвать spearmanr на целых массивах поначалу выглядит многообещающе:
from scipy.stats import spearmanr
import numpy as np
prng = np.random.default_rng()
X = prng.standard_normal((37, 100))
ref_vec = prng.standard_normal((37, 1))
pairwise = spearmanr(X, ref_vec)
print(pairwise.statistic.shape) # (101, 101)
Вместо вектора (N, 1) вы получаете матрицу (N+1, N+1). Это не ошибка — так устроен API для двумерных входов.
Почему размерность «раздувается» у spearmanr на 2D-входах
В 1D-случае spearmanr(x, y) возвращает одно число — корреляцию. В 2D-случае spearmanr(A) воспринимает каждый столбец A как отдельную переменную и вычисляет полную попарную матрицу корреляций между столбцами. Передача двух двумерных массивов эквивалентна конкатенации их столбцов с последующим вычислением попарных корреляций между всеми получившимися столбцами. Поэтому при подаче X со 100 столбцами и ref_vec с 1 столбцом получается матрица 101×101: считаются все корреляции между 101 переменной.
Иными словами, вы просили всего N корреляций, а посчитали все N×N внутри X, плюс всё, что связано с опорным столбцом, и ещё симметричные элементы. На деле вам нужны только корреляции между каждым столбцом X и опорным вектором.
Векторизованный результат без лишних циклов
Поскольку опорный столбец в концептуальной конкатенации идёт последним, нужную вам колонку можно просто извлечь из большой матрицы. Пусть n — число столбцов X. Искомые постолбцовые корреляции Спирмена находятся в последнем столбце, ограниченном первыми n строками.
from scipy.stats import spearmanr
import numpy as np
prng = np.random.default_rng()
X = prng.standard_normal((37, 100))
ref_vec = prng.standard_normal((37, 1))
n = X.shape[1]
full_corr = spearmanr(X, ref_vec).statistic
colwise_corr = full_corr[:n, n] # форма: (n,)
Так вы избегаете ручных Python-циклов, и для умеренных N это быстро, хотя при этом вычисляется гораздо больше корреляций, чем в итоге используется. В размерах выше посчитать полную матрицу 101×101 и вырезать последний столбец быстрее, чем идти столбец за столбцом. Но учтите: при очень больших N цена полной попарной матрицы становится избыточной.
Можно подумать о np.vectorize ради «красивого» интерфейса, но ускорения это не даст: по скорости это близко к обычному циклу.
from scipy.stats import spearmanr
import numpy as np
prng = np.random.default_rng()
X = prng.standard_normal((37, 100))
ref_vec = prng.standard_normal((37, 1))
def one_corr(u, v):
return spearmanr(u, v).statistic
vec_apply = np.vectorize(one_corr, signature='(m),(m,1)->()')
colwise_corr = vec_apply(X.T, ref_vec) # по скорости сопоставимо с циклом
Чистый NumPy: Спирмен через ранги (без совпадающих значений)
Корреляция Спирмена — это корреляция Пирсона, посчитанная по рангам. Если данные непрерывные и связки (ties) не ожидаются, ранги можно быстро получить «двойным argsort», а затем использовать закрытую форму для среднего и дисперсии рангов. Такой подход избегает большой попарной матрицы и векторизован по всем столбцам.
import numpy as np
def spearman_fast_noties(A, b):
# A: (m, n), b: (m, 1)
m = b.shape[0]
mean_r = (m - 1) / 2
var_r = (m - 1) * (m + 1) / 12
rA = A.argsort(axis=0).argsort(axis=0) # ранги 0..m-1
rb = b.argsort(axis=0).argsort(axis=0) # ранги 0..m-1
# Пирсон по рангам с известными средним и дисперсией
return ((rA * rb).mean(axis=0) - mean_r**2) / var_r
# Пример
prng = np.random.default_rng()
X = prng.standard_normal((37, 100))
ref_vec = prng.standard_normal((37, 1))
colwise_corr = spearman_fast_noties(X, ref_vec)
Этот вариант и векторизован, и не выполняет лишних попарных вычислений. В показанных размерах он заметно быстрее цикла: вычисление рангов через argsort дешёвое при малом m, и всё выполняется внутри NumPy.
Корректная обработка связок (повторов)
Если связки возможны и их нужно учесть точно, используйте scipy.stats.rankdata для вычисления рангов со обработкой связок. В этом случае нельзя опираться на закрытую форму среднего и дисперсии рангов: вычислите их по ранжированным массивам и примените обычную формулу Пирсона к рангам.
import numpy as np
from scipy.stats import rankdata
def spearman_tie_safe(A, b):
# A: (m, n), b: (m, 1)
rA = rankdata(A, axis=0)
rb = rankdata(b, axis=0)
# Пирсон по рангам с явным центрированием и масштабированием
return ((rA * rb).mean(axis=0) - rA.mean(axis=0) * rb.mean()) / (rA.std(axis=0) * rb.std())
# Пример
prng = np.random.default_rng()
X = prng.standard_normal((37, 100))
ref_vec = prng.standard_normal((37, 1))
colwise_corr = spearman_tie_safe(X, ref_vec)
Этот подход векторизован и корректен при наличии связок. Для малых m rankdata может быть медленнее «двойного argsort», но гарантирует правильную обработку связок.
Почему это важно
Понимание того, как spearmanr интерпретирует 2D-входы, помогает избежать путаницы и лишних вычислений. Если вы подаёте несколько столбцов, spearmanr возвращает полную попарную матрицу корреляций по всем столбцам конкатенированных входов. Это бывает полезно, но когда нужны корреляции только с одним опорным вектором, это избыточно. Зная, где находится нужный столбец, его можно просто вырезать и тем самым избежать ручных циклов. Когда число наблюдений невелико, а число столбцов большое, работа напрямую с рангами может быть значительно быстрее и вычисляет только то, что действительно требуется.
Итоги
Если вызываете spearmanr на матрице и опорном векторе, берите нужный столбец из последнего столбца результата срезом — это эффективно. Если хотите вовсе избежать попарной матрицы, посчитайте ранги сами и примените Пирсона к рангам. Когда связки важны, используйте rankdata; когда нет — приём с двойным argsort даёт эффективную альтернативу. Избегайте np.vectorize ради производительности: он ведёт себя как обычный Python-цикл. С этими приёмами вы получаете предсказуемые формы, меньше сюрпризов и решение, которое масштабируется под вашу структуру данных.
В основе статьи — вопрос на StackOverflow от QuanticDisaster и ответ от chrslg.