Skip to content

Commit 8ee12a5

Browse files
authored
Merge pull request #1414 from su-senka/feature/chatterjee-mnn
Add M nearest-neighbour Chatterjee correlation (#990)
2 parents 600627d + 2709fb2 commit 8ee12a5

4 files changed

Lines changed: 412 additions & 7 deletions

File tree

doc/overview/roadmap.qbk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Recent issues on GitHub [@https://github.com/boostorg/math/issues?utf8=%E2%9C%93
1717
* Add improvement to Heuman Lambda precision.
1818
* Improve Skew Normal root finding, see [@https://github.com/boostorg/math/issues/1120 1120].
1919
* Lots of minor fixes and improved code coverage.
20+
* Add support for the M nearest-neighbour Chatterjee correlation of Lin and Han (2021), see [@https://github.com/boostorg/math/issues/990 990].
2021

2122
[h4 Math-4.2.0 (Boost-1.85)]
2223

doc/statistics/chatterjee_correlation.qbk

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,83 @@ Of course, random numbers are not used internally, but the result is not guarant
7272

7373
[endsect]
7474
[/section:chatterjee_correlation Chatterjee Correlation]
75+
76+
[section:chatterjee_correlation_mnn Chatterjee Correlation (M nearest neighbours)]
77+
78+
[heading Synopsis]
79+
80+
``
81+
#include <boost/math/statistics/chatterjee_correlation.hpp>
82+
83+
namespace boost::math::statistics {
84+
85+
C++17:
86+
template <typename ExecutionPolicy, typename Container>
87+
auto chatterjee_correlation_mnn(ExecutionPolicy&& exec, const Container& u, const Container& v, std::size_t M);
88+
89+
C++11:
90+
template <typename Container>
91+
auto chatterjee_correlation_mnn(const Container& u, const Container& v, std::size_t M);
92+
}
93+
``
94+
95+
[heading Description]
96+
97+
`chatterjee_correlation_mnn` computes the revised Chatterjee rank correlation of Lin and Han (2021), which
98+
generalises Chatterjee's coefficient by incorporating the `M` right nearest neighbours of each point rather
99+
than only the single right neighbour.
100+
The statistic still consistently estimates the same measure of dependence (between 0 and 1; zero if and only
101+
if X and Y are independent, unity if and only if Y is a measurable function of X), but its use of additional
102+
neighbours boosts the power of the associated independence test.
103+
104+
The original coefficient `chatterjee_correlation` has a statistical detection boundary of n[super -1/4] for
105+
testing independence, which is substantially weaker than the parametric n[super -1/2] rate.
106+
By letting `M` grow with the sample size (with M/n -> 0), the revised statistic can approach near-parametric
107+
efficiency.
108+
109+
Let X and Y be random variables, where Y is not constant, and let (X_i, Y_i) be samples sorted so that
110+
X_(0) < X_(1) < ... < X_(n-1). Writing R_i for the rank of Y_i and j_m(i) for the index of the m-th right
111+
nearest neighbour of X_i, the statistic is
112+
113+
``
114+
xi_{n,M} = -2 + 6 * sum_i sum_{m=1}^{M} min(R_i, R_{j_m(i)}) / ((n + 1) * (n*M + M*(M + 1) / 4))
115+
``
116+
117+
The complexity is O(n log n + n M). For `M` of order O(1) or O(poly-log n) this is nearly linear; as `M`
118+
approaches `n` it tends to O(n[super 2]).
119+
120+
An example is given below:
121+
122+
std::vector<double> X{1,2,3,4,5};
123+
std::vector<double> Y{1,2,3,4,5};
124+
using boost::math::statistics::chatterjee_correlation_mnn;
125+
std::size_t M = 2;
126+
double coeff = chatterjee_correlation_mnn(X, Y, M);
127+
128+
/Nota bene:/ If the input is an integer type the output will be a double precision type.
129+
130+
[heading Choice of M]
131+
132+
The asymptotic null variance of the statistic is minimised when `M` is of order sqrt(n), which is a reasonable
133+
default for users who want improved power without the quadratic cost of large `M`. Pushing `M` closer to `n`
134+
increases the power of the independence test against smooth alternatives at the cost of additional computation.
135+
The choice is left to the caller; `M` must satisfy 1 <= M <= n.
136+
137+
/Nota bene:/ Even at `M` = 1 this statistic is not identical to `chatterjee_correlation`: it uses
138+
min(R_i, R_j) in place of |R_i - R_j| and a different normalisation, so the two agree only up to a term of
139+
order 1/n. Use `chatterjee_correlation` when the original coefficient is required.
140+
141+
[heading Invariants]
142+
143+
The function expects at least two samples, a non-constant vector Y, the same number of X's as Y's, and
144+
1 <= M <= n.
145+
If Y is constant, the result is a quiet NaN.
146+
The data set must be sorted by X values.
147+
If there are ties in the values of X, then the statistic is random due to the random breaking of ties.
148+
149+
[heading References]
150+
151+
* Lin, Zhexiao, and Fang Han. "On boosting the power of Chatterjee's rank correlation." Biometrika 110.2 (2023): 283-299. [@https://arxiv.org/abs/2108.06828 arXiv:2108.06828].
152+
153+
[endsect]
154+
[/section:chatterjee_correlation_mnn Chatterjee Correlation (M nearest neighbours)]

include/boost/math/statistics/chatterjee_correlation.hpp

Lines changed: 173 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// (C) Copyright Matt Borland 2022.
2+
// (C) Copyright Oleksandr Kornijcuk 2026.
23
// Use, modification and distribution are subject to the
34
// Boost Software License, Version 1.0. (See accompanying file
45
// LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
@@ -52,7 +53,7 @@ template <typename ReturnType, typename ForwardIterator>
5253
ReturnType chatterjee_correlation_seq_impl(ForwardIterator u_begin, ForwardIterator u_end, ForwardIterator v_begin, ForwardIterator v_end)
5354
{
5455
using std::abs;
55-
56+
5657
BOOST_MATH_ASSERT_MSG(std::is_sorted(u_begin, u_end), "The x values must be sorted in order to use this functionality");
5758

5859
const std::vector<std::size_t> rank_vector = rank(v_begin, v_end);
@@ -70,15 +71,107 @@ ReturnType chatterjee_correlation_seq_impl(ForwardIterator u_begin, ForwardItera
7071
return result;
7172
}
7273

74+
// Generalised (M right-nearest-neighbour) Chatterjee correlation of Lin and Han (2021),
75+
// "On boosting the power of Chatterjee's rank correlation", https://arxiv.org/abs/2108.06828.
76+
//
77+
// For a sample sorted by X, the inner sum of equation (2.4) is
78+
//
79+
// S = sum_{i} sum_{m=1}^{M} min(R_i, R_{j_m(i)}),
80+
//
81+
// where R_i is the (1-based) rank of Y_i and j_m(i) is the m-th right nearest neighbour of X_i.
82+
// After sorting by X the m-th right neighbour of element i is element i + m when it exists
83+
// (i + m < n); otherwise j_m(i) = i (the sentinel of the paper), contributing min(R_i, R_i) = R_i.
84+
//
85+
// Note: rank() returns 0-based ranks, whereas the paper's formula uses 1-based ranks. The +1 offset
86+
// cancels in the M = 1 statistic above (which uses |R_{i+1} - R_i|) but does NOT cancel under
87+
// min(.,.), so it is applied explicitly here.
88+
inline std::uint64_t chatterjee_mnn_min_sum(const std::vector<std::size_t>& rank_vector,
89+
std::size_t lo, std::size_t hi, std::size_t M)
90+
{
91+
const std::size_t n = rank_vector.size();
92+
std::uint64_t sum = 0;
93+
94+
for (std::size_t i = lo; i < hi; ++i)
95+
{
96+
const std::size_t r_i = rank_vector[i] + 1;
97+
98+
// Number of right neighbours of i that actually exist within [0, n).
99+
const std::size_t existing = (i + M < n) ? M : (n - 1 - i);
100+
101+
for (std::size_t m = 1; m <= existing; ++m)
102+
{
103+
const std::size_t r_j = rank_vector[i + m] + 1;
104+
sum += static_cast<std::uint64_t>((std::min)(r_i, r_j));
105+
}
106+
107+
// The remaining (M - existing) neighbours are sentinels j_m(i) = i, each contributing r_i.
108+
sum += static_cast<std::uint64_t>(M - existing) * static_cast<std::uint64_t>(r_i);
109+
}
110+
111+
return sum;
112+
}
113+
114+
template <typename ReturnType>
115+
ReturnType chatterjee_mnn_result(std::uint64_t sum, std::size_t n, std::size_t M)
116+
{
117+
const ReturnType n_r = static_cast<ReturnType>(n);
118+
const ReturnType m_r = static_cast<ReturnType>(M);
119+
120+
// Denominator (n + 1) * [nM + M(M + 1) / 4] of equation (2.4); chosen so that E[xi_{n,M}] = 0 under H0.
121+
const ReturnType denominator = (n_r + static_cast<ReturnType>(1)) *
122+
(n_r * m_r + m_r * (m_r + static_cast<ReturnType>(1)) / static_cast<ReturnType>(4));
123+
124+
return static_cast<ReturnType>(-2) + static_cast<ReturnType>(6) * static_cast<ReturnType>(sum) / denominator;
125+
}
126+
127+
template <typename ReturnType, typename ForwardIterator>
128+
ReturnType chatterjee_correlation_mnn_seq_impl(ForwardIterator u_begin, ForwardIterator u_end,
129+
ForwardIterator v_begin, ForwardIterator v_end, std::size_t M)
130+
{
131+
BOOST_MATH_ASSERT_MSG(std::is_sorted(u_begin, u_end), "The x values must be sorted in order to use this functionality");
132+
133+
const std::size_t n = static_cast<std::size_t>(std::distance(v_begin, v_end));
134+
135+
BOOST_MATH_ASSERT_MSG(M >= 1, "The number of right nearest neighbours M must be at least 1");
136+
BOOST_MATH_ASSERT_MSG(M <= n, "The number of right nearest neighbours M must not exceed the number of samples");
137+
138+
// If Y is constant the statistic is undefined (Y must be non-constant); return a quiet NaN.
139+
// This is checked on the input directly: rank() collapses tied values, so a constant Y would
140+
// otherwise reduce the effective sample size to one.
141+
if (v_begin != v_end)
142+
{
143+
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
144+
const value_type first_value = *v_begin;
145+
if (std::all_of(std::next(v_begin), v_end,
146+
[&first_value](const value_type& value) { return value == first_value; }))
147+
{
148+
return std::numeric_limits<ReturnType>::quiet_NaN();
149+
}
150+
}
151+
152+
const std::vector<std::size_t> rank_vector = rank(v_begin, v_end);
153+
154+
const std::uint64_t sum = chatterjee_mnn_min_sum(rank_vector, 0, n, M);
155+
156+
return chatterjee_mnn_result<ReturnType>(sum, n, M);
157+
}
158+
73159
} // Namespace detail
74160

75-
template <typename Container, typename Real = typename Container::value_type,
161+
template <typename Container, typename Real = typename Container::value_type,
76162
typename ReturnType = typename std::conditional<std::is_integral<Real>::value, double, Real>::type>
77163
inline ReturnType chatterjee_correlation(const Container& u, const Container& v)
78164
{
79165
return detail::chatterjee_correlation_seq_impl<ReturnType>(std::begin(u), std::end(u), std::begin(v), std::end(v));
80166
}
81167

168+
template <typename Container, typename Real = typename Container::value_type,
169+
typename ReturnType = typename std::conditional<std::is_integral<Real>::value, double, Real>::type>
170+
inline ReturnType chatterjee_correlation_mnn(const Container& u, const Container& v, std::size_t M)
171+
{
172+
return detail::chatterjee_correlation_mnn_seq_impl<ReturnType>(std::begin(u), std::end(u), std::begin(v), std::end(v), M);
173+
}
174+
82175
}}} // Namespace boost::math::statistics
83176

84177
#ifdef BOOST_MATH_EXEC_COMPATIBLE
@@ -121,7 +214,7 @@ ReturnType chatterjee_correlation_par_impl(ExecutionPolicy&& exec, ForwardIterat
121214
{
122215
sum += future_manager[i].get();
123216
}
124-
217+
125218
ReturnType result = static_cast<ReturnType>(1) - (static_cast<ReturnType>(3 * sum) / static_cast<ReturnType>(rank_vector.size() * rank_vector.size() - 1));
126219

127220
// If the result is 1 then Y is constant and all the elements must be ties
@@ -133,6 +226,66 @@ ReturnType chatterjee_correlation_par_impl(ExecutionPolicy&& exec, ForwardIterat
133226
return result;
134227
}
135228

229+
template <typename ReturnType, typename ExecutionPolicy, typename ForwardIterator>
230+
ReturnType chatterjee_correlation_mnn_par_impl(ExecutionPolicy&& exec, ForwardIterator u_begin, ForwardIterator u_end,
231+
ForwardIterator v_begin, ForwardIterator v_end, std::size_t M)
232+
{
233+
BOOST_MATH_ASSERT_MSG(std::is_sorted(std::forward<ExecutionPolicy>(exec), u_begin, u_end), "The x values must be sorted in order to use this functionality");
234+
235+
const std::size_t n = static_cast<std::size_t>(std::distance(v_begin, v_end));
236+
237+
BOOST_MATH_ASSERT_MSG(M >= 1, "The number of right nearest neighbours M must be at least 1");
238+
BOOST_MATH_ASSERT_MSG(M <= n, "The number of right nearest neighbours M must not exceed the number of samples");
239+
240+
// If Y is constant the statistic is undefined (Y must be non-constant); return a quiet NaN.
241+
// This is checked on the input directly: rank() collapses tied values, so a constant Y would
242+
// otherwise reduce the effective sample size to one.
243+
if (v_begin != v_end)
244+
{
245+
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
246+
const value_type first_value = *v_begin;
247+
if (std::all_of(std::next(v_begin), v_end,
248+
[&first_value](const value_type& value) { return value == first_value; }))
249+
{
250+
return std::numeric_limits<ReturnType>::quiet_NaN();
251+
}
252+
}
253+
254+
const auto rank_vector = rank(std::forward<ExecutionPolicy>(exec), v_begin, v_end);
255+
256+
const auto num_threads = std::thread::hardware_concurrency() == 0 ? 2u : std::thread::hardware_concurrency();
257+
258+
// The i-loop is embarrassingly parallel: each task sums over a disjoint contiguous range of
259+
// indices [lo, hi) and reads (read-only) rank_vector entries up to i + M, which may fall in a
260+
// neighbouring task's range. Because there are no writes, the partition needs no overlap; this
261+
// differs from the M = 1 parallel path above, which splits the data array (and overlaps by one
262+
// element) for the difference-based transform.
263+
std::vector<std::future<std::uint64_t>> future_manager {};
264+
future_manager.reserve(num_threads);
265+
266+
const std::size_t chunk = static_cast<std::size_t>(std::ceil(static_cast<double>(n) / num_threads));
267+
268+
std::size_t lo = 0;
269+
while (lo < n)
270+
{
271+
const std::size_t hi = (std::min)(lo + chunk, n);
272+
future_manager.emplace_back(std::async(std::launch::async | std::launch::deferred,
273+
[&rank_vector, lo, hi, M]() -> std::uint64_t
274+
{
275+
return chatterjee_mnn_min_sum(rank_vector, lo, hi, M);
276+
}));
277+
lo = hi;
278+
}
279+
280+
std::uint64_t sum = 0;
281+
for (std::size_t i {}; i < future_manager.size(); ++i)
282+
{
283+
sum += future_manager[i].get();
284+
}
285+
286+
return chatterjee_mnn_result<ReturnType>(sum, n, M);
287+
}
288+
136289
} // Namespace detail
137290

138291
template <typename ExecutionPolicy, typename Container, typename Real = typename Container::value_type,
@@ -152,6 +305,23 @@ inline ReturnType chatterjee_correlation(ExecutionPolicy&& exec, const Container
152305
}
153306
}
154307

308+
template <typename ExecutionPolicy, typename Container, typename Real = typename Container::value_type,
309+
typename ReturnType = std::conditional_t<std::is_integral_v<Real>, double, Real>>
310+
inline ReturnType chatterjee_correlation_mnn(ExecutionPolicy&& exec, const Container& u, const Container& v, std::size_t M)
311+
{
312+
if constexpr (std::is_same_v<std::remove_reference_t<decltype(exec)>, decltype(std::execution::seq)>)
313+
{
314+
return detail::chatterjee_correlation_mnn_seq_impl<ReturnType>(std::cbegin(u), std::cend(u),
315+
std::cbegin(v), std::cend(v), M);
316+
}
317+
else
318+
{
319+
return detail::chatterjee_correlation_mnn_par_impl<ReturnType>(std::forward<ExecutionPolicy>(exec),
320+
std::cbegin(u), std::cend(u),
321+
std::cbegin(v), std::cend(v), M);
322+
}
323+
}
324+
155325
} // Namespace boost::math::statistics
156326

157327
#endif

0 commit comments

Comments
 (0)