46#include <QtConcurrent>
47#include <QRandomGenerator>
66 const QVector<MatrixXd>& dataA,
67 const QVector<MatrixXd>& dataB,
68 const SparseMatrix<int>& adjacency,
76 const int nA = dataA.size();
79 MatrixXd tObs = computeTMap(dataA, dataB);
84 double threshold = inverseTCdf(tail ==
StatsTailType::Both ? clusterAlpha : 2.0 * clusterAlpha, df);
87 auto [clusterIds, clusterStats] = findClusters(tObs, threshold, adjacency, tail);
90 QVector<MatrixXd> allData;
91 allData.reserve(nA + dataB.size());
92 for (
const auto& m : dataA)
94 for (
const auto& m : dataB)
98 QVector<double> nullDist(nPermutations);
101 QVector<int> permIndices(nPermutations);
102 std::iota(permIndices.begin(), permIndices.end(), 0);
104 std::function<double(
int)> permuteFunc = [&](int) ->
double {
105 return permuteOnce(allData, nA, adjacency, threshold, tail);
108 QFuture<double> future = QtConcurrent::mapped(permIndices, permuteFunc);
109 future.waitForFinished();
111 for (
int i = 0; i < nPermutations; ++i) {
112 nullDist[i] = future.resultAt(i);
116 std::sort(nullDist.begin(), nullDist.end());
119 QVector<double> clusterPvals(clusterStats.size());
120 for (
int c = 0; c < clusterStats.size(); ++c) {
121 double obsStat = std::fabs(clusterStats[c]);
123 for (
int p = 0; p < nPermutations; ++p) {
124 if (nullDist[p] >= obsStat) {
128 clusterPvals[c] =
static_cast<double>(count + 1) /
static_cast<double>(nPermutations + 1);
142MatrixXd StatsCluster::computeTMap(
const QVector<MatrixXd>& dataA,
const QVector<MatrixXd>& dataB)
144 const int nSubjects = dataA.size();
145 const int nChannels =
static_cast<int>(dataA[0].rows());
146 const int nTimes =
static_cast<int>(dataA[0].cols());
150 MatrixXd sumDiff = MatrixXd::Zero(nChannels, nTimes);
151 MatrixXd sumDiffSq = MatrixXd::Zero(nChannels, nTimes);
153 for (
int s = 0; s < nSubjects; ++s) {
154 MatrixXd diff = dataA[s] - dataB[s];
156 sumDiffSq += diff.cwiseProduct(diff);
159 double n =
static_cast<double>(nSubjects);
160 MatrixXd mean = sumDiff / n;
161 MatrixXd variance = (sumDiffSq - sumDiff.cwiseProduct(sumDiff) / n) / (n - 1.0);
164 variance = variance.cwiseMax(1.0e-30);
166 MatrixXd tMap = mean.array() / (variance.array().sqrt() / std::sqrt(n));
172QPair<MatrixXi, QVector<double>> StatsCluster::findClusters(
173 const MatrixXd& tMap,
175 const SparseMatrix<int>& adjacency,
178 const int nChannels =
static_cast<int>(tMap.rows());
179 const int nTimes =
static_cast<int>(tMap.cols());
181 MatrixXi clusterIds = MatrixXi::Zero(nChannels, nTimes);
182 QVector<double> clusterStats;
183 int currentClusterId = 0;
186 auto bfsClusters = [&](
bool positive) {
188 MatrixXi visited = MatrixXi::Zero(nChannels, nTimes);
190 for (
int ch = 0; ch < nChannels; ++ch) {
191 for (
int t = 0; t < nTimes; ++t) {
192 double val = tMap(ch, t);
193 bool suprathreshold = positive ? (val > threshold) : (val < -threshold);
195 if (!suprathreshold || visited(ch, t))
200 double clusterSum = 0.0;
201 std::queue<std::pair<int, int>> queue;
205 while (!queue.empty()) {
206 auto [curCh, curT] = queue.front();
209 clusterIds(curCh, curT) = positive ? currentClusterId : -currentClusterId;
210 clusterSum += tMap(curCh, curT);
213 for (
int dt = -1; dt <= 1; dt += 2) {
215 if (nt < 0 || nt >= nTimes)
217 double nval = tMap(curCh, nt);
218 bool nSupra = positive ? (nval > threshold) : (nval < -threshold);
219 if (nSupra && !visited(curCh, nt)) {
220 visited(curCh, nt) = 1;
221 queue.push({curCh, nt});
226 for (SparseMatrix<int>::InnerIterator it(adjacency, curCh); it; ++it) {
227 int nCh =
static_cast<int>(it.row());
228 double nval = tMap(nCh, curT);
229 bool nSupra = positive ? (nval > threshold) : (nval < -threshold);
230 if (nSupra && !visited(nCh, curT)) {
231 visited(nCh, curT) = 1;
232 queue.push({nCh, curT});
237 clusterStats.append(clusterSum);
249 return {clusterIds, clusterStats};
254double StatsCluster::permuteOnce(
255 const QVector<MatrixXd>& allData,
257 const SparseMatrix<int>& adjacency,
261 const int nTotal = allData.size();
264 std::vector<int> indices(nTotal);
265 std::iota(indices.begin(), indices.end(), 0);
268 QRandomGenerator rng(QRandomGenerator::global()->generate());
269 for (
int i = nTotal - 1; i > 0; --i) {
270 int j =
static_cast<int>(rng.bounded(i + 1));
271 std::swap(indices[i], indices[j]);
275 QVector<MatrixXd> permA, permB;
277 permB.reserve(nTotal - nA);
278 for (
int i = 0; i < nTotal; ++i) {
280 permA.append(allData[indices[i]]);
282 permB.append(allData[indices[i]]);
287 MatrixXd tMap = computeTMap(permA, permB);
288 auto [clusterIds, clusterStats] = findClusters(tMap, threshold, adjacency, tail);
291 double maxStat = 0.0;
292 for (
double s : clusterStats) {
293 double absS = std::fabs(s);
294 if (absS > maxStat) {
303double StatsCluster::inverseTCdf(
double p,
int df)
310 double targetCdf = 1.0 - p / 2.0;
315 for (
int iter = 0; iter < 100; ++iter) {
316 double mid = (lo + hi) / 2.0;
318 if (cdf < targetCdf) {
325 return (lo + hi) / 2.0;
330MatrixXd StatsCluster::computeOneSampleTMap(
const QVector<MatrixXd>& data)
332 const int nSubjects = data.size();
333 const int nVertices =
static_cast<int>(data[0].rows());
334 const int nTimes =
static_cast<int>(data[0].cols());
336 MatrixXd sum = MatrixXd::Zero(nVertices, nTimes);
337 MatrixXd sumSq = MatrixXd::Zero(nVertices, nTimes);
339 for (
int s = 0; s < nSubjects; ++s) {
341 sumSq += data[s].cwiseProduct(data[s]);
344 double n =
static_cast<double>(nSubjects);
345 MatrixXd mean = sum / n;
346 MatrixXd variance = (sumSq - sum.cwiseProduct(sum) / n) / (n - 1.0);
347 variance = variance.cwiseMax(1.0e-30);
349 MatrixXd tMap = mean.array() / (variance.array().sqrt() / std::sqrt(n));
355MatrixXd StatsCluster::computeFMap(
const QVector<QVector<MatrixXd>>& conditions)
357 const int nConditions = conditions.size();
358 const int nVertices =
static_cast<int>(conditions[0][0].rows());
359 const int nTimes =
static_cast<int>(conditions[0][0].cols());
363 for (
int c = 0; c < nConditions; ++c) {
364 nTotal += conditions[c].size();
368 MatrixXd grandSum = MatrixXd::Zero(nVertices, nTimes);
369 for (
int c = 0; c < nConditions; ++c) {
370 for (
int s = 0; s < conditions[c].size(); ++s) {
371 grandSum += conditions[c][s];
374 MatrixXd grandMean = grandSum /
static_cast<double>(nTotal);
377 MatrixXd ssBetween = MatrixXd::Zero(nVertices, nTimes);
378 MatrixXd ssWithin = MatrixXd::Zero(nVertices, nTimes);
380 for (
int c = 0; c < nConditions; ++c) {
381 int nc = conditions[c].size();
382 MatrixXd condSum = MatrixXd::Zero(nVertices, nTimes);
383 for (
int s = 0; s < nc; ++s) {
384 condSum += conditions[c][s];
386 MatrixXd condMean = condSum /
static_cast<double>(nc);
388 MatrixXd diff = condMean - grandMean;
389 ssBetween +=
static_cast<double>(nc) * diff.cwiseProduct(diff);
391 for (
int s = 0; s < nc; ++s) {
392 MatrixXd residual = conditions[c][s] - condMean;
393 ssWithin += residual.cwiseProduct(residual);
397 int dfBetween = nConditions - 1;
398 int dfWithin = nTotal - nConditions;
400 MatrixXd msBetween = ssBetween /
static_cast<double>(dfBetween);
401 MatrixXd msWithin = ssWithin /
static_cast<double>(dfWithin);
402 msWithin = msWithin.cwiseMax(1.0e-30);
404 MatrixXd fMap = msBetween.array() / msWithin.array();
410QPair<MatrixXi, QVector<double>> StatsCluster::findClustersFlat(
411 const MatrixXd& statMap,
413 const SparseMatrix<int>& adjacency,
416 const int nVertices =
static_cast<int>(statMap.rows());
417 const int nTimes =
static_cast<int>(statMap.cols());
418 const int nTotal = nVertices * nTimes;
420 MatrixXi clusterIds = MatrixXi::Zero(nVertices, nTimes);
421 QVector<double> clusterStats;
422 int currentClusterId = 0;
425 std::vector<bool> visited(nTotal,
false);
427 for (
int idx = 0; idx < nTotal; ++idx) {
428 int v = idx / nTimes;
429 int t = idx % nTimes;
430 double val = statMap(v, t);
432 bool suprathreshold = positiveOnly ? (val > threshold) : (val < -threshold);
433 if (!suprathreshold || visited[idx])
437 double clusterSum = 0.0;
438 std::queue<int> queue;
442 while (!queue.empty()) {
443 int curIdx = queue.front();
446 int curV = curIdx / nTimes;
447 int curT = curIdx % nTimes;
449 clusterIds(curV, curT) = positiveOnly ? currentClusterId : -currentClusterId;
450 clusterSum += statMap(curV, curT);
453 for (SparseMatrix<int>::InnerIterator it(adjacency, curIdx); it; ++it) {
454 int nIdx = static_cast<int>(it.row());
457 int nV = nIdx / nTimes;
458 int nT = nIdx % nTimes;
459 double nval = statMap(nV, nT);
460 bool nSupra = positiveOnly ? (nval > threshold) : (nval < -threshold);
462 visited[nIdx] = true;
468 clusterStats.append(clusterSum);
471 return {clusterIds, clusterStats};
476double StatsCluster::permuteOnceOneSample(
477 const QVector<MatrixXd>& data,
478 const SparseMatrix<int>& adjacency,
482 const int nSubjects = data.size();
485 QRandomGenerator rng(QRandomGenerator::global()->generate());
486 QVector<int> signs(nSubjects);
487 for (
int s = 0; s < nSubjects; ++s) {
488 signs[s] = rng.bounded(2) == 0 ? 1 : -1;
492 QVector<MatrixXd> flipped(nSubjects);
493 for (
int s = 0; s < nSubjects; ++s) {
494 flipped[s] = data[s] *
static_cast<double>(signs[s]);
498 MatrixXd tMap = computeOneSampleTMap(flipped);
500 double maxStat = 0.0;
503 auto [ids, stats] = findClustersFlat(tMap, threshold, adjacency,
true);
504 for (
double s : stats) {
505 if (std::fabs(s) > maxStat)
506 maxStat = std::fabs(s);
510 auto [ids, stats] = findClustersFlat(tMap, threshold, adjacency,
false);
511 for (
double s : stats) {
512 if (std::fabs(s) > maxStat)
513 maxStat = std::fabs(s);
522double StatsCluster::permuteOnceFTest(
523 const QVector<MatrixXd>& allData,
524 const QVector<int>& groupSizes,
525 const SparseMatrix<int>& adjacency,
528 const int nTotal = allData.size();
531 std::vector<int> indices(nTotal);
532 std::iota(indices.begin(), indices.end(), 0);
534 QRandomGenerator rng(QRandomGenerator::global()->generate());
535 for (
int i = nTotal - 1; i > 0; --i) {
536 int j =
static_cast<int>(rng.bounded(i + 1));
537 std::swap(indices[i], indices[j]);
541 QVector<QVector<MatrixXd>> permConditions;
543 for (
int c = 0; c < groupSizes.size(); ++c) {
544 QVector<MatrixXd> group;
545 group.reserve(groupSizes[c]);
546 for (
int s = 0; s < groupSizes[c]; ++s) {
547 group.append(allData[indices[offset + s]]);
549 permConditions.append(group);
550 offset += groupSizes[c];
554 MatrixXd fMap = computeFMap(permConditions);
555 auto [ids, stats] = findClustersFlat(fMap, threshold, adjacency,
true);
557 double maxStat = 0.0;
558 for (
double s : stats) {
568 const QVector<MatrixXd>& data,
569 const SparseMatrix<int>& adjacency,
575 MatrixXd tObs = computeOneSampleTMap(data);
578 MatrixXi clusterIdsPos, clusterIdsNeg;
579 QVector<double> clusterStats;
582 auto [ids, stats] = findClustersFlat(tObs, threshold, adjacency,
true);
584 clusterStats.append(stats);
587 auto [ids, stats] = findClustersFlat(tObs, threshold, adjacency,
false);
589 clusterStats.append(stats);
593 MatrixXi clusterIds = MatrixXi::Zero(tObs.rows(), tObs.cols());
594 if (clusterIdsPos.size() > 0)
595 clusterIds += clusterIdsPos;
596 if (clusterIdsNeg.size() > 0)
597 clusterIds += clusterIdsNeg;
600 QVector<int> permIndices(nPermutations);
601 std::iota(permIndices.begin(), permIndices.end(), 0);
603 std::function<double(
int)> permuteFunc = [&](int) ->
double {
604 return permuteOnceOneSample(data, adjacency, threshold, tail);
607 QFuture<double> future = QtConcurrent::mapped(permIndices, permuteFunc);
608 future.waitForFinished();
610 QVector<double> nullDist(nPermutations);
611 for (
int i = 0; i < nPermutations; ++i) {
612 nullDist[i] = future.resultAt(i);
614 std::sort(nullDist.begin(), nullDist.end());
617 QVector<double> clusterPvals(clusterStats.size());
618 for (
int c = 0; c < clusterStats.size(); ++c) {
619 double obsStat = std::fabs(clusterStats[c]);
621 for (
int p = 0; p < nPermutations; ++p) {
622 if (nullDist[p] >= obsStat) {
626 clusterPvals[c] =
static_cast<double>(count + 1) /
static_cast<double>(nPermutations + 1);
641 const QVector<QVector<MatrixXd>>& conditions,
642 const SparseMatrix<int>& adjacency,
647 MatrixXd fObs = computeFMap(conditions);
650 auto [clusterIds, clusterStats] = findClustersFlat(fObs, threshold, adjacency,
true);
653 QVector<MatrixXd> allData;
654 QVector<int> groupSizes;
655 for (
int c = 0; c < conditions.size(); ++c) {
656 groupSizes.append(conditions[c].size());
657 for (
int s = 0; s < conditions[c].size(); ++s) {
658 allData.append(conditions[c][s]);
663 QVector<int> permIndices(nPermutations);
664 std::iota(permIndices.begin(), permIndices.end(), 0);
666 std::function<double(
int)> permuteFunc = [&](int) ->
double {
667 return permuteOnceFTest(allData, groupSizes, adjacency, threshold);
670 QFuture<double> future = QtConcurrent::mapped(permIndices, permuteFunc);
671 future.waitForFinished();
673 QVector<double> nullDist(nPermutations);
674 for (
int i = 0; i < nPermutations; ++i) {
675 nullDist[i] = future.resultAt(i);
677 std::sort(nullDist.begin(), nullDist.end());
680 QVector<double> clusterPvals(clusterStats.size());
681 for (
int c = 0; c < clusterStats.size(); ++c) {
682 double obsStat = clusterStats[c];
684 for (
int p = 0; p < nPermutations; ++p) {
685 if (nullDist[p] >= obsStat) {
689 clusterPvals[c] =
static_cast<double>(count + 1) /
static_cast<double>(nPermutations + 1);
704 const MatrixXd& statMap,
705 const SparseMatrix<int>& adjacency,
710 const int nVertices =
static_cast<int>(statMap.rows());
711 const int nTimes =
static_cast<int>(statMap.cols());
712 const int nTotal = nVertices * nTimes;
714 MatrixXd tfceMap = MatrixXd::Zero(nVertices, nTimes);
717 const double dh = statMap.cwiseAbs().maxCoeff() /
static_cast<double>(nSteps + 1);
722 auto runTfce = [&](
const MatrixXd& absMap,
bool isPositive) {
723 for (
int step = 1; step <= nSteps; ++step) {
724 double h = dh *
static_cast<double>(step);
727 std::vector<bool> visited(nTotal,
false);
729 for (
int idx = 0; idx < nTotal; ++idx) {
730 int v = idx / nTimes;
731 int t = idx % nTimes;
733 if (absMap(v, t) <= h || visited[idx])
737 std::vector<int> cluster;
738 std::queue<int> queue;
742 while (!queue.empty()) {
743 int curIdx = queue.front();
745 cluster.push_back(curIdx);
747 for (SparseMatrix<int>::InnerIterator it(adjacency, curIdx); it; ++it) {
748 int nIdx =
static_cast<int>(it.row());
751 int nV = nIdx / nTimes;
752 int nT = nIdx % nTimes;
753 if (absMap(nV, nT) > h) {
754 visited[nIdx] =
true;
761 double extent =
static_cast<double>(cluster.size());
762 double contribution = std::pow(extent, E) * std::pow(h, H) * dh;
764 for (
int cIdx : cluster) {
765 int cv = cIdx / nTimes;
766 int ct = cIdx % nTimes;
768 tfceMap(cv, ct) += contribution;
770 tfceMap(cv, ct) -= contribution;
778 MatrixXd posMap = statMap.cwiseMax(0.0);
779 runTfce(posMap,
true);
782 MatrixXd negMap = (-statMap).cwiseMax(0.0);
783 runTfce(negMap,
false);
Maris-Oostenveld cluster-mass permutation tests and Threshold-Free Cluster Enhancement for M/EEG infe...
Frequentist Student's t-tests with exact p-values via the regularised incomplete beta function.
One-way ANOVA F-test with exact p-values for comparing two or more independent groups.
Statistical testing (t-tests, F-tests, cluster permutation, multiple comparison correction).
StatsTailType
Direction of the alternative hypothesis for a t- or F-test (left, right, or two-sided).
Per-call output of a cluster permutation test: observed statistic map, cluster masses,...
Eigen::MatrixXi matClusterIds
QVector< double > vecClusterStats
QVector< double > vecClusterPvals
static StatsClusterResult permutationTest(const QVector< Eigen::MatrixXd > &dataA, const QVector< Eigen::MatrixXd > &dataB, const Eigen::SparseMatrix< int > &adjacency, int nPermutations=1024, double clusterAlpha=0.05, double pThreshold=0.05, StatsTailType tail=StatsTailType::Both)
static Eigen::MatrixXd tfce(const Eigen::MatrixXd &statMap, const Eigen::SparseMatrix< int > &adjacency, double E=0.5, double H=2.0, int nSteps=100)
static StatsClusterResult oneSamplePermutationTest(const QVector< Eigen::MatrixXd > &data, const Eigen::SparseMatrix< int > &adjacency, double threshold, int nPermutations, StatsTailType tail)
static StatsClusterResult fTestPermutationTest(const QVector< QVector< Eigen::MatrixXd > > &conditions, const Eigen::SparseMatrix< int > &adjacency, double threshold, int nPermutations)
static double tCdf(double t, int df)