| 1 |
#' @useDynLib libqe, .registration = TRUE |
|
| 2 |
#' @importFrom Rcpp evalCpp |
|
| 3 |
NULL |
|
| 4 | ||
| 5 |
.onLoad <- function(libname, pkgname) {
|
|
| 6 |
# Nothing to initialise: libqe is a header + Rcpp library. |
|
| 7 | ! |
invisible(NULL) |
| 8 |
} |
| 1 |
/** |
|
| 2 |
* @file adjacency.hpp |
|
| 3 |
* @brief Combinatorics and vector/matrix utilities for ENA connection networks. |
|
| 4 |
* |
|
| 5 |
* Pure C++ / Armadillo — no Rcpp dependency. |
|
| 6 |
* Include @c <RcppArmadillo.h> before this header when building inside an R package. |
|
| 7 |
*/ |
|
| 8 |
#ifndef LIBQE_ADJACENCY_HPP |
|
| 9 |
#define LIBQE_ADJACENCY_HPP |
|
| 10 | ||
| 11 |
#include <armadillo> |
|
| 12 |
#include <string> |
|
| 13 |
#include <vector> |
|
| 14 |
#include <cmath> |
|
| 15 | ||
| 16 |
namespace qe {
|
|
| 17 | ||
| 18 |
// --------------------------------------------------------------------------- |
|
| 19 |
/// @name Combinatorics |
|
| 20 |
/// @{
|
|
| 21 |
// --------------------------------------------------------------------------- |
|
| 22 | ||
| 23 |
/** |
|
| 24 |
* @brief Number of unordered pairs from @p n items (n choose 2). |
|
| 25 |
* |
|
| 26 |
* @param n Number of codes/nodes. |
|
| 27 |
* @returns @c (n * (n - 1)) / 2. |
|
| 28 |
*/ |
|
| 29 | 15x |
inline int choose_two(int n) {
|
| 30 | 15x |
return (n * (n - 1)) / 2; |
| 31 |
} |
|
| 32 | ||
| 33 |
/** |
|
| 34 |
* @brief Number of undirected connection columns for @p n codes. |
|
| 35 |
* |
|
| 36 |
* @param n Number of codes/nodes. |
|
| 37 |
* @param diagonal Include self-connections (the diagonal, @c A & A). |
|
| 38 |
* @returns @c choose_two(n), or @c n(n+1)/2 with @p diagonal. |
|
| 39 |
*/ |
|
| 40 | 16x |
inline int n_connections(int n, bool diagonal = false) {
|
| 41 | 16x |
return diagonal ? (n * (n + 1)) / 2 : choose_two(n); |
| 42 |
} |
|
| 43 | ||
| 44 |
/** |
|
| 45 |
* @brief Upper-triangle index pairs for a symmetric matrix of side @p len. |
|
| 46 |
* |
|
| 47 |
* Column-major order: (0,1), (0,2), (1,2), ... -- or, with @p diagonal, |
|
| 48 |
* (0,0), (0,1), (1,1), (0,2), (1,2), (2,2), ... (Armadillo's |
|
| 49 |
* @c trimatu_ind order), so a self-connection's row and column index are |
|
| 50 |
* equal. |
|
| 51 |
* |
|
| 52 |
* @param len Side length of the square matrix (number of codes). |
|
| 53 |
* @param row Controls what is returned: |
|
| 54 |
* - @c -1 (default): 2 × k matrix of [row_idx; col_idx]. |
|
| 55 |
* - @c 0: row indices only (1 × k). |
|
| 56 |
* - @c 1: column indices only (1 × k). |
|
| 57 |
* @param diagonal Include the diagonal (self-connections). Default @c false. |
|
| 58 |
* @returns An @c arma::umat of shape 2×k or 1×k depending on @p row, with |
|
| 59 |
* @c k = n_connections(len, diagonal). |
|
| 60 |
* @note Equivalent to @c triIndices() in rENA/ena.cpp and tma/code.cpp |
|
| 61 |
* (without @p diagonal). |
|
| 62 |
*/ |
|
| 63 | 10x |
inline arma::umat connection_indices(int len, int row = -1, bool diagonal = false) {
|
| 64 | 10x |
int vS = n_connections(len, diagonal); |
| 65 | 10x |
int s = 0; |
| 66 | ||
| 67 | 10x |
arma::umat vR(2, vS, arma::fill::zeros); |
| 68 | 10x |
arma::umat vRone(1, vS, arma::fill::zeros); |
| 69 | ||
| 70 | 58x |
for (int i = 1; i <= len; i++) {
|
| 71 | 163x |
for (int j = 0; j < (diagonal ? i : i - 1); j++) {
|
| 72 | 115x |
vR(0, s) = j; |
| 73 | 115x |
vR(1, s) = i - 1; |
| 74 | 121x |
if (row == 0) vRone[s] = j; |
| 75 | 115x |
else if (row == 1) vRone[s] = i - 1; |
| 76 | 115x |
s++; |
| 77 |
} |
|
| 78 |
} |
|
| 79 | 20x |
return (row == -1) ? vR : vRone; |
| 80 |
} |
|
| 81 | ||
| 82 |
/// @} |
|
| 83 | ||
| 84 |
// --------------------------------------------------------------------------- |
|
| 85 |
/// @name Vector ↔ upper-triangle conversions |
|
| 86 |
/// @{
|
|
| 87 |
// --------------------------------------------------------------------------- |
|
| 88 | ||
| 89 |
/** |
|
| 90 |
* @brief Compute pairwise products and return as a flat upper-triangle vector. |
|
| 91 |
* |
|
| 92 |
* For each pair @c (j, i) with @c j < i, outputs @c v[j] * v[i]. |
|
| 93 |
* The result has length @c choose_two(v.size()). |
|
| 94 |
* |
|
| 95 |
* @param v A 1 × p row vector (or p-element matrix). |
|
| 96 |
* @returns A row vector of length @c choose_two(p). |
|
| 97 |
* @note Equivalent to @c vector_to_ut() in rENA/ena.cpp. |
|
| 98 |
*/ |
|
| 99 | 2x |
inline arma::rowvec code_connections(arma::mat v) {
|
| 100 | 2x |
int vL = v.size(); |
| 101 | 2x |
int vS = choose_two(vL); |
| 102 | 2x |
arma::rowvec out(vS, arma::fill::zeros); |
| 103 | 2x |
int s = 0; |
| 104 | 7x |
for (int i = 2; i <= vL; i++) {
|
| 105 | 14x |
for (int j = 0; j < i - 1; j++) {
|
| 106 | 27x |
out[s] = v[j] * v[i - 1]; |
| 107 | 9x |
s++; |
| 108 |
} |
|
| 109 |
} |
|
| 110 | 2x |
return out; |
| 111 |
} |
|
| 112 | ||
| 113 |
/** |
|
| 114 |
* @brief Fold a directed n² vector into an undirected upper-triangle vector. |
|
| 115 |
* |
|
| 116 |
* Symmetric elements are summed: A→B + B→A for every pair. With |
|
| 117 |
* @p diagonal the self-connections (A→A) are kept, each once, in the |
|
| 118 |
* @c connection_indices(n, -1, true) order (A & A, A & B, B & B, ...). |
|
| 119 |
* |
|
| 120 |
* @param v Flat directed vector of length n², column-major |
|
| 121 |
* (n = sqrt(v.size())). |
|
| 122 |
* @param diagonal Keep the diagonal. Default @c false. |
|
| 123 |
* @returns Upper-triangle row vector of length @c n_connections(n, diagonal). |
|
| 124 |
* @note Equivalent to @c vector_to_summed_uppertri() in tma/code.cpp |
|
| 125 |
* (without @p diagonal). |
|
| 126 |
*/ |
|
| 127 | 7x |
inline arma::rowvec fold_directed_network(arma::vec v, bool diagonal = false) {
|
| 128 | 7x |
int n = static_cast<int>(std::round(std::sqrt(static_cast<double>(v.size())))); |
| 129 | ||
| 130 | 7x |
arma::mat m(v); |
| 131 | 7x |
m.reshape(n, n); |
| 132 | 14x |
arma::mat folded = m + m.t(); |
| 133 | 9x |
if (diagonal) folded.diag() = m.diag(); // a self-connection is not a pair |
| 134 | ||
| 135 | 7x |
arma::colvec flat = folded.as_col(); |
| 136 | 7x |
arma::uvec up_inds = arma::trimatu_ind(arma::size(folded), diagonal ? 0 : 1); |
| 137 | 14x |
return arma::conv_to<arma::rowvec>::from(flat.elem(up_inds)); |
| 138 |
} |
|
| 139 | ||
| 140 |
/** |
|
| 141 |
* @brief Flatten an adjacency matrix to a vector. |
|
| 142 |
* |
|
| 143 |
* @param x Square adjacency matrix (n × n). |
|
| 144 |
* @param full If @c true (default), returns the full n² vector (directed). |
|
| 145 |
* If @c false, returns the upper-triangle only (undirected). |
|
| 146 |
* @param diagonal With @p full @c false: include the diagonal, in the |
|
| 147 |
* @c connection_indices(n, -1, true) order. Default @c false. |
|
| 148 |
* @returns Row vector of length n² or @c n_connections(n, diagonal). |
|
| 149 |
* @note Equivalent to @c adjacency_matrix_to_vector() in tma/code.cpp |
|
| 150 |
* (without @p diagonal). |
|
| 151 |
*/ |
|
| 152 | 3x |
inline arma::rowvec network_to_vector(arma::mat x, bool full = true, bool diagonal = false) {
|
| 153 | 4x |
if (full) return arma::vectorise(x).t(); |
| 154 | 2x |
if (diagonal) {
|
| 155 | 1x |
arma::uvec up_inds = arma::trimatu_ind(arma::size(x), 0); |
| 156 | 2x |
return arma::vectorise(x(up_inds)).t(); |
| 157 |
} |
|
| 158 | 3x |
arma::mat combined = arma::trimatu(x, 1) + arma::trimatl(x, -1); |
| 159 | 1x |
arma::uvec up_inds = arma::trimatu_ind(arma::size(combined), 1); |
| 160 | 2x |
return arma::vectorise(combined(up_inds)).t(); |
| 161 |
} |
|
| 162 | ||
| 163 |
/// @} |
|
| 164 | ||
| 165 |
// --------------------------------------------------------------------------- |
|
| 166 |
/// @name String utilities |
|
| 167 |
/// @{
|
|
| 168 |
// --------------------------------------------------------------------------- |
|
| 169 | ||
| 170 |
/** |
|
| 171 |
* @brief Generate "A & B" pair names for every upper-triangle position. |
|
| 172 |
* |
|
| 173 |
* For code names @c {"A", "B", "C"} returns @c {"A & B", "A & C", "B & C"};
|
|
| 174 |
* with @p diagonal, @c {"A & A", "A & B", "B & B", "A & C", "B & C", "C & C"}
|
|
| 175 |
* (the @c connection_indices(p, -1, true) order). |
|
| 176 |
* |
|
| 177 |
* @param v Ordered list of code names (length p). |
|
| 178 |
* @param diagonal Include self-connections. Default @c false. |
|
| 179 |
* @returns Vector of @c n_connections(p, diagonal) label strings. |
|
| 180 |
* @note Equivalent to @c svector_to_ut() in rENA/ena.cpp (without @p diagonal). |
|
| 181 |
*/ |
|
| 182 | 4x |
inline std::vector<std::string> connection_names(std::vector<std::string> v, |
| 183 |
bool diagonal = false) {
|
|
| 184 | 4x |
int vL = v.size(); |
| 185 | 4x |
int vS = n_connections(vL, diagonal); |
| 186 | 4x |
std::vector<std::string> out(vS); |
| 187 | 4x |
int s = 0; |
| 188 | 17x |
for (int i = 1; i <= vL; i++) {
|
| 189 | 31x |
for (int j = 0; j < (diagonal ? i : i - 1); j++) {
|
| 190 | 18x |
out[s] = v[j] + " & " + v[i - 1]; |
| 191 | 18x |
s++; |
| 192 |
} |
|
| 193 |
} |
|
| 194 | 4x |
return out; |
| 195 |
} |
|
| 196 | ||
| 197 |
/// @} |
|
| 198 | ||
| 199 |
} // namespace qe |
|
| 200 | ||
| 201 |
#endif // LIBQE_ADJACENCY_HPP |
| 1 |
/** @file door.hpp |
|
| 2 |
* @brief Door temporal pooling and smoothing functions for trajectory modeling. |
|
| 3 |
* |
|
| 4 |
* Provides sliding lookback window pooling (with uniform or linear weighting, |
|
| 5 |
* sum or mean aggregation, and optional segment boundary resets) as well as |
|
| 6 |
* Exponential Moving Average (EMA) smoothing over consecutive time steps. |
|
| 7 |
*/ |
|
| 8 |
#ifndef LIBQE_DOOR_HPP |
|
| 9 |
#define LIBQE_DOOR_HPP |
|
| 10 | ||
| 11 |
#include <armadillo> |
|
| 12 |
#include <vector> |
|
| 13 |
#include <string> |
|
| 14 |
#include <cmath> |
|
| 15 |
#include <algorithm> |
|
| 16 | ||
| 17 |
namespace qe {
|
|
| 18 | ||
| 19 |
/** @brief Apply a sliding lookback window pool over a single unit's connection matrix. |
|
| 20 |
* |
|
| 21 |
* For each row @c i, pools rows in the window [@c max(0, i - lookback_size + 1), @c i]. |
|
| 22 |
* If @p segment_ids is non-empty, the window does not cross rows with a different segment ID. |
|
| 23 |
* |
|
| 24 |
* @param[in] block Matrix of connection counts for one unit (n_rows × n_features). |
|
| 25 |
* @param[in] lookback_size Number of rows in the lookback window (>= 1). |
|
| 26 |
* @param[in] aggregate_mean When @c true, averages rows; when @c false, sums rows. |
|
| 27 |
* @param[in] weighting_linear When @c true, applies linearly increasing weights |
|
| 28 |
* (oldest row in window has weight 1, current row has weight k). |
|
| 29 |
* When @c false, all rows in window have equal weight 1. |
|
| 30 |
* @param[in] segment_ids Optional vector of segment/activity IDs (length n_rows). |
|
| 31 |
* Window pooling resets at segment transitions. |
|
| 32 |
* |
|
| 33 |
* @returns A matrix of shape n_rows × n_features containing door-smoothed values. |
|
| 34 |
*/ |
|
| 35 | 5x |
inline arma::mat lookback_block( |
| 36 |
const arma::mat& block, |
|
| 37 |
int lookback_size = 20, |
|
| 38 |
bool aggregate_mean = false, |
|
| 39 |
bool weighting_linear = false, |
|
| 40 |
const std::vector<int>& segment_ids = {}
|
|
| 41 |
) {
|
|
| 42 | 5x |
int n_rows = static_cast<int>(block.n_rows); |
| 43 | 5x |
int n_cols = static_cast<int>(block.n_cols); |
| 44 | 5x |
arma::mat out(n_rows, n_cols, arma::fill::zeros); |
| 45 | ||
| 46 |
if (n_rows == 0 || n_cols == 0) return out; |
|
| 47 | 5x |
int k_max = std::max(1, lookback_size); |
| 48 | 5x |
bool has_segments = (!segment_ids.empty() && static_cast<int>(segment_ids.size()) >= n_rows); |
| 49 | ||
| 50 | 25x |
for (int i = 0; i < n_rows; ++i) {
|
| 51 | 20x |
int start_idx = std::max(0, i - k_max + 1); |
| 52 | ||
| 53 | 20x |
if (has_segments) {
|
| 54 | 4x |
int cur_seg = segment_ids[i]; |
| 55 | 10x |
for (int s = i; s >= start_idx; --s) {
|
| 56 | 8x |
if (segment_ids[s] != cur_seg) {
|
| 57 | 2x |
start_idx = s + 1; |
| 58 | 2x |
break; |
| 59 |
} |
|
| 60 |
} |
|
| 61 |
} |
|
| 62 | ||
| 63 | 20x |
int win_len = i - start_idx + 1; |
| 64 |
if (win_len <= 0) continue; |
|
| 65 | ||
| 66 | 20x |
arma::rowvec acc(n_cols, arma::fill::zeros); |
| 67 | 20x |
arma::rowvec denom(n_cols, arma::fill::zeros); |
| 68 | ||
| 69 | 56x |
for (int j = start_idx; j <= i; ++j) {
|
| 70 | 36x |
double w = 1.0; |
| 71 | 36x |
if (weighting_linear) {
|
| 72 |
// Linear weight: 1 for earliest in window, win_len for current row i |
|
| 73 | ! |
w = static_cast<double>(j - start_idx + 1); |
| 74 |
} |
|
| 75 | 108x |
for (int col = 0; col < n_cols; ++col) {
|
| 76 | 72x |
double value = block(j, col); |
| 77 | 72x |
if (!std::isnan(value)) {
|
| 78 | 66x |
acc[col] += w * value; |
| 79 | 132x |
denom[col] += w; |
| 80 |
} |
|
| 81 |
} |
|
| 82 |
} |
|
| 83 | ||
| 84 | 20x |
if (aggregate_mean) {
|
| 85 | 24x |
for (int col = 0; col < n_cols; ++col) {
|
| 86 |
acc[col] = (denom[col] > 0.0) ? (acc[col] / denom[col]) : arma::datum::nan; |
|
| 87 |
} |
|
| 88 |
} |
|
| 89 | ||
| 90 | 40x |
out.row(i) = acc; |
| 91 |
} |
|
| 92 | ||
| 93 | 5x |
return out; |
| 94 |
} |
|
| 95 | ||
| 96 |
/** @brief Apply Exponential Moving Average (EMA) smoothing over a single unit's connection matrix. |
|
| 97 |
* |
|
| 98 |
* Computes S_0 = Y_0, S_t = alpha * Y_t + (1 - alpha) * S_{t-1}.
|
|
| 99 |
* If @p segment_ids is non-empty, S_t resets to Y_t when transitioning to a new segment. |
|
| 100 |
* |
|
| 101 |
* @param[in] block Matrix of connection counts for one unit (n_rows × n_features). |
|
| 102 |
* @param[in] alpha Smoothing parameter alpha in (0, 1]. |
|
| 103 |
* @param[in] segment_ids Optional vector of segment/activity IDs (length n_rows). |
|
| 104 |
* |
|
| 105 |
* @returns A matrix of shape n_rows × n_features containing EMA-smoothed values. |
|
| 106 |
*/ |
|
| 107 | 2x |
inline arma::mat ema_block( |
| 108 |
const arma::mat& block, |
|
| 109 |
double alpha = 0.1, |
|
| 110 |
const std::vector<int>& segment_ids = {}
|
|
| 111 |
) {
|
|
| 112 | 2x |
int n_rows = static_cast<int>(block.n_rows); |
| 113 | 2x |
int n_cols = static_cast<int>(block.n_cols); |
| 114 | 2x |
arma::mat out(n_rows, n_cols, arma::fill::zeros); |
| 115 | ||
| 116 |
if (n_rows == 0 || n_cols == 0) return out; |
|
| 117 | 2x |
double a = std::max(1e-6, std::min(1.0, alpha)); |
| 118 |
bool has_segments = (!segment_ids.empty() && static_cast<int>(segment_ids.size()) >= n_rows); |
|
| 119 | ||
| 120 | 2x |
out.row(0) = block.row(0); |
| 121 | ||
| 122 | 6x |
for (int i = 1; i < n_rows; ++i) {
|
| 123 |
bool reset = has_segments && (segment_ids[i] != segment_ids[i - 1]); |
|
| 124 | 4x |
if (reset) {
|
| 125 | ! |
out.row(i) = block.row(i); |
| 126 |
} else {
|
|
| 127 | 12x |
for (int col = 0; col < n_cols; ++col) {
|
| 128 | 8x |
double cur = block(i, col); |
| 129 | 8x |
if (std::isnan(cur)) {
|
| 130 | 4x |
cur = out(i - 1, col); |
| 131 |
} |
|
| 132 | 24x |
out(i, col) = a * cur + (1.0 - a) * out(i - 1, col); |
| 133 |
} |
|
| 134 |
} |
|
| 135 |
} |
|
| 136 | ||
| 137 | 2x |
return out; |
| 138 |
} |
|
| 139 | ||
| 140 |
/** @brief Apply lookback door pooling to an entire connection matrix across multiple units. |
|
| 141 |
* |
|
| 142 |
* @param[in] conn_counts Complete connection matrix (all units). |
|
| 143 |
* @param[in] unit_row_indices Vector of row-index vectors, one per unit (0-indexed). |
|
| 144 |
* @param[in] lookback_sizes Vector of lookback sizes, one per unit. |
|
| 145 |
* @param[in] aggregate_mean When @c true, compute mean; when @c false, compute sum. |
|
| 146 |
* @param[in] weighting_linear When @c true, use linear weighting. |
|
| 147 |
* @param[in] segment_ids Optional vector of segment IDs for every row in conn_counts. |
|
| 148 |
* |
|
| 149 |
* @returns Smoothed connection matrix with identical dimensions to @p conn_counts. |
|
| 150 |
*/ |
|
| 151 | ! |
inline arma::mat apply_door_lookback( |
| 152 |
const arma::mat& conn_counts, |
|
| 153 |
const std::vector<std::vector<int>>& unit_row_indices, |
|
| 154 |
const std::vector<int>& lookback_sizes, |
|
| 155 |
bool aggregate_mean = false, |
|
| 156 |
bool weighting_linear = false, |
|
| 157 |
const std::vector<int>& segment_ids = {}
|
|
| 158 |
) {
|
|
| 159 | ! |
arma::mat out = conn_counts; |
| 160 | ! |
size_t n_units = unit_row_indices.size(); |
| 161 | ! |
bool has_segments = (!segment_ids.empty() && segment_ids.size() >= conn_counts.n_rows); |
| 162 | ||
| 163 | ! |
for (size_t u = 0; u < n_units; ++u) {
|
| 164 | ! |
const auto& rows = unit_row_indices[u]; |
| 165 | ! |
if (rows.empty()) continue; |
| 166 | ||
| 167 | ! |
int k = (u < lookback_sizes.size()) ? lookback_sizes[u] : 20; |
| 168 | ! |
int n_u = static_cast<int>(rows.size()); |
| 169 | ||
| 170 | ! |
arma::mat block(n_u, conn_counts.n_cols); |
| 171 | ! |
std::vector<int> block_segs(n_u, 0); |
| 172 | ||
| 173 | ! |
for (int i = 0; i < n_u; ++i) {
|
| 174 | ! |
int r = rows[i]; |
| 175 | ! |
block.row(i) = conn_counts.row(r); |
| 176 | ! |
if (has_segments) block_segs[i] = segment_ids[r]; |
| 177 |
} |
|
| 178 | ||
| 179 |
arma::mat smoothed = lookback_block(block, k, aggregate_mean, weighting_linear, |
|
| 180 | ! |
has_segments ? block_segs : std::vector<int>{});
|
| 181 | ||
| 182 | ! |
for (int i = 0; i < n_u; ++i) {
|
| 183 | ! |
out.row(rows[i]) = smoothed.row(i); |
| 184 |
} |
|
| 185 |
} |
|
| 186 | ||
| 187 | ! |
return out; |
| 188 |
} |
|
| 189 | ||
| 190 |
/** @brief Apply EMA door smoothing to an entire connection matrix across multiple units. |
|
| 191 |
*/ |
|
| 192 | ! |
inline arma::mat apply_door_ema( |
| 193 |
const arma::mat& conn_counts, |
|
| 194 |
const std::vector<std::vector<int>>& unit_row_indices, |
|
| 195 |
double alpha = 0.1, |
|
| 196 |
const std::vector<int>& segment_ids = {}
|
|
| 197 |
) {
|
|
| 198 | ! |
arma::mat out = conn_counts; |
| 199 | ! |
size_t n_units = unit_row_indices.size(); |
| 200 | ! |
bool has_segments = (!segment_ids.empty() && segment_ids.size() >= conn_counts.n_rows); |
| 201 | ||
| 202 | ! |
for (size_t u = 0; u < n_units; ++u) {
|
| 203 | ! |
const auto& rows = unit_row_indices[u]; |
| 204 | ! |
if (rows.empty()) continue; |
| 205 | ||
| 206 | ! |
int n_u = static_cast<int>(rows.size()); |
| 207 | ! |
arma::mat block(n_u, conn_counts.n_cols); |
| 208 | ! |
std::vector<int> block_segs(n_u, 0); |
| 209 | ||
| 210 | ! |
for (int i = 0; i < n_u; ++i) {
|
| 211 | ! |
int r = rows[i]; |
| 212 | ! |
block.row(i) = conn_counts.row(r); |
| 213 | ! |
if (has_segments) block_segs[i] = segment_ids[r]; |
| 214 |
} |
|
| 215 | ||
| 216 | ! |
arma::mat smoothed = ema_block(block, alpha, has_segments ? block_segs : std::vector<int>{});
|
| 217 | ||
| 218 | ! |
for (int i = 0; i < n_u; ++i) {
|
| 219 | ! |
out.row(rows[i]) = smoothed.row(i); |
| 220 |
} |
|
| 221 |
} |
|
| 222 | ||
| 223 | ! |
return out; |
| 224 |
} |
|
| 225 | ||
| 226 |
} // namespace qe |
|
| 227 | ||
| 228 |
#endif // LIBQE_DOOR_HPP |
| 1 |
/** @file linalg_fallback.hpp |
|
| 2 |
* @brief Thin LAPACK-free implementations of SVD, QR, and symmetric |
|
| 3 |
* eigendecomposition for use in WASM / no-LAPACK builds. |
|
| 4 |
* |
|
| 5 |
* When @c ARMA_USE_LAPACK is defined these wrappers delegate directly to the |
|
| 6 |
* standard Armadillo routines (@c arma::svd, @c arma::qr, @c arma::eig_sym), |
|
| 7 |
* so there is zero overhead in normal builds. When LAPACK is not available |
|
| 8 |
* (e.g. Emscripten WASM builds) the wrappers fall back to pure C++ |
|
| 9 |
* implementations that work for the small matrices typical in ENA |
|
| 10 |
* (p ≤ ~100 connection dimensions). |
|
| 11 |
* |
|
| 12 |
* All routines live in namespace @c qe::linalg and operate on @c arma::mat |
|
| 13 |
* (double precision). |
|
| 14 |
* |
|
| 15 |
* Fallback algorithms: |
|
| 16 |
* - **SVD** — one-sided Jacobi iterations on \f$A^T\f$ (Demmel & Veselić 1992) |
|
| 17 |
* - **QR** — modified Gram-Schmidt |
|
| 18 |
* - **eig_sym** — cyclic Jacobi (classical Jacobi sweep) |
|
| 19 |
*/ |
|
| 20 | ||
| 21 |
#ifndef LIBQE_LINALG_FALLBACK_HPP |
|
| 22 |
#define LIBQE_LINALG_FALLBACK_HPP |
|
| 23 | ||
| 24 |
#include <armadillo> |
|
| 25 |
#include <cmath> |
|
| 26 |
#include <limits> |
|
| 27 | ||
| 28 |
namespace qe {
|
|
| 29 |
namespace linalg {
|
|
| 30 | ||
| 31 |
/// @cond INTERNAL |
|
| 32 |
// ── helpers ─────────────────────────────────────────────────────────────────── |
|
| 33 | ||
| 34 |
namespace detail {
|
|
| 35 | ||
| 36 |
/// @brief Modified Gram-Schmidt orthonormalization applied in-place. |
|
| 37 |
/// |
|
| 38 |
/// Columns of @p Q are successively orthonormalized against all previously |
|
| 39 |
/// processed columns. A column whose norm falls below 1e-14 after projection |
|
| 40 |
/// is left unchanged (not zeroed) to avoid introducing NaNs. |
|
| 41 |
/// |
|
| 42 |
/// @param Q Matrix whose columns are orthonormalized in-place (modified). |
|
| 43 |
inline void mgs(arma::mat& Q) {
|
|
| 44 |
const arma::uword p = Q.n_cols; |
|
| 45 |
for (arma::uword j = 0; j < p; ++j) {
|
|
| 46 |
for (arma::uword i = 0; i < j; ++i) |
|
| 47 |
Q.col(j) -= arma::dot(Q.col(i), Q.col(j)) * Q.col(i); |
|
| 48 |
double n = arma::norm(Q.col(j)); |
|
| 49 |
if (n > 1e-14) Q.col(j) /= n; |
|
| 50 |
} |
|
| 51 |
} |
|
| 52 | ||
| 53 |
/// @brief One cyclic Jacobi sweep on a p×p symmetric matrix. |
|
| 54 |
/// |
|
| 55 |
/// Iterates over all super-diagonal index pairs @c (q,r) and applies a |
|
| 56 |
/// two-sided Jacobi rotation that annihilates @c A(q,r). The same rotation |
|
| 57 |
/// is accumulated in @p V so that, starting with @c V = I, the full sequence |
|
| 58 |
/// of sweeps yields @c V = eigenvector matrix. |
|
| 59 |
/// |
|
| 60 |
/// @param A Symmetric p×p matrix, updated in-place toward diagonal form. |
|
| 61 |
/// @param V Rotation accumulator (p×p); initialize to identity to recover |
|
| 62 |
/// eigenvectors. |
|
| 63 |
/// |
|
| 64 |
/// @note Algorithm follows the classical cyclic-by-rows Jacobi scheme. |
|
| 65 |
/// Off-diagonal entries no larger than 1e-14 × (|a_qq| + |a_rr|) are |
|
| 66 |
/// skipped for numerical stability. The `<=` (not `<`) guard correctly |
|
| 67 |
/// handles the all-zero case (a_qq = a_rr = a_qr = 0) that would |
|
| 68 |
/// otherwise produce NaN via 0/0 in the theta computation. |
|
| 69 |
inline void jacobi_sweep(arma::mat& A, arma::mat& V) {
|
|
| 70 |
const arma::uword p = A.n_rows; |
|
| 71 |
for (arma::uword q = 0; q < p - 1; ++q) {
|
|
| 72 |
for (arma::uword r = q + 1; r < p; ++r) {
|
|
| 73 |
double aqq = A(q, q), arr = A(r, r), aqr = A(q, r); |
|
| 74 |
if (std::abs(aqr) <= 1e-14 * (std::abs(aqq) + std::abs(arr))) |
|
| 75 |
continue; |
|
| 76 |
double theta = (arr - aqq) / (2.0 * aqr); |
|
| 77 |
double t = (theta >= 0.0) |
|
| 78 |
? 1.0 / (theta + std::sqrt(1.0 + theta * theta)) |
|
| 79 |
: 1.0 / (theta - std::sqrt(1.0 + theta * theta)); |
|
| 80 |
double c = 1.0 / std::sqrt(1.0 + t * t); ///< cosine of Jacobi rotation angle |
|
| 81 |
double s = t * c; ///< sine of Jacobi rotation angle |
|
| 82 |
// Apply Jacobi rotation to A (symmetric update) |
|
| 83 |
double new_qq = aqq - t * aqr; |
|
| 84 |
double new_rr = arr + t * aqr; |
|
| 85 |
A(q, q) = new_qq; A(r, r) = new_rr; A(q, r) = A(r, q) = 0.0; |
|
| 86 |
for (arma::uword k = 0; k < p; ++k) {
|
|
| 87 |
if (k == q || k == r) continue; |
|
| 88 |
double akq = A(k, q), akr = A(k, r); |
|
| 89 |
A(k, q) = A(q, k) = c * akq - s * akr; |
|
| 90 |
A(k, r) = A(r, k) = s * akq + c * akr; |
|
| 91 |
} |
|
| 92 |
// Accumulate rotation in V |
|
| 93 |
for (arma::uword k = 0; k < p; ++k) {
|
|
| 94 |
double vkq = V(k, q), vkr = V(k, r); |
|
| 95 |
V(k, q) = c * vkq - s * vkr; |
|
| 96 |
V(k, r) = s * vkq + c * vkr; |
|
| 97 |
} |
|
| 98 |
} |
|
| 99 |
} |
|
| 100 |
} |
|
| 101 | ||
| 102 |
} // namespace detail |
|
| 103 | ||
| 104 |
/// @endcond |
|
| 105 | ||
| 106 |
/// @name Eigendecomposition |
|
| 107 |
/// @{
|
|
| 108 | ||
| 109 |
// ── eig_sym ─────────────────────────────────────────────────────────────────── |
|
| 110 | ||
| 111 |
/// @brief Eigendecomposition of a real symmetric matrix. |
|
| 112 |
/// |
|
| 113 |
/// Computes eigenvalues and eigenvectors of the symmetric p×p matrix @p S and |
|
| 114 |
/// returns them sorted in **ascending** eigenvalue order. |
|
| 115 |
/// |
|
| 116 |
/// @param[out] eigval p×1 vector of eigenvalues, ascending. |
|
| 117 |
/// @param[out] eigvec p×p matrix whose columns are the corresponding |
|
| 118 |
/// orthonormal eigenvectors. |
|
| 119 |
/// @param[in] S Real symmetric p×p input matrix (not modified). |
|
| 120 |
/// |
|
| 121 |
/// @warning With @c ARMA_USE_LAPACK defined this calls @c arma::eig_sym |
|
| 122 |
/// directly. Without LAPACK the fallback runs up to @c 50*p cyclic |
|
| 123 |
/// Jacobi sweeps; convergence is guaranteed for well-conditioned |
|
| 124 |
/// matrices but may be slow for p > 200. |
|
| 125 |
/// |
|
| 126 |
/// @note Fallback algorithm: classical cyclic-by-rows Jacobi iteration. |
|
| 127 |
/// Convergence is declared when the Frobenius norm of the strictly |
|
| 128 |
/// off-diagonal part drops below 1e-13. |
|
| 129 | 1x |
inline void eig_sym(arma::vec& eigval, arma::mat& eigvec, const arma::mat& S) {
|
| 130 |
#if defined(ARMA_USE_LAPACK) |
|
| 131 | 1x |
arma::eig_sym(eigval, eigvec, S); |
| 132 |
#else |
|
| 133 |
const arma::uword p = S.n_rows; |
|
| 134 |
arma::mat A = S; ///< working copy of S |
|
| 135 |
eigvec.eye(p, p); ///< accumulate rotations; starts as identity |
|
| 136 | ||
| 137 |
for (int sweep = 0; sweep < 50 * static_cast<int>(p); ++sweep) {
|
|
| 138 |
// Check off-diagonal convergence |
|
| 139 |
double off = 0.0; |
|
| 140 |
for (arma::uword q = 0; q < p - 1; ++q) |
|
| 141 |
for (arma::uword r = q + 1; r < p; ++r) |
|
| 142 |
off += A(q, r) * A(q, r); |
|
| 143 |
if (std::sqrt(off) < 1e-13) break; |
|
| 144 |
detail::jacobi_sweep(A, eigvec); |
|
| 145 |
} |
|
| 146 |
eigval = A.diag(); |
|
| 147 | ||
| 148 |
// Sort ascending |
|
| 149 |
arma::uvec idx = arma::sort_index(eigval); |
|
| 150 |
eigval = eigval(idx); |
|
| 151 |
eigvec = eigvec.cols(idx); |
|
| 152 |
#endif |
|
| 153 |
} |
|
| 154 | ||
| 155 |
/// @} |
|
| 156 | ||
| 157 |
/// @name QR Factorization |
|
| 158 |
/// @{
|
|
| 159 | ||
| 160 |
// ── qr_full ─────────────────────────────────────────────────────────────────── |
|
| 161 | ||
| 162 |
/// @brief Full (square) QR factorization of an n×p matrix. |
|
| 163 |
/// |
|
| 164 |
/// Produces a square n×n orthogonal factor @p Q and an n×p upper-trapezoidal |
|
| 165 |
/// factor @p R such that @c A = Q*R. This matches the convention of |
|
| 166 |
/// @c arma::qr(), which returns the full (non-economy) @p Q. |
|
| 167 |
/// |
|
| 168 |
/// @param[out] Q n×n orthogonal matrix. |
|
| 169 |
/// @param[out] R n×p upper-trapezoidal matrix. |
|
| 170 |
/// @param[in] A n×p input matrix (not modified). |
|
| 171 |
/// |
|
| 172 |
/// @warning With @c ARMA_USE_LAPACK defined this calls @c arma::qr directly. |
|
| 173 |
/// Without LAPACK the fallback constructs the full ONB by seeding |
|
| 174 |
/// columns beyond @c A.n_cols with standard basis vectors and then |
|
| 175 |
/// applying modified Gram-Schmidt; numerical quality degrades for |
|
| 176 |
/// nearly rank-deficient inputs. |
|
| 177 |
/// |
|
| 178 |
/// @note The caller (@c orthogonal_svd) depends on @p Q being n×n so that |
|
| 179 |
/// @c Q.cols(k, n-1) spans the null space of @c A^T. |
|
| 180 |
inline void qr_full(arma::mat& Q, arma::mat& R, const arma::mat& A) {
|
|
| 181 |
#if defined(ARMA_USE_LAPACK) |
|
| 182 |
arma::qr(Q, R, A); |
|
| 183 |
#else |
|
| 184 |
// Build a full n×n orthogonal matrix. |
|
| 185 |
// Step 1: thin QR via modified Gram-Schmidt (gives first k=A.n_cols columns). |
|
| 186 |
// Step 2: extend to a full ONB using random vectors + re-orthogonalisation. |
|
| 187 |
const arma::uword n = A.n_rows, p = A.n_cols; |
|
| 188 | ||
| 189 |
// Start with all n columns of A padded with random vectors if n > p. |
|
| 190 |
arma::mat Qfull(n, n, arma::fill::zeros); |
|
| 191 |
// Copy A's columns first |
|
| 192 |
for (arma::uword j = 0; j < p && j < n; ++j) |
|
| 193 |
Qfull.col(j) = A.col(j); |
|
| 194 |
// Fill remaining columns with standard basis vectors (or random fallback) |
|
| 195 |
for (arma::uword j = p; j < n; ++j) {
|
|
| 196 |
Qfull(j, j) = 1.0; ///< standard basis vector e_j used as seed |
|
| 197 |
} |
|
| 198 |
// Full MGS orthonormalization |
|
| 199 |
detail::mgs(Qfull); |
|
| 200 |
Q = Qfull; |
|
| 201 | ||
| 202 |
// R is n×p (upper trapezoidal); only first p rows are non-zero |
|
| 203 |
R = Q.t() * A; |
|
| 204 |
// Zero the strictly lower-triangular part of R[0:p, 0:p] |
|
| 205 |
for (arma::uword j = 0; j < p; ++j) |
|
| 206 |
for (arma::uword i = j + 1; i < n; ++i) |
|
| 207 |
R(i, j) = 0.0; |
|
| 208 |
#endif |
|
| 209 |
} |
|
| 210 | ||
| 211 |
/// @} |
|
| 212 | ||
| 213 |
/// @name Singular Value Decomposition |
|
| 214 |
/// @{
|
|
| 215 | ||
| 216 |
// ── svd ─────────────────────────────────────────────────────────────────────── |
|
| 217 | ||
| 218 |
/// @brief Full SVD: @c A ≈ U * diag(s) * V', singular values descending. |
|
| 219 |
/// |
|
| 220 |
/// Decomposes the n×p matrix @p A into left singular vectors @p U, singular |
|
| 221 |
/// values @p s, and right singular vectors @p V. |
|
| 222 |
/// |
|
| 223 |
/// @param[out] U n×min(n,p) matrix of left singular vectors. |
|
| 224 |
/// @param[out] s min(n,p)×1 vector of singular values, descending. |
|
| 225 |
/// @param[out] V p×p matrix of right singular vectors (all p columns). |
|
| 226 |
/// @param[in] A n×p input matrix (not modified). |
|
| 227 |
/// |
|
| 228 |
/// @warning With @c ARMA_USE_LAPACK defined this calls @c arma::svd directly. |
|
| 229 |
/// Without LAPACK the fallback computes @c A^T*A, calls @c eig_sym, |
|
| 230 |
/// and derives @p U via @c U[:,j] = A*V[:,j]/s_j. Columns with |
|
| 231 |
/// @c s_j < 1e-14 are zeroed. Accuracy is governed by the |
|
| 232 |
/// conditioning of @c A^T*A (squares the condition number of @p A). |
|
| 233 |
/// |
|
| 234 |
/// @note Fallback algorithm: eigendecomposition of \f$A^T A\f$ via cyclic |
|
| 235 |
/// Jacobi (Demmel & Veselić 1992). Suitable for the small, moderately |
|
| 236 |
/// conditioned matrices typical in ENA (p ≤ ~100). |
|
| 237 |
inline void svd(arma::mat& U, arma::vec& s, arma::mat& V, const arma::mat& A) {
|
|
| 238 |
#if defined(ARMA_USE_LAPACK) |
|
| 239 |
arma::svd(U, s, V, A); |
|
| 240 |
#else |
|
| 241 |
// One-sided Jacobi on A^T: |
|
| 242 |
// We work with B = A^T (p×n). The right singular vectors of A are the |
|
| 243 |
// left singular vectors of B (= columns of U_B after Jacobi on B^T B). |
|
| 244 |
// We use the eigendecomposition of A^T * A to get V and s. |
|
| 245 |
const arma::uword n = A.n_rows, p = A.n_cols; |
|
| 246 |
const arma::uword k = std::min(n, p); |
|
| 247 | ||
| 248 |
arma::mat AtA = A.t() * A; ///< p×p symmetric Gram matrix |
|
| 249 |
arma::vec eigval; |
|
| 250 |
arma::mat eigvec; |
|
| 251 |
eig_sym(eigval, eigvec, AtA); |
|
| 252 | ||
| 253 |
// Eigenvalues from eig_sym are ascending; we want descending. |
|
| 254 |
arma::uvec idx = arma::sort_index(eigval, "descend"); |
|
| 255 |
eigval = eigval(idx); |
|
| 256 |
eigvec = eigvec.cols(idx); |
|
| 257 | ||
| 258 |
// Singular values (non-negative square roots) |
|
| 259 |
s.set_size(k); |
|
| 260 |
for (arma::uword j = 0; j < k; ++j) |
|
| 261 |
s(j) = std::sqrt(std::max(0.0, eigval(j))); |
|
| 262 | ||
| 263 |
// V = eigvec (right singular vectors of A are eigvectors of A^T*A) |
|
| 264 |
V = eigvec; |
|
| 265 | ||
| 266 |
// U = A * V / s (left singular vectors, only first k columns needed) |
|
| 267 |
U.set_size(n, k); |
|
| 268 |
for (arma::uword j = 0; j < k; ++j) {
|
|
| 269 |
if (s(j) > 1e-14) |
|
| 270 |
U.col(j) = A * V.col(j) / s(j); |
|
| 271 |
else |
|
| 272 |
U.col(j).zeros(); |
|
| 273 |
} |
|
| 274 |
#endif |
|
| 275 |
} |
|
| 276 | ||
| 277 |
// ── svd_right ───────────────────────────────────────────────────────────────── |
|
| 278 | ||
| 279 |
/// @brief Singular values and right singular vectors only (left vectors skipped). |
|
| 280 |
/// |
|
| 281 |
/// A lighter-weight variant of @c svd() for callers that only need @p s and |
|
| 282 |
/// @p V. Under LAPACK the full @c arma::svd is still called internally; the |
|
| 283 |
/// savings come in WASM builds where computing @p U is avoided. |
|
| 284 |
/// |
|
| 285 |
/// @param[out] s min(n,p)×1 vector of singular values, descending. |
|
| 286 |
/// @param[out] V p×p matrix of right singular vectors. |
|
| 287 |
/// @param[in] A n×p input matrix (not modified). |
|
| 288 |
/// |
|
| 289 |
/// @warning With @c ARMA_USE_LAPACK defined this still invokes the full |
|
| 290 |
/// @c arma::svd; the left-vector output is discarded. |
|
| 291 |
inline void svd_right(arma::vec& s, arma::mat& V, const arma::mat& A) {
|
|
| 292 |
#if defined(ARMA_USE_LAPACK) |
|
| 293 |
arma::mat U; |
|
| 294 |
arma::svd(U, s, V, A); |
|
| 295 |
#else |
|
| 296 |
arma::mat U_unused; |
|
| 297 |
svd(U_unused, s, V, A); |
|
| 298 |
#endif |
|
| 299 |
} |
|
| 300 | ||
| 301 |
/// @} |
|
| 302 | ||
| 303 |
/// @name Linear System Solve |
|
| 304 |
/// @{
|
|
| 305 | ||
| 306 |
// ── solve_spd ───────────────────────────────────────────────────────────────── |
|
| 307 | ||
| 308 |
/// @brief Solve @c A*x = b for symmetric positive (semi-)definite @p A. |
|
| 309 |
/// |
|
| 310 |
/// Returns the n×k solution matrix @p x given the n×n system matrix @p A and |
|
| 311 |
/// the n×k right-hand side @p b. Near-zero eigenvalues are handled gracefully, |
|
| 312 |
/// making this a pseudo-inverse solve safe for rank-deficient Gram matrices. |
|
| 313 |
/// |
|
| 314 |
/// @param[in] A Real symmetric positive (semi-)definite n×n matrix. |
|
| 315 |
/// @param[in] b n×k right-hand side matrix. |
|
| 316 |
/// @param[in] tol Relative threshold below which eigenvalues are treated as |
|
| 317 |
/// zero. Eigenvalues with @c |λ| < tol * max(|λ|) are |
|
| 318 |
/// inverted as zero (i.e. those directions are projected out). |
|
| 319 |
/// Default: 1e-12. |
|
| 320 |
/// @returns n×k solution matrix @p x satisfying @c A*x ≈ b. |
|
| 321 |
/// |
|
| 322 |
/// @warning With @c ARMA_USE_LAPACK defined this delegates to |
|
| 323 |
/// @c arma::solve(A, b, arma::solve_opts::fast), which uses a |
|
| 324 |
/// Cholesky factorization and does **not** handle rank deficiency. |
|
| 325 |
/// Without LAPACK the eigendecomposition-based pseudo-inverse is used, |
|
| 326 |
/// which is safe for rank-deficient inputs but slower. |
|
| 327 |
/// |
|
| 328 |
/// @note Without LAPACK: @c A = V * diag(λ) * V^T is computed via @c eig_sym; |
|
| 329 |
/// the solution is @c x = V * diag(1/λ) * V^T * b with small λ skipped. |
|
| 330 |
inline arma::mat solve_spd(const arma::mat& A, const arma::mat& b, |
|
| 331 |
double tol = 1e-12) {
|
|
| 332 |
#if defined(ARMA_USE_LAPACK) |
|
| 333 |
return arma::solve(A, b, arma::solve_opts::fast); |
|
| 334 |
#else |
|
| 335 |
arma::vec eigval; |
|
| 336 |
arma::mat eigvec; |
|
| 337 |
eig_sym(eigval, eigvec, A); ///< A = eigvec * diag(eigval) * eigvec^T |
|
| 338 | ||
| 339 |
const double thresh = tol * std::abs(eigval.max()); |
|
| 340 |
arma::vec inv_eigval(eigval.n_elem, arma::fill::zeros); |
|
| 341 |
for (arma::uword i = 0; i < eigval.n_elem; ++i) |
|
| 342 |
if (std::abs(eigval(i)) > thresh) |
|
| 343 |
inv_eigval(i) = 1.0 / eigval(i); |
|
| 344 | ||
| 345 |
// x = eigvec * diag(inv_eigval) * eigvec^T * b |
|
| 346 |
return eigvec * arma::diagmat(inv_eigval) * eigvec.t() * b; |
|
| 347 |
#endif |
|
| 348 |
} |
|
| 349 | ||
| 350 |
// ── inv_sym ─────────────────────────────────────────────────────────────────── |
|
| 351 | ||
| 352 |
/// @brief Inverse of a symmetric positive (semi-)definite matrix, falling back |
|
| 353 |
/// to the pseudo-inverse when it is singular. |
|
| 354 |
/// |
|
| 355 |
/// @param[in] A Real symmetric positive (semi-)definite n×n matrix. |
|
| 356 |
/// @param[in] tol Without LAPACK: relative eigenvalue threshold, as in |
|
| 357 |
/// @c solve_spd. Default: 1e-12. |
|
| 358 |
/// @returns n×n matrix: @c A^-1, or the pseudo-inverse when @p A is singular. |
|
| 359 |
/// |
|
| 360 |
/// @warning With @c ARMA_USE_LAPACK defined this calls @c arma::inv and, when |
|
| 361 |
/// that reports a singular matrix, @c arma::pinv. Without LAPACK it |
|
| 362 |
/// is the eigendecomposition-based pseudo-inverse (@c solve_spd with |
|
| 363 |
/// the identity), which equals the inverse for a non-singular @p A. |
|
| 364 | 5x |
inline arma::mat inv_sym(const arma::mat& A, double tol = 1e-12) {
|
| 365 |
#if defined(ARMA_USE_LAPACK) |
|
| 366 | 5x |
arma::mat out; |
| 367 | 5x |
if (!arma::inv(out, A)) |
| 368 | ! |
out = arma::pinv(A); |
| 369 | 5x |
return out; |
| 370 |
#else |
|
| 371 |
return solve_spd(A, arma::eye(A.n_rows, A.n_rows), tol); |
|
| 372 |
#endif |
|
| 373 |
} |
|
| 374 | ||
| 375 |
/// @} |
|
| 376 | ||
| 377 |
} // namespace linalg |
|
| 378 |
} // namespace qe |
|
| 379 | ||
| 380 |
#endif // LIBQE_LINALG_FALLBACK_HPP |
| 1 |
/** |
|
| 2 |
* @file nesting.hpp |
|
| 3 |
* @brief Nested-code candidates from a binary code matrix: which codes are |
|
| 4 |
* contained in which, by co-occurrence on the coded lines. |
|
| 5 |
* |
|
| 6 |
* A nested code (B under A) is one that, when it is coded, is coded on a |
|
| 7 |
* line that also carries A. Before accumulation the parent is split into its |
|
| 8 |
* subcodes plus a residual (A without any subcode) and the family's |
|
| 9 |
* connections are masked; that is a data transformation shared by every |
|
| 10 |
* model (ENA, ONA, TMA), so the detection lives here, not in libena. |
|
| 11 |
* |
|
| 12 |
* This header holds only the numeric signal: per-code line counts, pairwise |
|
| 13 |
* co-occurrence, the pairs whose containment reaches a threshold, and the |
|
| 14 |
* pairs with identical presence (duplicates, never nested). Signals from a |
|
| 15 |
* codebook hierarchy or from code names belong to the caller. |
|
| 16 |
* |
|
| 17 |
* Pure C++ / Armadillo — no Rcpp dependency. |
|
| 18 |
*/ |
|
| 19 |
#ifndef LIBQE_NESTING_HPP |
|
| 20 |
#define LIBQE_NESTING_HPP |
|
| 21 | ||
| 22 |
#include <armadillo> |
|
| 23 |
#include <cmath> |
|
| 24 |
#include <stdexcept> |
|
| 25 |
#include <vector> |
|
| 26 | ||
| 27 |
namespace qe {
|
|
| 28 | ||
| 29 |
/** @brief Result of nesting_candidates(). All indices are 0-based code columns. */ |
|
| 30 |
struct NestingCandidates {
|
|
| 31 |
arma::umat cooccurrence; ///< n_codes × n_codes; (i, i) = lines with code i, (i, j) = lines with both |
|
| 32 |
arma::umat pairs; ///< k × 2 rows of (sub, parent) whose containment reaches the threshold |
|
| 33 |
arma::vec containment; ///< k; cooccurrence(sub, parent) / cooccurrence(sub, sub) per pair |
|
| 34 |
arma::umat duplicates; ///< d × 2 rows of (i, j), i < j, identical presence on at least one line |
|
| 35 |
}; |
|
| 36 | ||
| 37 |
/** @brief Pairs of codes where one is (nearly) always coded with another. |
|
| 38 |
* |
|
| 39 |
* A value is "present" when it is finite and greater than zero, so NA/NaN |
|
| 40 |
* and empty cells count as absent. Co-occurrence is counted per line over |
|
| 41 |
* the codes present on it, so the cost is rows × (codes per line)², not |
|
| 42 |
* rows × codes². |
|
| 43 |
* |
|
| 44 |
* A pair (sub, parent) is reported when |
|
| 45 |
* - the sub occurs on at least @p min_rows lines, |
|
| 46 |
* - at least @p threshold of those lines also carry the parent, and |
|
| 47 |
* - the parent occurs on more lines than the two share (a parent that |
|
| 48 |
* never occurs without the sub is not a parent). |
|
| 49 |
* Two codes present on exactly the same lines are duplicates: they appear |
|
| 50 |
* in @c duplicates and never in @c pairs, in either direction. |
|
| 51 |
* |
|
| 52 |
* Pairs are ordered by sub, then parent; duplicates by i, then j. |
|
| 53 |
* |
|
| 54 |
* @param[in] codes Lines × codes matrix of 0/1 (or counts). |
|
| 55 |
* @param[in] threshold Minimum containment, in (0, 1]. Default 0.95. |
|
| 56 |
* @param[in] min_rows Minimum lines a sub needs for the data signal. Default 5. |
|
| 57 |
* |
|
| 58 |
* @returns NestingCandidates. |
|
| 59 |
* @throws std::invalid_argument when @p threshold is not in (0, 1] or |
|
| 60 |
* @p min_rows is negative. |
|
| 61 |
* |
|
| 62 |
* @note Equivalent to the data signal of rena-wasm's detectNesting() (0.1.19). |
|
| 63 |
*/ |
|
| 64 | 10x |
inline NestingCandidates nesting_candidates(const arma::mat& codes, |
| 65 |
double threshold = 0.95, |
|
| 66 |
int min_rows = 5) {
|
|
| 67 | 10x |
if (!(threshold > 0.0 && threshold <= 1.0)) |
| 68 | 1x |
throw std::invalid_argument("nesting_candidates: threshold must be in (0, 1]");
|
| 69 | 9x |
if (min_rows < 0) |
| 70 | 1x |
throw std::invalid_argument("nesting_candidates: min_rows must be >= 0");
|
| 71 | ||
| 72 | 8x |
const arma::uword n_rows = codes.n_rows; |
| 73 | 8x |
const arma::uword n_codes = codes.n_cols; |
| 74 | ||
| 75 | 8x |
NestingCandidates out; |
| 76 | 8x |
out.cooccurrence.zeros(n_codes, n_codes); |
| 77 | ||
| 78 |
// One pass over the lines: for each, the codes present, then every pair. |
|
| 79 | 8x |
std::vector<arma::uword> hits; |
| 80 | 8x |
hits.reserve(n_codes); |
| 81 | 78x |
for (arma::uword r = 0; r < n_rows; ++r) {
|
| 82 | 70x |
hits.clear(); |
| 83 | 250x |
for (arma::uword i = 0; i < n_codes; ++i) {
|
| 84 | 180x |
const double v = codes(r, i); |
| 85 | 180x |
if (std::isfinite(v) && v > 0.0) hits.push_back(i); |
| 86 |
} |
|
| 87 | 175x |
for (std::size_t a = 0; a < hits.size(); ++a) {
|
| 88 | 105x |
const arma::uword i = hits[a]; |
| 89 | 105x |
out.cooccurrence(i, i) += 1; |
| 90 | 172x |
for (std::size_t b = a + 1; b < hits.size(); ++b) {
|
| 91 | 67x |
const arma::uword j = hits[b]; |
| 92 | 67x |
out.cooccurrence(i, j) += 1; |
| 93 | 134x |
out.cooccurrence(j, i) += 1; |
| 94 |
} |
|
| 95 |
} |
|
| 96 |
} |
|
| 97 | 8x |
const arma::umat& co = out.cooccurrence; |
| 98 | 142x |
auto count = [&co](arma::uword i) { return co(i, i); };
|
| 99 | ||
| 100 |
// Duplicates: identical presence (mutual containment), once per unordered pair. |
|
| 101 | 8x |
std::vector<arma::uword> dup_i, dup_j; |
| 102 | 8x |
std::vector<char> is_dup(n_codes * n_codes, 0); |
| 103 | 28x |
for (arma::uword i = 0; i < n_codes; ++i) {
|
| 104 | 20x |
if (count(i) == 0) continue; |
| 105 | 33x |
for (arma::uword j = i + 1; j < n_codes; ++j) {
|
| 106 | 15x |
const arma::uword both = co(i, j); |
| 107 | 15x |
if (both == count(i) && both == count(j)) {
|
| 108 | 2x |
dup_i.push_back(i); |
| 109 | 2x |
dup_j.push_back(j); |
| 110 | 2x |
is_dup[i * n_codes + j] = is_dup[j * n_codes + i] = 1; |
| 111 |
} |
|
| 112 |
} |
|
| 113 |
} |
|
| 114 | ||
| 115 |
// Candidate pairs by containment. |
|
| 116 | 8x |
std::vector<arma::uword> sub, parent; |
| 117 | 8x |
std::vector<double> contain; |
| 118 | 8x |
const arma::uword min_lines = static_cast<arma::uword>(min_rows); |
| 119 | 28x |
for (arma::uword s = 0; s < n_codes; ++s) {
|
| 120 | 20x |
const arma::uword lines = count(s); |
| 121 | 20x |
if (lines == 0 || lines < min_lines) continue; |
| 122 | 62x |
for (arma::uword p = 0; p < n_codes; ++p) {
|
| 123 | 45x |
if (p == s || is_dup[s * n_codes + p]) continue; |
| 124 | 24x |
const arma::uword both = co(s, p); |
| 125 | 24x |
const double ratio = static_cast<double>(both) / static_cast<double>(lines); |
| 126 | 24x |
if (ratio >= threshold && count(p) > both) {
|
| 127 | 9x |
sub.push_back(s); |
| 128 | 9x |
parent.push_back(p); |
| 129 | 9x |
contain.push_back(ratio); |
| 130 |
} |
|
| 131 |
} |
|
| 132 |
} |
|
| 133 | ||
| 134 | 8x |
out.pairs.set_size(sub.size(), 2); |
| 135 | 8x |
out.containment.set_size(sub.size()); |
| 136 | 17x |
for (std::size_t k = 0; k < sub.size(); ++k) {
|
| 137 | 9x |
out.pairs(k, 0) = sub[k]; |
| 138 | 9x |
out.pairs(k, 1) = parent[k]; |
| 139 | 18x |
out.containment(k) = contain[k]; |
| 140 |
} |
|
| 141 | 8x |
out.duplicates.set_size(dup_i.size(), 2); |
| 142 | 10x |
for (std::size_t k = 0; k < dup_i.size(); ++k) {
|
| 143 | 2x |
out.duplicates(k, 0) = dup_i[k]; |
| 144 | 4x |
out.duplicates(k, 1) = dup_j[k]; |
| 145 |
} |
|
| 146 | 8x |
return out; |
| 147 |
} |
|
| 148 | ||
| 149 |
} // namespace qe |
|
| 150 | ||
| 151 |
#endif // LIBQE_NESTING_HPP |
| 1 |
/** |
|
| 2 |
* @file normalization.hpp |
|
| 3 |
* @brief Row-wise normalization utilities for ENA connection networks. |
|
| 4 |
*/ |
|
| 5 |
#ifndef LIBQE_NORMALIZATION_HPP |
|
| 6 |
#define LIBQE_NORMALIZATION_HPP |
|
| 7 | ||
| 8 |
#include <armadillo> |
|
| 9 |
#include <cmath> |
|
| 10 | ||
| 11 |
namespace qe {
|
|
| 12 | ||
| 13 |
/** |
|
| 14 |
* @brief Normalize each row to unit L2 norm (project onto the unit hypersphere). |
|
| 15 |
* |
|
| 16 |
* Rows whose L2 norm is zero are left unchanged. |
|
| 17 |
* |
|
| 18 |
* @param m Input matrix (n_units × n_connections), row-major. |
|
| 19 |
* @returns Matrix of the same shape with each row divided by its L2 norm. |
|
| 20 |
* @note Equivalent to @c fun_sphere_norm() in rENA/ena.cpp. |
|
| 21 |
*/ |
|
| 22 | 4x |
inline arma::mat normalize_networks(arma::mat m) {
|
| 23 | 4x |
arma::mat out(m.n_rows, m.n_cols, arma::fill::zeros); |
| 24 | 14x |
for (arma::uword r = 0; r < m.n_rows; r++) {
|
| 25 | 10x |
double len = arma::norm(m.row(r), 2); |
| 26 | 18x |
if (std::isfinite(len) && len > 0.0) out.row(r) = m.row(r) / len; |
| 27 |
} |
|
| 28 | 4x |
return out; |
| 29 |
} |
|
| 30 | ||
| 31 |
/** |
|
| 32 |
* @brief Scale every entry by the reciprocal of the largest row L2 norm. |
|
| 33 |
* |
|
| 34 |
* Preserves relative magnitudes across rows; does not normalise each row |
|
| 35 |
* individually. If the largest row norm is zero, the matrix is returned |
|
| 36 |
* unchanged. |
|
| 37 |
* |
|
| 38 |
* @param m Input matrix (n_units × n_connections), modified in place. |
|
| 39 |
* @returns Scaled matrix (same shape as @p m). |
|
| 40 |
* @note Equivalent to @c fun_skip_sphere_norm() in rENA/ena.cpp. |
|
| 41 |
*/ |
|
| 42 | 3x |
inline arma::mat scale_networks(arma::mat m) {
|
| 43 | 3x |
double largest = 0.0; |
| 44 | 10x |
for (arma::uword r = 0; r < m.n_rows; r++) {
|
| 45 | 7x |
double len = arma::norm(m.row(r), 2); |
| 46 | 7x |
if (std::isfinite(len)) largest = std::max(largest, len); |
| 47 |
} |
|
| 48 | 3x |
if (largest > 0.0) m /= largest; |
| 49 | 6x |
return m; |
| 50 |
} |
|
| 51 | ||
| 52 |
} // namespace qe |
|
| 53 | ||
| 54 |
#endif // LIBQE_NORMALIZATION_HPP |
| 1 |
/** @file stability.hpp |
|
| 2 |
* @brief Stability metrics and distance-distance matrix correlation for parameter tuning. |
|
| 3 |
*/ |
|
| 4 |
#ifndef LIBQE_STABILITY_HPP |
|
| 5 |
#define LIBQE_STABILITY_HPP |
|
| 6 | ||
| 7 |
#include <armadillo> |
|
| 8 |
#include <cmath> |
|
| 9 |
#include <algorithm> |
|
| 10 | ||
| 11 |
namespace qe {
|
|
| 12 | ||
| 13 |
/** @brief Compute Pearson correlation between pairwise Euclidean distance structures of two matrices. |
|
| 14 |
* |
|
| 15 |
* Evaluates dist-dist correlation: r(dist(X), dist(Y)), measuring whether relative |
|
| 16 |
* neighborhoods and distances are preserved across parameter changes (e.g. window vs lookback). |
|
| 17 |
* |
|
| 18 |
* @param[in] X Numeric matrix (n_rows × n_cols). |
|
| 19 |
* @param[in] Y Numeric matrix (n_rows × n_cols). |
|
| 20 |
* |
|
| 21 |
* @returns Pearson correlation coefficient in [-1, 1]. |
|
| 22 |
*/ |
|
| 23 | 1x |
inline double dist_dist_correlation(const arma::mat& X, const arma::mat& Y) {
|
| 24 | 1x |
size_t n = X.n_rows; |
| 25 |
if (n != Y.n_rows || n < 3) return 0.0; |
|
| 26 | ||
| 27 | 1x |
size_t n_pairs = n * (n - 1) / 2; |
| 28 | 1x |
arma::vec dx(n_pairs); |
| 29 | 1x |
arma::vec dy(n_pairs); |
| 30 | ||
| 31 | 1x |
size_t idx = 0; |
| 32 | 11x |
for (size_t i = 0; i < n; ++i) {
|
| 33 | 55x |
for (size_t j = i + 1; j < n; ++j) {
|
| 34 | 180x |
arma::rowvec diff_x = X.row(i) - X.row(j); |
| 35 | 180x |
arma::rowvec diff_y = Y.row(i) - Y.row(j); |
| 36 | 90x |
dx[idx] = std::sqrt(arma::dot(diff_x, diff_x)); |
| 37 | 45x |
dy[idx] = std::sqrt(arma::dot(diff_y, diff_y)); |
| 38 | 45x |
idx++; |
| 39 |
} |
|
| 40 |
} |
|
| 41 | ||
| 42 | 1x |
double mean_x = arma::mean(dx); |
| 43 | 1x |
double mean_y = arma::mean(dy); |
| 44 | ||
| 45 | 1x |
arma::vec zx = dx - mean_x; |
| 46 | 1x |
arma::vec zy = dy - mean_y; |
| 47 | ||
| 48 | 1x |
double sxx = arma::dot(zx, zx); |
| 49 | 1x |
double syy = arma::dot(zy, zy); |
| 50 | 1x |
double sxy = arma::dot(zx, zy); |
| 51 | ||
| 52 |
if (sxx <= 1e-12 || syy <= 1e-12) return 0.0; |
|
| 53 | 1x |
return sxy / std::sqrt(sxx * syy); |
| 54 |
} |
|
| 55 | ||
| 56 |
} // namespace qe |
|
| 57 | ||
| 58 |
#endif // LIBQE_STABILITY_HPP |
| 1 |
/** |
|
| 2 |
* @file stats.hpp |
|
| 3 |
* @brief Generic statistics: centering, normal/t quantiles, group confidence |
|
| 4 |
* intervals (t-based and IQR outlier fences), and two-group comparison |
|
| 5 |
* tests (Welch t-test, Wilcoxon rank-sum). |
|
| 6 |
* |
|
| 7 |
* Split out of the former modeling.hpp; the ENA node-position solvers and |
|
| 8 |
* ena_correlation() are in rENA's libena (positions.hpp). |
|
| 9 |
*/ |
|
| 10 |
#ifndef LIBQE_STATS_HPP |
|
| 11 |
#define LIBQE_STATS_HPP |
|
| 12 | ||
| 13 |
#include <armadillo> |
|
| 14 |
#include <algorithm> |
|
| 15 |
#include <cmath> |
|
| 16 |
#include <limits> |
|
| 17 |
#include <stdexcept> |
|
| 18 |
#include <vector> |
|
| 19 | ||
| 20 |
namespace qe {
|
|
| 21 | ||
| 22 |
// --------------------------------------------------------------------------- |
|
| 23 |
/// @name Centering |
|
| 24 |
/// @{
|
|
| 25 |
// --------------------------------------------------------------------------- |
|
| 26 | ||
| 27 |
/** |
|
| 28 |
* @brief Subtract column means so that every dimension is centred at the origin. |
|
| 29 |
* |
|
| 30 |
* @param[in] values Matrix of ENA unit points (n_units × n_dims). |
|
| 31 |
* @returns A copy of @p values with each column mean subtracted (centre-to-origin). |
|
| 32 |
* |
|
| 33 |
* @note Equivalent to `center_data_c()` in rENA/ena.cpp. |
|
| 34 |
*/ |
|
| 35 | 2x |
inline arma::mat center_points(arma::mat values) {
|
| 36 | 4x |
return values.each_row() - arma::mean(values); |
| 37 |
} |
|
| 38 | ||
| 39 |
/// @} |
|
| 40 | ||
| 41 |
/// @cond INTERNAL |
|
| 42 | ||
| 43 |
// --------------------------------------------------------------------------- |
|
| 44 |
// Normal quantile (probit) — pure C++, no R dependency |
|
| 45 |
// Rational approximation by Peter Acklam; max abs error < 1.15e-9. |
|
| 46 |
// Used by libena's ena_correlation() (rENA) so it doesn't need Rcpp::qnorm. |
|
| 47 |
// --------------------------------------------------------------------------- |
|
| 48 | ||
| 49 | 13x |
inline double normal_quantile(double p) {
|
| 50 |
static const double a[] = {
|
|
| 51 |
-3.969683028665376e+01, 2.209460984245205e+02, |
|
| 52 |
-2.759285104469687e+02, 1.383577518672690e+02, |
|
| 53 |
-3.066479806614716e+01, 2.506628277459239e+00 |
|
| 54 |
}; |
|
| 55 |
static const double b[] = {
|
|
| 56 |
-5.447609879822406e+01, 1.615858368580409e+02, |
|
| 57 |
-1.556989798598866e+02, 6.680131188771972e+01, |
|
| 58 |
-1.328068155288572e+01 |
|
| 59 |
}; |
|
| 60 |
static const double c[] = {
|
|
| 61 |
-7.784894002430293e-03, -3.223964580411365e-01, |
|
| 62 |
-2.400758277161838e+00, -2.549732539343734e+00, |
|
| 63 |
4.374664141464968e+00, 2.938163982698783e+00 |
|
| 64 |
}; |
|
| 65 |
static const double d[] = {
|
|
| 66 |
7.784695709041462e-03, 3.224671290700398e-01, |
|
| 67 |
2.445134137142996e+00, 3.754408661907416e+00 |
|
| 68 |
}; |
|
| 69 | 13x |
const double p_low = 0.02425; |
| 70 | 13x |
const double p_high = 1.0 - p_low; |
| 71 |
double q, r; |
|
| 72 | 13x |
if (p < p_low) {
|
| 73 | ! |
q = std::sqrt(-2.0 * std::log(p)); |
| 74 | ! |
return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / |
| 75 | ! |
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); |
| 76 | 13x |
} else if (p <= p_high) {
|
| 77 | 12x |
q = p - 0.5; r = q * q; |
| 78 | 12x |
return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / |
| 79 | 12x |
(((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1.0); |
| 80 |
} else {
|
|
| 81 | 1x |
q = std::sqrt(-2.0 * std::log(1.0 - p)); |
| 82 | 1x |
return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / |
| 83 | 1x |
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0); |
| 84 |
} |
|
| 85 |
} |
|
| 86 | ||
| 87 |
// --------------------------------------------------------------------------- |
|
| 88 |
// Internal helpers: regularized incomplete beta + t-distribution quantile |
|
| 89 |
// --------------------------------------------------------------------------- |
|
| 90 |
// |
|
| 91 |
// These live in qe::detail so they don't pollute the public qe namespace. |
|
| 92 |
// They are used by mean_ci() and are not part of the public API. |
|
| 93 | ||
| 94 |
namespace detail {
|
|
| 95 | ||
| 96 |
// Lentz continued-fraction evaluation for the regularized incomplete beta. |
|
| 97 |
// Ported from Numerical Recipes in C, §6.4. |
|
| 98 | 161x |
inline double betacf(double a, double b, double x) {
|
| 99 | 161x |
const int MAXIT = 200; |
| 100 | 161x |
const double EPS = std::numeric_limits<double>::epsilon(); |
| 101 | 161x |
const double FPMIN = std::numeric_limits<double>::min() / EPS; |
| 102 | 161x |
double qab = a + b, qap = a + 1.0, qam = a - 1.0; |
| 103 | 161x |
double c = 1.0; |
| 104 | 161x |
double d = 1.0 - qab * x / qap; |
| 105 |
if (std::abs(d) < FPMIN) d = FPMIN; |
|
| 106 | 161x |
d = 1.0 / d; |
| 107 | 161x |
double h = d; |
| 108 | 2890x |
for (int m = 1; m <= MAXIT; ++m) {
|
| 109 | 2890x |
const int m2 = 2 * m; |
| 110 |
// even step |
|
| 111 | 2890x |
double aa = static_cast<double>(m) * (b - static_cast<double>(m)) * x |
| 112 | 2890x |
/ ((qam + m2) * (a + m2)); |
| 113 |
d = 1.0 + aa * d; if (std::abs(d) < FPMIN) d = FPMIN; |
|
| 114 |
c = 1.0 + aa / c; if (std::abs(c) < FPMIN) c = FPMIN; |
|
| 115 | 2890x |
d = 1.0 / d; h *= d * c; |
| 116 |
// odd step |
|
| 117 | 2890x |
aa = -(a + static_cast<double>(m)) * (qab + static_cast<double>(m)) * x |
| 118 | 2890x |
/ ((a + m2) * (qap + m2)); |
| 119 |
d = 1.0 + aa * d; if (std::abs(d) < FPMIN) d = FPMIN; |
|
| 120 |
c = 1.0 + aa / c; if (std::abs(c) < FPMIN) c = FPMIN; |
|
| 121 | 2890x |
d = 1.0 / d; |
| 122 | 2890x |
const double del = d * c; |
| 123 | 2890x |
h *= del; |
| 124 | 2890x |
if (std::abs(del - 1.0) <= EPS) break; |
| 125 |
} |
|
| 126 | 161x |
return h; |
| 127 |
} |
|
| 128 | ||
| 129 |
// Regularized incomplete beta I_x(a, b). |
|
| 130 | 161x |
inline double betai(double a, double b, double x) {
|
| 131 |
if (x <= 0.0) return 0.0; |
|
| 132 |
if (x >= 1.0) return 1.0; |
|
| 133 | 161x |
const double lbab = std::lgamma(a + b) - std::lgamma(a) - std::lgamma(b); |
| 134 | 161x |
const double bt = std::exp(lbab + a * std::log(x) + b * std::log(1.0 - x)); |
| 135 | 161x |
if (x < (a + 1.0) / (a + b + 2.0)) |
| 136 | 151x |
return bt * betacf(a, b, x) / a; |
| 137 | 10x |
return 1.0 - bt * betacf(b, a, 1.0 - x) / b; |
| 138 |
} |
|
| 139 | ||
| 140 |
// Quantile function (inverse CDF) for the t-distribution with `df` degrees |
|
| 141 |
// of freedom. Uses the relation: |
|
| 142 |
// |
|
| 143 |
// P(T ≤ t | df) = 1 - I_x(df/2, 1/2) / 2, x = df / (df + t^2) |
|
| 144 |
// |
|
| 145 |
// Inverted via Newton–Raphson on I_x with bisection fallback; normal-quantile |
|
| 146 |
// plus one Cornish–Fisher correction term provides the starting guess. |
|
| 147 | 14x |
inline double t_quantile(double p, double df) {
|
| 148 | 14x |
const double INF = std::numeric_limits<double>::infinity(); |
| 149 |
if (p <= 0.0) return -INF; |
|
| 150 |
if (p >= 1.0) return INF; |
|
| 151 |
if (p == 0.5) return 0.0; |
|
| 152 | 14x |
if (df <= 0.0) return INF; // df = 0 → t-distribution undefined → treat as ∞ |
| 153 | ||
| 154 |
// Work with pp > 0.5 for numerical stability; negate at the end if needed. |
|
| 155 | 13x |
const bool flip = (p < 0.5); |
| 156 |
const double pp = flip ? 1.0 - p : p; |
|
| 157 | 13x |
const double a = 0.5 * df; |
| 158 | 13x |
const double bv = 0.5; |
| 159 | ||
| 160 |
// We need x in (0,1) such that I_x(a, bv) = 2*(1-pp). |
|
| 161 | 13x |
const double target = 2.0 * (1.0 - pp); |
| 162 | ||
| 163 |
// Initial guess via normal quantile + single Cornish–Fisher correction. |
|
| 164 | 13x |
const double z = normal_quantile(pp); |
| 165 | 13x |
const double t0 = z + (z * z * z + z) / (4.0 * df); |
| 166 | 13x |
double x = df / (df + t0 * t0); |
| 167 | 13x |
x = std::max(1e-12, std::min(1.0 - 1e-12, x)); |
| 168 | ||
| 169 |
// Newton–Raphson with a bisection bracket. |
|
| 170 |
// f(x) = I_x(a,bv) - target; f'(x) = x^(a-1)*(1-x)^(bv-1) / B(a,bv) |
|
| 171 | 13x |
const double lbab = std::lgamma(a + bv) - std::lgamma(a) - std::lgamma(bv); |
| 172 | 13x |
double xlo = 0.0, xhi = 1.0; |
| 173 | 161x |
for (int iter = 0; iter < 100; ++iter) {
|
| 174 | 161x |
const double fx = betai(a, bv, x) - target; |
| 175 |
// betai is increasing in x, so fx < 0 → x too small → raise lower bound |
|
| 176 | 161x |
if (fx < 0.0) xlo = x; else xhi = x; |
| 177 | ||
| 178 |
// Derivative: may underflow near boundaries — fall back to bisection. |
|
| 179 | 161x |
const double log_fpx = lbab + (a - 1.0) * std::log(x) |
| 180 | 161x |
+ (bv - 1.0) * std::log(1.0 - x); |
| 181 |
const double fpx = (log_fpx > -700.0) ? std::exp(log_fpx) : 0.0; |
|
| 182 | ||
| 183 |
double x_new; |
|
| 184 | 161x |
if (fpx > 0.0) {
|
| 185 | 161x |
x_new = x - fx / fpx; |
| 186 | 161x |
if (x_new <= xlo || x_new >= xhi) |
| 187 | 110x |
x_new = 0.5 * (xlo + xhi); |
| 188 |
} else {
|
|
| 189 | ! |
x_new = 0.5 * (xlo + xhi); |
| 190 |
} |
|
| 191 | ||
| 192 | 161x |
if (std::abs(x_new - x) < 1e-13 * x) break; |
| 193 | 148x |
x = x_new; |
| 194 |
} |
|
| 195 | ||
| 196 | 13x |
const double t_val = std::sqrt(df * (1.0 - x) / x); |
| 197 |
return flip ? -t_val : t_val; |
|
| 198 |
} |
|
| 199 | ||
| 200 |
// --------------------------------------------------------------------------- |
|
| 201 |
// IQR helper — matches R's quantile(type = 7) (Hyndman–Fan #7), which is |
|
| 202 |
// identical to numpy's np.percentile(method="linear"). |
|
| 203 |
// |
|
| 204 |
// For a sorted n-element vector and probability p: |
|
| 205 |
// h = (n - 1) * p |
|
| 206 |
// lo = floor(h), frac = h - lo |
|
| 207 |
// Q(p) = sorted[lo] * (1 - frac) + sorted[lo+1] * frac |
|
| 208 |
// --------------------------------------------------------------------------- |
|
| 209 | ||
| 210 | 22x |
inline double quantile_type7(const arma::vec& sorted, double p) {
|
| 211 | 22x |
const int n = static_cast<int>(sorted.n_elem); |
| 212 |
if (n == 0) return std::numeric_limits<double>::quiet_NaN(); |
|
| 213 |
if (n == 1) return sorted[0]; |
|
| 214 | 22x |
const double h = (n - 1) * p; |
| 215 | 22x |
const int lo = static_cast<int>(std::floor(h)); |
| 216 | 22x |
const double frac = h - lo; |
| 217 |
if (lo + 1 >= n) return sorted[n - 1]; // guard: p == 1.0 |
|
| 218 | 66x |
return sorted[lo] * (1.0 - frac) + sorted[lo + 1] * frac; |
| 219 |
} |
|
| 220 | ||
| 221 |
// Interquartile range for a column vector — uses the same type-7 quantile as R. |
|
| 222 | 11x |
inline double iqr(const arma::vec& col) {
|
| 223 | 11x |
const arma::vec s = arma::sort(col); |
| 224 | 22x |
return quantile_type7(s, 0.75) - quantile_type7(s, 0.25); |
| 225 |
} |
|
| 226 | ||
| 227 |
} // namespace detail |
|
| 228 | ||
| 229 |
/// @endcond |
|
| 230 | ||
| 231 |
// --------------------------------------------------------------------------- |
|
| 232 |
/// @name Group confidence intervals |
|
| 233 |
/// @{
|
|
| 234 |
// --------------------------------------------------------------------------- |
|
| 235 | ||
| 236 |
/** |
|
| 237 |
* @brief Student-t confidence interval for the mean of a group of ENA unit points. |
|
| 238 |
* |
|
| 239 |
* For each dimension d: |
|
| 240 |
* @code |
|
| 241 |
* mean ± t_{α/2, n-1} × (sample_sd / sqrt(n))
|
|
| 242 |
* @endcode |
|
| 243 |
* where α = 1 − @p conf_level and degrees-of-freedom = n − 1. |
|
| 244 |
* |
|
| 245 |
* @param[in] points ENA unit points for a single group, n_units × n_dims. |
|
| 246 |
* @param[in] conf_level Confidence level for the interval (default 0.95). |
|
| 247 |
* @returns An n_dims × 3 matrix whose columns are [mean, ci_lower, ci_upper]. |
|
| 248 |
* When n == 0 all entries are NaN. |
|
| 249 |
* When n == 1 the CI bounds are ±Inf. |
|
| 250 |
*/ |
|
| 251 | 14x |
inline arma::mat mean_ci(const arma::mat& points, double conf_level = 0.95) {
|
| 252 | 14x |
const int n = static_cast<int>(points.n_rows); |
| 253 | 14x |
const int n_dims = static_cast<int>(points.n_cols); |
| 254 | ||
| 255 | 14x |
arma::mat out(n_dims, 3); |
| 256 | 14x |
out.fill(std::numeric_limits<double>::quiet_NaN()); |
| 257 | ||
| 258 |
if (n == 0) return out; |
|
| 259 | ||
| 260 | 14x |
const double df = static_cast<double>(n - 1); |
| 261 | 14x |
const double t_crit = detail::t_quantile(1.0 - (1.0 - conf_level) / 2.0, df); |
| 262 | 14x |
const double INF = std::numeric_limits<double>::infinity(); |
| 263 | ||
| 264 | 40x |
for (int d = 0; d < n_dims; ++d) {
|
| 265 | 52x |
const arma::vec col = points.col(d); |
| 266 | 26x |
const double mu = arma::mean(col); |
| 267 | 26x |
out(d, 0) = mu; |
| 268 | 26x |
if (n == 1) {
|
| 269 |
// t_crit = Inf and se = 0 would give NaN; explicitly set ±Inf |
|
| 270 | 2x |
out(d, 1) = -INF; |
| 271 | 4x |
out(d, 2) = INF; |
| 272 |
} else {
|
|
| 273 | 24x |
const double se = arma::stddev(col) / std::sqrt(static_cast<double>(n)); |
| 274 | 24x |
out(d, 1) = mu - t_crit * se; |
| 275 | 48x |
out(d, 2) = mu + t_crit * se; |
| 276 |
} |
|
| 277 |
} |
|
| 278 | 14x |
return out; |
| 279 |
} |
|
| 280 | ||
| 281 |
/** |
|
| 282 |
* @brief Tukey-fence outlier interval for a group of ENA unit points. |
|
| 283 |
* |
|
| 284 |
* For each dimension d the half-width is computed as: |
|
| 285 |
* @code |
|
| 286 |
* half_width[d] = iqr_factor * IQR(points[:, d]) |
|
| 287 |
* @endcode |
|
| 288 |
* and the interval [−half_width, +half_width] is symmetric around zero, |
|
| 289 |
* matching rENA's formula: |
|
| 290 |
* @code |
|
| 291 |
* outlier.interval.values = matrix(...) * c(-1, 1) |
|
| 292 |
* @endcode |
|
| 293 |
* where the matrix is built from `c(IQR(dim1), IQR(dim2), ...) * iqr_factor`. |
|
| 294 |
* |
|
| 295 |
* IQR uses R's default type-7 / Hyndman-Fan #7 quantile, identical to |
|
| 296 |
* `numpy.percentile(method="linear")`. |
|
| 297 |
* |
|
| 298 |
* @param[in] points ENA unit points for a single group, n_units × n_dims. |
|
| 299 |
* @param[in] iqr_factor Multiplier applied to the IQR (default 1.5, the |
|
| 300 |
* standard Tukey fence threshold). |
|
| 301 |
* @returns An n_dims × 2 matrix whose columns are [lower, upper]. |
|
| 302 |
* When n == 0 all entries are NaN. |
|
| 303 |
* When n == 1, IQR == 0, so lower == upper == 0. |
|
| 304 |
*/ |
|
| 305 | 6x |
inline arma::mat outlier_ci(const arma::mat& points, double iqr_factor = 1.5) {
|
| 306 | 6x |
const int n_dims = static_cast<int>(points.n_cols); |
| 307 | 6x |
arma::mat out(n_dims, 2, arma::fill::zeros); |
| 308 | ||
| 309 | 6x |
if (points.n_rows == 0) {
|
| 310 | 1x |
out.fill(std::numeric_limits<double>::quiet_NaN()); |
| 311 | 1x |
return out; |
| 312 |
} |
|
| 313 | ||
| 314 | 16x |
for (int d = 0; d < n_dims; ++d) {
|
| 315 | 22x |
const double half_width = iqr_factor * detail::iqr(points.col(d)); |
| 316 | 11x |
out(d, 0) = -half_width; |
| 317 | 22x |
out(d, 1) = half_width; |
| 318 |
} |
|
| 319 | 5x |
return out; |
| 320 |
} |
|
| 321 | ||
| 322 |
/// @} |
|
| 323 | ||
| 324 |
// --------------------------------------------------------------------------- |
|
| 325 |
/// @name Two-group comparison statistics |
|
| 326 |
/// @{
|
|
| 327 |
// --------------------------------------------------------------------------- |
|
| 328 | ||
| 329 |
/** |
|
| 330 |
* @brief Per-dimension statistics from a two-group comparison. |
|
| 331 |
* |
|
| 332 |
* Matches the output of rENA-api's `group.stats()`: |
|
| 333 |
* |
|
| 334 |
* - **Parametric** (Welch two-sample t-test): `t`, `df`, `pvalue_t`, |
|
| 335 |
* `cohens_d` (pooled-SD formula), `means`, `sds`. |
|
| 336 |
* - **Non-parametric** (Wilcoxon rank-sum / Mann-Whitney U): |
|
| 337 |
* `U` (= R's W statistic for group 1), `pvalue_u` (normal approximation |
|
| 338 |
* with tie correction and continuity correction), `effect_r` |
|
| 339 |
* (rank-biserial: `1 − 2*U / (n1*n2)`), `medians`. |
|
| 340 |
* |
|
| 341 |
* All per-dimension vectors have length `n_dims`; `means`, `sds`, and |
|
| 342 |
* `medians` are `2 × n_dims` matrices with row 0 = group 1, row 1 = group 2. |
|
| 343 |
* Entries are NaN when a statistic is undefined (e.g. n < 2 for parametric |
|
| 344 |
* tests, or n = 0 for either group). |
|
| 345 |
*/ |
|
| 346 |
struct GroupStats {
|
|
| 347 |
int n1; ///< Sample size of group 1. |
|
| 348 |
int n2; ///< Sample size of group 2. |
|
| 349 | ||
| 350 |
// Parametric |
|
| 351 |
arma::vec t; ///< Welch two-sample t-statistics. |
|
| 352 |
arma::vec df; ///< Welch–Satterthwaite degrees of freedom. |
|
| 353 |
arma::vec pvalue_t; ///< Two-tailed p-values from the Welch t-test. |
|
| 354 |
arma::vec cohens_d; ///< Cohen's d (pooled-SD formula, group1 − group2). |
|
| 355 |
arma::mat means; ///< 2 × n_dims: means; row 0 = group1, row 1 = group2. |
|
| 356 |
arma::mat sds; ///< 2 × n_dims: sample standard deviations. |
|
| 357 | ||
| 358 |
// Non-parametric |
|
| 359 |
arma::vec U; ///< Wilcoxon rank-sum U for group 1 (= R's W statistic). |
|
| 360 |
arma::vec pvalue_u; ///< Two-tailed p-values (normal approx, tie + continuity correction). |
|
| 361 |
arma::vec effect_r; ///< Rank-biserial effect: 1 − 2·U / (n1·n2). |
|
| 362 |
arma::mat medians; ///< 2 × n_dims: medians; row 0 = group1, row 1 = group2. |
|
| 363 |
}; |
|
| 364 | ||
| 365 |
/// @cond INTERNAL |
|
| 366 |
namespace detail {
|
|
| 367 | ||
| 368 |
// Two-tailed p-value for a Welch t-statistic. |
|
| 369 |
// Uses the relation P(T≤t|df) = 1 − I_x(df/2, 1/2)/2, x = df/(df+t²). |
|
| 370 | ! |
inline double t_test_pvalue(double t_stat, double df) {
|
| 371 | ! |
if (!std::isfinite(t_stat) || df <= 0.0) |
| 372 | ! |
return std::numeric_limits<double>::quiet_NaN(); |
| 373 | ! |
const double x = df / (df + t_stat * t_stat); |
| 374 | ! |
return betai(df / 2.0, 0.5, x); // == 2 * P(T_df > |t_stat|) |
| 375 |
} |
|
| 376 | ||
| 377 |
// Wilcoxon rank-sum U (= R's W for group 1) and two-tailed p-value. |
|
| 378 |
// Normal approximation with tie correction and continuity correction, |
|
| 379 |
// matching R's wilcox.test(..., exact=FALSE, correct=TRUE). |
|
| 380 |
struct WilcoxResult { double U; double p; };
|
|
| 381 | ||
| 382 | ! |
inline WilcoxResult wilcox_ranksum(const arma::vec& g1, const arma::vec& g2) {
|
| 383 | ! |
const int n1 = static_cast<int>(g1.n_elem); |
| 384 | ! |
const int n2 = static_cast<int>(g2.n_elem); |
| 385 | ! |
const int n = n1 + n2; |
| 386 | ! |
const double NaN = std::numeric_limits<double>::quiet_NaN(); |
| 387 | ||
| 388 | ! |
if (n1 == 0 || n2 == 0) return {NaN, NaN};
|
| 389 | ||
| 390 |
// Merge both groups into a single array, tagging each element's origin. |
|
| 391 | ! |
arma::vec vals(n); |
| 392 | ! |
std::vector<int> grp(n); |
| 393 | ! |
for (int i = 0; i < n1; ++i) { vals[i] = g1[i]; grp[i] = 0; }
|
| 394 | ! |
for (int i = 0; i < n2; ++i) { vals[n1 + i] = g2[i]; grp[n1 + i] = 1; }
|
| 395 | ||
| 396 | ! |
arma::uvec ord = arma::sort_index(vals); |
| 397 | ||
| 398 |
// Assign average ranks for tied values; accumulate tie correction T = Σ(t³−t). |
|
| 399 | ! |
arma::vec rnk(n); |
| 400 | ! |
double T_ties = 0.0; |
| 401 | ! |
arma::uword i = 0; |
| 402 | ! |
while (i < static_cast<arma::uword>(n)) {
|
| 403 | ! |
arma::uword j = i; |
| 404 | ! |
while (j + 1 < static_cast<arma::uword>(n) && |
| 405 | ! |
vals[ord[j + 1]] == vals[ord[j]]) |
| 406 | ! |
++j; |
| 407 | ! |
const double avg = 0.5 * (static_cast<double>(i) + static_cast<double>(j)) + 1.0; |
| 408 | ! |
for (arma::uword k = i; k <= j; ++k) rnk[ord[k]] = avg; |
| 409 | ! |
const double t = static_cast<double>(j - i + 1); |
| 410 | ! |
T_ties += t * (t * t - 1.0); // t³ − t |
| 411 | ! |
i = j + 1; |
| 412 |
} |
|
| 413 | ||
| 414 |
// W = rank-sum for group 1; U = W − n1*(n1+1)/2 (R's wilcox.test statistic). |
|
| 415 | ! |
double W = 0.0; |
| 416 | ! |
for (int k = 0; k < n; ++k) |
| 417 | ! |
if (grp[k] == 0) W += rnk[k]; |
| 418 | ! |
const double U = W - static_cast<double>(n1) * (n1 + 1) / 2.0; |
| 419 | ||
| 420 |
// Normal approximation: Var[U] = n1·n2/12 · ((n+1) − T_ties/(n·(n−1))) |
|
| 421 | ! |
const double mu_U = static_cast<double>(n1) * n2 / 2.0; |
| 422 | ! |
const double tie_adj = (n > 1) ? T_ties / (static_cast<double>(n) * (n - 1)) : 0.0; |
| 423 | ! |
double var_U = static_cast<double>(n1) * n2 / 12.0 |
| 424 | ! |
* (static_cast<double>(n + 1) - tie_adj); |
| 425 | ! |
if (var_U < 0.0) var_U = 0.0; |
| 426 | ||
| 427 |
double p; |
|
| 428 | ! |
if (var_U == 0.0) {
|
| 429 | ! |
p = (U == mu_U) ? 1.0 : 0.0; |
| 430 |
} else {
|
|
| 431 |
// Continuity correction: |U − mu| − 0.5 |
|
| 432 | ! |
double z = (std::abs(U - mu_U) - 0.5) / std::sqrt(var_U); |
| 433 | ! |
if (z < 0.0) z = 0.0; |
| 434 | ! |
p = std::erfc(z / std::sqrt(2.0)); // = 2*(1−Φ(z)) |
| 435 | ! |
p = std::min(p, 1.0); |
| 436 |
} |
|
| 437 | ||
| 438 | ! |
return {U, p};
|
| 439 |
} |
|
| 440 | ||
| 441 |
} // namespace detail (group stats helpers) |
|
| 442 |
/// @endcond |
|
| 443 | ||
| 444 |
/** |
|
| 445 |
* @brief Compute per-dimension two-group comparison statistics. |
|
| 446 |
* |
|
| 447 |
* For each dimension @p d of the input matrices the function computes: |
|
| 448 |
* |
|
| 449 |
* **Parametric (Welch two-sample t-test)** |
|
| 450 |
* - t-statistic and Welch–Satterthwaite degrees of freedom |
|
| 451 |
* - Two-tailed p-value via the regularised incomplete beta function |
|
| 452 |
* - Cohen's d using the pooled-SD formula |
|
| 453 |
* (`(μ1−μ2) / √(((n1−1)s1² + (n2−1)s2²)/(n1+n2−2))`) |
|
| 454 |
* - Per-group means and sample standard deviations |
|
| 455 |
* |
|
| 456 |
* **Non-parametric (Wilcoxon rank-sum / Mann-Whitney U)** |
|
| 457 |
* - U statistic for group 1 (identical to R's `wilcox.test` W statistic) |
|
| 458 |
* - Two-tailed p-value via the normal approximation with tie correction |
|
| 459 |
* and continuity correction (matching R's `wilcox.test(..., correct=TRUE)`) |
|
| 460 |
* - Rank-biserial effect size: `1 − 2·U / (n1·n2)` |
|
| 461 |
* (rENA-api's `nonparam.effect` formula) |
|
| 462 |
* - Per-group medians |
|
| 463 |
* |
|
| 464 |
* @param[in] g1 Unit points for group 1, n1 × n_dims. |
|
| 465 |
* @param[in] g2 Unit points for group 2, n2 × n_dims. |
|
| 466 |
* @returns A @ref GroupStats struct. |
|
| 467 |
* |
|
| 468 |
* @note Parametric statistics are NaN when either group has fewer than 2 units |
|
| 469 |
* (no sample variance). All statistics are NaN when either group is empty. |
|
| 470 |
* @note The Wilcoxon p-value uses the normal approximation regardless of sample |
|
| 471 |
* size; R switches to an exact test for small (n ≤ ~50) tie-free samples, |
|
| 472 |
* so p-values may differ slightly in those cases. |
|
| 473 |
* @note Equivalent to `group.stats()` in rENA-api, moved to the C++ layer so |
|
| 474 |
* all language bindings share a single implementation. |
|
| 475 |
*/ |
|
| 476 | ! |
inline GroupStats group_stats(const arma::mat& g1, const arma::mat& g2) {
|
| 477 | ! |
const int n1 = static_cast<int>(g1.n_rows); |
| 478 | ! |
const int n2 = static_cast<int>(g2.n_rows); |
| 479 | ! |
const int n_dims = static_cast<int>(g1.n_cols); |
| 480 | ! |
const double NaN = std::numeric_limits<double>::quiet_NaN(); |
| 481 | ||
| 482 | ! |
GroupStats out; |
| 483 | ! |
out.n1 = n1; |
| 484 | ! |
out.n2 = n2; |
| 485 | ||
| 486 | ! |
out.t = arma::vec(n_dims); out.t.fill(NaN); |
| 487 | ! |
out.df = arma::vec(n_dims); out.df.fill(NaN); |
| 488 | ! |
out.pvalue_t = arma::vec(n_dims); out.pvalue_t.fill(NaN); |
| 489 | ! |
out.cohens_d = arma::vec(n_dims); out.cohens_d.fill(NaN); |
| 490 | ! |
out.means = arma::mat(2, n_dims); out.means.fill(NaN); |
| 491 | ! |
out.sds = arma::mat(2, n_dims); out.sds.fill(NaN); |
| 492 | ! |
out.U = arma::vec(n_dims); out.U.fill(NaN); |
| 493 | ! |
out.pvalue_u = arma::vec(n_dims); out.pvalue_u.fill(NaN); |
| 494 | ! |
out.effect_r = arma::vec(n_dims); out.effect_r.fill(NaN); |
| 495 | ! |
out.medians = arma::mat(2, n_dims); out.medians.fill(NaN); |
| 496 | ||
| 497 | ! |
if (n1 == 0 || n2 == 0) return out; |
| 498 | ||
| 499 | ! |
for (int d = 0; d < n_dims; ++d) {
|
| 500 | ! |
const arma::vec c1 = g1.col(d); |
| 501 | ! |
const arma::vec c2 = g2.col(d); |
| 502 | ||
| 503 |
// ── Means and medians ─────────────────────────────────────────────── |
|
| 504 | ! |
const double m1 = arma::mean(c1); |
| 505 | ! |
const double m2 = arma::mean(c2); |
| 506 | ! |
out.means(0, d) = m1; |
| 507 | ! |
out.means(1, d) = m2; |
| 508 | ! |
out.medians(0, d) = arma::median(c1); |
| 509 | ! |
out.medians(1, d) = arma::median(c2); |
| 510 | ||
| 511 |
// ── Standard deviations (ddof = 1, matching R's sd()) ─────────────── |
|
| 512 | ! |
const double s1 = (n1 > 1) ? arma::stddev(c1) : NaN; |
| 513 | ! |
const double s2 = (n2 > 1) ? arma::stddev(c2) : NaN; |
| 514 | ! |
out.sds(0, d) = s1; |
| 515 | ! |
out.sds(1, d) = s2; |
| 516 | ||
| 517 |
// ── Welch two-sample t-test ────────────────────────────────────────── |
|
| 518 | ! |
if (n1 > 1 && n2 > 1 && std::isfinite(s1) && std::isfinite(s2)) {
|
| 519 | ! |
const double v1n = s1 * s1 / n1; |
| 520 | ! |
const double v2n = s2 * s2 / n2; |
| 521 | ! |
const double se = std::sqrt(v1n + v2n); |
| 522 | ! |
if (se > 0.0) {
|
| 523 | ! |
const double t_stat = (m1 - m2) / se; |
| 524 | ! |
const double df_val = (v1n + v2n) * (v1n + v2n) |
| 525 | ! |
/ (v1n * v1n / (n1 - 1) + v2n * v2n / (n2 - 1)); |
| 526 | ! |
out.t(d) = t_stat; |
| 527 | ! |
out.df(d) = df_val; |
| 528 | ! |
out.pvalue_t(d) = detail::t_test_pvalue(t_stat, df_val); |
| 529 |
} |
|
| 530 |
} |
|
| 531 | ||
| 532 |
// ── Cohen's d (pooled SD) ──────────────────────────────────────────── |
|
| 533 | ! |
if (n1 + n2 > 2 && std::isfinite(s1) && std::isfinite(s2)) {
|
| 534 | ! |
const double pooled_var = ((n1 - 1) * s1 * s1 + (n2 - 1) * s2 * s2) |
| 535 | ! |
/ (n1 + n2 - 2); |
| 536 | ! |
const double pooled_sd = std::sqrt(pooled_var); |
| 537 | ! |
out.cohens_d(d) = (pooled_sd > 0.0) ? (m1 - m2) / pooled_sd : 0.0; |
| 538 |
} |
|
| 539 | ||
| 540 |
// ── Wilcoxon rank-sum ──────────────────────────────────────────────── |
|
| 541 | ! |
const detail::WilcoxResult wr = detail::wilcox_ranksum(c1, c2); |
| 542 | ! |
out.U(d) = wr.U; |
| 543 | ! |
out.pvalue_u(d) = wr.p; |
| 544 | ! |
if (std::isfinite(wr.U)) |
| 545 | ! |
out.effect_r(d) = 1.0 - 2.0 * wr.U / (static_cast<double>(n1) * n2); |
| 546 |
} |
|
| 547 | ||
| 548 | ! |
return out; |
| 549 |
} |
|
| 550 | ||
| 551 |
/// @} |
|
| 552 | ||
| 553 |
} // namespace qe |
|
| 554 | ||
| 555 |
#endif // LIBQE_STATS_HPP |
| 1 |
/** @file trajectory_distance.hpp |
|
| 2 |
* @brief Integrated Euclidean distance between trajectory curves and lagged distance metrics. |
|
| 3 |
*/ |
|
| 4 |
#ifndef LIBQE_TRAJECTORY_DISTANCE_HPP |
|
| 5 |
#define LIBQE_TRAJECTORY_DISTANCE_HPP |
|
| 6 | ||
| 7 |
#include <armadillo> |
|
| 8 |
#include <vector> |
|
| 9 |
#include <cmath> |
|
| 10 |
#include <algorithm> |
|
| 11 |
#include "trajectory.hpp" |
|
| 12 |
#include "linalg_fallback.hpp" |
|
| 13 | ||
| 14 |
namespace qe {
|
|
| 15 | ||
| 16 |
struct GaussLegendreRule {
|
|
| 17 |
arma::vec nodes; |
|
| 18 |
arma::vec weights; |
|
| 19 |
}; |
|
| 20 | ||
| 21 |
/** @brief Obtain exact 32-point Gauss-Legendre quadrature nodes and weights on [-1, 1] via Golub-Welsch. */ |
|
| 22 | 2x |
inline const GaussLegendreRule& get_gl_rule_32() {
|
| 23 | 1x |
static const GaussLegendreRule rule = []() {
|
| 24 | 1x |
int N = 32; |
| 25 | 1x |
arma::mat J(N, N, arma::fill::zeros); |
| 26 | 32x |
for (int k = 1; k < N; ++k) {
|
| 27 | 31x |
double b = static_cast<double>(k) / std::sqrt(4.0 * k * k - 1.0); |
| 28 | 31x |
J(k, k - 1) = b; |
| 29 | 62x |
J(k - 1, k) = b; |
| 30 |
} |
|
| 31 | 1x |
arma::vec evals; |
| 32 | 1x |
arma::mat evecs; |
| 33 | 1x |
qe::linalg::eig_sym(evals, evecs, J); // LAPACK-free in WASM builds |
| 34 | 1x |
arma::vec w = 2.0 * arma::square(evecs.row(0).t()); |
| 35 |
return GaussLegendreRule{evals, w};
|
|
| 36 |
}(); |
|
| 37 | 2x |
return rule; |
| 38 |
} |
|
| 39 | ||
| 40 |
/** @brief Compute integrated Euclidean distance between two 2D parametric polynomial curves. |
|
| 41 |
* |
|
| 42 |
* Computes integral_{t_start}^{t_end} ||F_a(t) - F_b(t)|| dt
|
|
| 43 |
* using 32-point Gauss-Legendre quadrature. |
|
| 44 |
* |
|
| 45 |
* @param[in] coeffs_ax Coefficients of x_a(t). |
|
| 46 |
* @param[in] coeffs_ay Coefficients of y_a(t). |
|
| 47 |
* @param[in] coeffs_bx Coefficients of x_b(t). |
|
| 48 |
* @param[in] coeffs_by Coefficients of y_b(t). |
|
| 49 |
* @param[in] t_start Start of integration interval (default 0.0). |
|
| 50 |
* @param[in] t_end End of integration interval (default 1.0). |
|
| 51 |
* |
|
| 52 |
* @returns Integrated distance scalar. |
|
| 53 |
*/ |
|
| 54 | 2x |
inline double integrated_curve_distance( |
| 55 |
const arma::vec& coeffs_ax, |
|
| 56 |
const arma::vec& coeffs_ay, |
|
| 57 |
const arma::vec& coeffs_bx, |
|
| 58 |
const arma::vec& coeffs_by, |
|
| 59 |
double t_start = 0.0, |
|
| 60 |
double t_end = 1.0 |
|
| 61 |
) {
|
|
| 62 |
if (t_end <= t_start) return 0.0; |
|
| 63 | ||
| 64 | 2x |
const auto& rule = get_gl_rule_32(); |
| 65 | 2x |
double mid = 0.5 * (t_start + t_end); |
| 66 | 2x |
double half_len = 0.5 * (t_end - t_start); |
| 67 | 2x |
double total = 0.0; |
| 68 | ||
| 69 | 66x |
for (size_t i = 0; i < rule.nodes.n_elem; ++i) {
|
| 70 | 64x |
double t_eval = mid + half_len * rule.nodes[i]; |
| 71 | 64x |
double xa = eval_poly_scalar(coeffs_ax, t_eval); |
| 72 | 64x |
double ya = eval_poly_scalar(coeffs_ay, t_eval); |
| 73 | 64x |
double xb = eval_poly_scalar(coeffs_bx, t_eval); |
| 74 | 64x |
double yb = eval_poly_scalar(coeffs_by, t_eval); |
| 75 | 64x |
double dx = xa - xb; |
| 76 | 64x |
double dy = ya - yb; |
| 77 | 64x |
double dist = std::sqrt(dx * dx + dy * dy); |
| 78 | ||
| 79 | 128x |
total += rule.weights[i] * dist; |
| 80 |
} |
|
| 81 | ||
| 82 | 2x |
return half_len * total; |
| 83 |
} |
|
| 84 | ||
| 85 |
/** @brief Compute lagged curve distance: 1/(1-lag) * integral_{lag}^1 ||F_follower(t) - F_leader(t - lag)|| dt. */
|
|
| 86 | ! |
inline double lagged_curve_distance( |
| 87 |
const arma::vec& coeffs_fol_x, |
|
| 88 |
const arma::vec& coeffs_fol_y, |
|
| 89 |
const arma::vec& coeffs_ldr_x, |
|
| 90 |
const arma::vec& coeffs_ldr_y, |
|
| 91 |
double lag = 0.0 |
|
| 92 |
) {
|
|
| 93 | ! |
if (lag < 0.0 || lag >= 0.999) return 0.0; |
| 94 | ! |
double t_start = lag; |
| 95 | ! |
double t_end = 1.0; |
| 96 | ! |
const auto& rule = get_gl_rule_32(); |
| 97 | ! |
double mid = 0.5 * (t_start + t_end); |
| 98 | ! |
double half_len = 0.5 * (t_end - t_start); |
| 99 | ! |
double total = 0.0; |
| 100 | ||
| 101 | ! |
for (size_t i = 0; i < rule.nodes.n_elem; ++i) {
|
| 102 | ! |
double t_fol = mid + half_len * rule.nodes[i]; |
| 103 | ! |
double t_ldr = t_fol - lag; |
| 104 | ||
| 105 | ! |
double x_fol = eval_poly_scalar(coeffs_fol_x, t_fol); |
| 106 | ! |
double y_fol = eval_poly_scalar(coeffs_fol_y, t_fol); |
| 107 | ! |
double x_ldr = eval_poly_scalar(coeffs_ldr_x, t_ldr); |
| 108 | ! |
double y_ldr = eval_poly_scalar(coeffs_ldr_y, t_ldr); |
| 109 | ||
| 110 | ! |
double dx = x_fol - x_ldr; |
| 111 | ! |
double dy = y_fol - y_ldr; |
| 112 | ! |
double dist = std::sqrt(dx * dx + dy * dy); |
| 113 | ||
| 114 | ! |
total += rule.weights[i] * dist; |
| 115 |
} |
|
| 116 | ||
| 117 | ! |
double integral = half_len * total; |
| 118 | ! |
double interval = 1.0 - lag; |
| 119 | ! |
return (interval > 1e-6) ? (integral / interval) : 0.0; |
| 120 |
} |
|
| 121 | ||
| 122 |
/** @brief Compute pairwise integrated distance matrix across K polynomial trajectory curves. */ |
|
| 123 | ! |
inline arma::mat pairwise_trajectory_distance_matrix( |
| 124 |
const std::vector<arma::vec>& all_coeffs_x, |
|
| 125 |
const std::vector<arma::vec>& all_coeffs_y |
|
| 126 |
) {
|
|
| 127 | ! |
size_t K = all_coeffs_x.size(); |
| 128 | ! |
arma::mat D(K, K, arma::fill::zeros); |
| 129 | ||
| 130 | ! |
for (size_t i = 0; i < K; ++i) {
|
| 131 | ! |
for (size_t j = i + 1; j < K; ++j) {
|
| 132 | ! |
double dist = integrated_curve_distance( |
| 133 | ! |
all_coeffs_x[i], all_coeffs_y[i], |
| 134 | ! |
all_coeffs_x[j], all_coeffs_y[j] |
| 135 |
); |
|
| 136 | ! |
D(i, j) = dist; |
| 137 | ! |
D(j, i) = dist; |
| 138 |
} |
|
| 139 |
} |
|
| 140 | ||
| 141 | ! |
return D; |
| 142 |
} |
|
| 143 | ||
| 144 |
} // namespace qe |
|
| 145 | ||
| 146 |
#endif // LIBQE_TRAJECTORY_DISTANCE_HPP |
| 1 |
/** @file trajectory_following.hpp |
|
| 2 |
* @brief Who-follows-whom leader-follower turn lag analysis along shared timeline. |
|
| 3 |
*/ |
|
| 4 |
#ifndef LIBQE_TRAJECTORY_FOLLOWING_HPP |
|
| 5 |
#define LIBQE_TRAJECTORY_FOLLOWING_HPP |
|
| 6 | ||
| 7 |
#include <armadillo> |
|
| 8 |
#include <vector> |
|
| 9 |
#include <cmath> |
|
| 10 |
#include <algorithm> |
|
| 11 |
#include <limits> |
|
| 12 | ||
| 13 |
namespace qe {
|
|
| 14 | ||
| 15 |
/** @brief Result of signed turn-lag sweep across delta in [-max_lag, max_lag]. */ |
|
| 16 |
struct SignedTurnLagResult {
|
|
| 17 |
int best_lag = 0; ///< delta* that minimizes mean distance |
|
| 18 |
double min_mean_distance = 0.0; |
|
| 19 |
arma::ivec lags; ///< Range of evaluated lags |
|
| 20 |
arma::vec mean_distances; ///< Mean distance for each lag |
|
| 21 |
arma::ivec valid_counts; ///< Number of paired turns for each lag |
|
| 22 |
}; |
|
| 23 | ||
| 24 |
/** @brief Compute mean distance between agent A at turn T and agent B at turn T - delta. |
|
| 25 |
* |
|
| 26 |
* @param[in] pts_a n_a × 2 coordinates for agent A. |
|
| 27 |
* @param[in] pts_b n_b × 2 coordinates for agent B. |
|
| 28 |
* @param[in] times_a Length n_a vector of calendar/turn indices for A. |
|
| 29 |
* @param[in] times_b Length n_b vector of calendar/turn indices for B. |
|
| 30 |
* @param[in] delta Signed lag in turns (delta > 0: A compares to B's past => A follows B; |
|
| 31 |
* delta < 0: B compares to A's past => B follows A; delta = 0: sync). |
|
| 32 |
* |
|
| 33 |
* @returns Pair of (mean_distance, valid_pairs_count). |
|
| 34 |
*/ |
|
| 35 | 9x |
inline std::pair<double, int> signed_turn_lag_distance( |
| 36 |
const arma::mat& pts_a, |
|
| 37 |
const arma::mat& pts_b, |
|
| 38 |
const arma::vec& times_a, |
|
| 39 |
const arma::vec& times_b, |
|
| 40 |
int delta |
|
| 41 |
) {
|
|
| 42 | 9x |
int n_a = static_cast<int>(pts_a.n_rows); |
| 43 | 9x |
int n_b = static_cast<int>(pts_b.n_rows); |
| 44 |
if (n_a == 0 || n_b == 0) return {0.0, 0};
|
|
| 45 | ||
| 46 | 9x |
double sum_dist = 0.0; |
| 47 | 9x |
int count = 0; |
| 48 | ||
| 49 | 54x |
for (int i = 0; i < n_a; ++i) {
|
| 50 | 45x |
double t_a = times_a[i]; |
| 51 | 45x |
double target_tb = t_a - static_cast<double>(delta); |
| 52 | ||
| 53 |
// Find the latest turn for agent B that occurred at or before target_tb |
|
| 54 | 45x |
int best_b_idx = -1; |
| 55 | 216x |
for (int j = 0; j < n_b; ++j) {
|
| 56 | 382x |
if (times_b[j] <= target_tb) {
|
| 57 | 171x |
best_b_idx = j; |
| 58 |
} else {
|
|
| 59 | 20x |
break; |
| 60 |
} |
|
| 61 |
} |
|
| 62 | ||
| 63 | 45x |
if (best_b_idx >= 0) {
|
| 64 | 84x |
double dx = pts_a(i, 0) - pts_b(best_b_idx, 0); |
| 65 | 84x |
double dy = pts_a(i, 1) - pts_b(best_b_idx, 1); |
| 66 | 42x |
sum_dist += std::sqrt(dx * dx + dy * dy); |
| 67 | 42x |
count++; |
| 68 |
} |
|
| 69 |
} |
|
| 70 | ||
| 71 |
double mean_d = (count > 0) ? (sum_dist / count) : 0.0; |
|
| 72 | 9x |
return {mean_d, count};
|
| 73 |
} |
|
| 74 | ||
| 75 |
/** @brief Sweep signed turn lags in [-max_lag, max_lag] to determine leader-follower dynamics. */ |
|
| 76 | 1x |
inline SignedTurnLagResult best_signed_turn_lag( |
| 77 |
const arma::mat& pts_a, |
|
| 78 |
const arma::mat& pts_b, |
|
| 79 |
const arma::vec& times_a, |
|
| 80 |
const arma::vec& times_b, |
|
| 81 |
int max_lag = 15 |
|
| 82 |
) {
|
|
| 83 | 1x |
int k_max = std::max(1, max_lag); |
| 84 | 1x |
int n_lags = 2 * k_max + 1; |
| 85 | ||
| 86 | 1x |
SignedTurnLagResult res; |
| 87 | 1x |
res.lags.set_size(n_lags); |
| 88 | 1x |
res.mean_distances.set_size(n_lags); |
| 89 | 1x |
res.valid_counts.set_size(n_lags); |
| 90 | ||
| 91 | 1x |
double min_dist = std::numeric_limits<double>::infinity(); |
| 92 | 1x |
int best_delta = 0; |
| 93 | ||
| 94 | 10x |
for (int idx = 0; idx < n_lags; ++idx) {
|
| 95 | 9x |
int delta = -k_max + idx; |
| 96 | 9x |
res.lags[idx] = delta; |
| 97 | ||
| 98 | 9x |
auto [mean_d, count] = signed_turn_lag_distance(pts_a, pts_b, times_a, times_b, delta); |
| 99 | 9x |
res.mean_distances[idx] = mean_d; |
| 100 | 9x |
res.valid_counts[idx] = count; |
| 101 | ||
| 102 | 9x |
if (count > 0 && mean_d < min_dist) {
|
| 103 | 5x |
min_dist = mean_d; |
| 104 | 5x |
best_delta = delta; |
| 105 |
} |
|
| 106 |
} |
|
| 107 | ||
| 108 | 1x |
res.best_lag = best_delta; |
| 109 |
res.min_mean_distance = (min_dist == std::numeric_limits<double>::infinity()) ? 0.0 : min_dist; |
|
| 110 | 1x |
return res; |
| 111 |
} |
|
| 112 | ||
| 113 |
} // namespace qe |
|
| 114 | ||
| 115 |
#endif // LIBQE_TRAJECTORY_FOLLOWING_HPP |
| 1 |
/** @file trajectory.hpp |
|
| 2 |
* @brief Parametric trajectory curve fitting, LOOCV-2D degree selection, |
|
| 3 |
* and differential geometry (velocity, acceleration, curvature, turns). |
|
| 4 |
*/ |
|
| 5 |
#ifndef LIBQE_TRAJECTORY_HPP |
|
| 6 |
#define LIBQE_TRAJECTORY_HPP |
|
| 7 | ||
| 8 |
#include <armadillo> |
|
| 9 |
#include <vector> |
|
| 10 |
#include <string> |
|
| 11 |
#include <cmath> |
|
| 12 |
#include <algorithm> |
|
| 13 |
#include <limits> |
|
| 14 | ||
| 15 |
#include "linalg_fallback.hpp" |
|
| 16 | ||
| 17 |
namespace qe {
|
|
| 18 | ||
| 19 |
/** @brief Polynomial curve fit result in 2D epistemic space. */ |
|
| 20 |
struct PolyCurveFit {
|
|
| 21 |
int degree = 1; |
|
| 22 |
arma::vec coeffs_x; ///< Raw coefficients [c0, ..., cd] for x(t) = sum(ck * t^k) |
|
| 23 |
arma::vec coeffs_y; ///< Raw coefficients [c0, ..., cd] for y(t) = sum(ck * t^k) |
|
| 24 |
std::string basis = "orthogonal"; ///< Basis used during fitting: "orthogonal" (R stats::poly-like) or "raw". |
|
| 25 |
arma::vec basis_alpha; ///< Orthogonal-basis recurrence alpha values (empty for raw basis). |
|
| 26 |
arma::vec basis_norm2; ///< Orthogonal-basis recurrence squared norms, matching stats::poly coefs$norm2. |
|
| 27 |
double cv_error = 0.0; ///< 2D LOOCV error |
|
| 28 |
double aic = 0.0; ///< Joint bivariate AIC |
|
| 29 |
arma::vec t; ///< Evaluated or input time points |
|
| 30 |
arma::mat fitted; ///< n × 2 fitted coordinates |
|
| 31 |
}; |
|
| 32 | ||
| 33 | 5x |
inline arma::mat build_raw_poly_design(const arma::vec& t_vec, int degree) {
|
| 34 | 5x |
int n = static_cast<int>(t_vec.n_elem); |
| 35 | 5x |
arma::mat X(n, degree + 1); |
| 36 | 50x |
for (int i = 0; i < n; ++i) {
|
| 37 | 45x |
double cur_t = t_vec[i]; |
| 38 | 45x |
double p = 1.0; |
| 39 | 183x |
for (int k = 0; k <= degree; ++k) {
|
| 40 | 138x |
X(i, k) = p; |
| 41 | 138x |
p *= cur_t; |
| 42 |
} |
|
| 43 |
} |
|
| 44 | 5x |
return X; |
| 45 |
} |
|
| 46 | ||
| 47 | 5x |
inline arma::vec poly_multiply_by_linear(const arma::vec& coeffs, double shift) {
|
| 48 | 5x |
arma::vec out(coeffs.n_elem + 1, arma::fill::zeros); |
| 49 | 17x |
for (size_t k = 0; k < coeffs.n_elem; ++k) {
|
| 50 | 24x |
out[k] += -shift * coeffs[k]; |
| 51 | 36x |
out[k + 1] += coeffs[k]; |
| 52 |
} |
|
| 53 | 5x |
return out; |
| 54 |
} |
|
| 55 | ||
| 56 | 4x |
inline arma::mat orthogonal_poly_basis_from_coefs( |
| 57 |
const arma::vec& t_vec, |
|
| 58 |
int degree, |
|
| 59 |
const arma::vec& alpha, |
|
| 60 |
const arma::vec& norm2 |
|
| 61 |
) {
|
|
| 62 | 4x |
int n = static_cast<int>(t_vec.n_elem); |
| 63 | 4x |
arma::mat Z(n, degree + 1, arma::fill::ones); |
| 64 | 4x |
if (degree >= 1) {
|
| 65 | 12x |
Z.col(1) = t_vec - alpha[0]; |
| 66 |
} |
|
| 67 | 9x |
for (int i = 2; i <= degree; ++i) {
|
| 68 | 30x |
Z.col(i) = (t_vec - alpha[i - 1]) % Z.col(i - 1) - |
| 69 | 25x |
(norm2[i] / norm2[i - 1]) * Z.col(i - 2); |
| 70 |
} |
|
| 71 | 17x |
for (int i = 0; i <= degree; ++i) {
|
| 72 | 39x |
Z.col(i) /= std::sqrt(norm2[i + 1]); |
| 73 |
} |
|
| 74 | 12x |
return Z.cols(1, degree); |
| 75 |
} |
|
| 76 | ||
| 77 | 4x |
inline arma::mat orthogonal_basis_raw_coefficients( |
| 78 |
int degree, |
|
| 79 |
const arma::vec& alpha, |
|
| 80 |
const arma::vec& norm2 |
|
| 81 |
) {
|
|
| 82 | 4x |
arma::mat raw(degree + 1, degree + 1, arma::fill::zeros); |
| 83 | 4x |
raw(0, 0) = 1.0; |
| 84 | 4x |
if (degree >= 1) {
|
| 85 | 4x |
arma::vec p1(2); |
| 86 | 12x |
p1[0] = -alpha[0]; |
| 87 | 4x |
p1[1] = 1.0; |
| 88 | 12x |
raw.submat(0, 1, 1, 1) = p1 / std::sqrt(norm2[2]); |
| 89 |
} |
|
| 90 | 4x |
arma::vec prev2(1); |
| 91 | 4x |
prev2[0] = 1.0; |
| 92 | 4x |
arma::vec prev1; |
| 93 | 4x |
if (degree >= 1) {
|
| 94 | 4x |
prev1.set_size(2); |
| 95 | 12x |
prev1[0] = -alpha[0]; |
| 96 | 4x |
prev1[1] = 1.0; |
| 97 |
} |
|
| 98 | 9x |
for (int i = 2; i <= degree; ++i) {
|
| 99 | 10x |
arma::vec term = poly_multiply_by_linear(prev1, alpha[i - 1]); |
| 100 | 5x |
arma::vec padded_prev2(term.n_elem, arma::fill::zeros); |
| 101 | 10x |
padded_prev2.subvec(0, prev2.n_elem - 1) = prev2; |
| 102 | 25x |
arma::vec current = term - (norm2[i] / norm2[i - 1]) * padded_prev2; |
| 103 | 20x |
raw.submat(0, i, i, i) = current / std::sqrt(norm2[i + 1]); |
| 104 | 5x |
prev2 = prev1; |
| 105 | 5x |
prev1 = current; |
| 106 |
} |
|
| 107 | 4x |
return raw; |
| 108 |
} |
|
| 109 | ||
| 110 | 4x |
inline bool modified_gram_schmidt_qr( |
| 111 |
const arma::mat& X, |
|
| 112 |
arma::mat& Q, |
|
| 113 |
arma::mat& R |
|
| 114 |
) {
|
|
| 115 | 4x |
const int n = static_cast<int>(X.n_rows); |
| 116 | 4x |
const int p = static_cast<int>(X.n_cols); |
| 117 | 4x |
Q.zeros(n, p); |
| 118 | 4x |
R.zeros(p, p); |
| 119 | ||
| 120 | 17x |
for (int k = 0; k < p; ++k) {
|
| 121 | 26x |
arma::vec v = X.col(k); |
| 122 | 29x |
for (int j = 0; j < k; ++j) {
|
| 123 | 48x |
R(j, k) = arma::dot(Q.col(j), v); |
| 124 | 64x |
v -= R(j, k) * Q.col(j); |
| 125 |
} |
|
| 126 | ||
| 127 | 13x |
double norm = std::sqrt(arma::dot(v, v)); |
| 128 | 13x |
if (norm <= 1e-12) {
|
| 129 | ! |
return false; |
| 130 |
} |
|
| 131 | ||
| 132 | 26x |
R(k, k) = norm; |
| 133 | 26x |
Q.col(k) = v / norm; |
| 134 |
} |
|
| 135 | ||
| 136 | 4x |
return true; |
| 137 |
} |
|
| 138 | ||
| 139 | 4x |
inline arma::mat build_orthogonal_poly_design( |
| 140 |
const arma::vec& t_vec, |
|
| 141 |
int degree, |
|
| 142 |
arma::vec& alpha, |
|
| 143 |
arma::vec& norm2 |
|
| 144 |
) {
|
|
| 145 | 4x |
int n = static_cast<int>(t_vec.n_elem); |
| 146 | 4x |
if (degree < 1) {
|
| 147 | ! |
return arma::mat(n, 0); |
| 148 |
} |
|
| 149 | 4x |
arma::vec unique_t = arma::unique(t_vec); |
| 150 | 4x |
if (static_cast<size_t>(degree) >= unique_t.n_elem) {
|
| 151 | ! |
throw std::invalid_argument("orthogonal polynomial degree must be less than number of unique time points.");
|
| 152 |
} |
|
| 153 | ||
| 154 | 4x |
double xbar = arma::mean(t_vec); |
| 155 | 4x |
arma::vec centered = t_vec - xbar; |
| 156 | 4x |
arma::mat X = build_raw_poly_design(centered, degree); |
| 157 | ||
| 158 | 4x |
arma::mat Q; |
| 159 | 4x |
arma::mat R; |
| 160 | 4x |
bool ok = modified_gram_schmidt_qr(X, Q, R); |
| 161 | 4x |
if (!ok) {
|
| 162 | ! |
throw std::invalid_argument("orthogonal polynomial degree must be less than number of unique time points.");
|
| 163 |
} |
|
| 164 | ||
| 165 | 8x |
arma::mat Z = Q * arma::diagmat(R.diag()); |
| 166 | 4x |
arma::vec z_norm2 = arma::sum(arma::square(Z), 0).t(); |
| 167 | ||
| 168 | 4x |
alpha.set_size(degree); |
| 169 | 13x |
for (int i = 0; i < degree; ++i) {
|
| 170 | 36x |
alpha[i] = arma::dot(centered, arma::square(Z.col(i))) / z_norm2[i] + xbar; |
| 171 |
} |
|
| 172 | ||
| 173 | 4x |
norm2.set_size(degree + 2); |
| 174 | 4x |
norm2[0] = 1.0; |
| 175 | 17x |
for (int i = 0; i <= degree; ++i) {
|
| 176 | 39x |
norm2[i + 1] = z_norm2[i]; |
| 177 |
} |
|
| 178 | ||
| 179 | 4x |
return orthogonal_poly_basis_from_coefs(t_vec, degree, alpha, norm2); |
| 180 |
} |
|
| 181 | ||
| 182 | 5x |
inline arma::mat build_poly_design( |
| 183 |
const arma::vec& t_vec, |
|
| 184 |
int degree, |
|
| 185 |
const std::string& basis, |
|
| 186 |
arma::vec& alpha, |
|
| 187 |
arma::vec& norm2, |
|
| 188 |
arma::mat& basis_raw_coeffs |
|
| 189 |
) {
|
|
| 190 | 5x |
int n = static_cast<int>(t_vec.n_elem); |
| 191 | 5x |
arma::mat X(n, degree + 1); |
| 192 | 5x |
X.col(0).ones(); |
| 193 | 5x |
if (basis == "orthogonal") {
|
| 194 | 4x |
arma::mat Z = build_orthogonal_poly_design(t_vec, degree, alpha, norm2); |
| 195 | 8x |
X.cols(1, degree) = Z; |
| 196 | 4x |
basis_raw_coeffs = orthogonal_basis_raw_coefficients(degree, alpha, norm2); |
| 197 | 5x |
} else if (basis == "raw") {
|
| 198 | 1x |
X = build_raw_poly_design(t_vec, degree); |
| 199 | 1x |
alpha.reset(); |
| 200 | 1x |
norm2.reset(); |
| 201 | 1x |
basis_raw_coeffs.eye(degree + 1, degree + 1); |
| 202 |
} else {
|
|
| 203 | ! |
throw std::invalid_argument("basis must be 'orthogonal' or 'raw'.");
|
| 204 |
} |
|
| 205 | 5x |
return X; |
| 206 |
} |
|
| 207 | ||
| 208 |
/** @brief Differential geometry profile along a trajectory curve. */ |
|
| 209 |
struct TrajectoryDerivatives {
|
|
| 210 |
arma::vec t; |
|
| 211 |
arma::vec vx; ///< dx/dt |
|
| 212 |
arma::vec vy; ///< dy/dt |
|
| 213 |
arma::vec speed; ///< sqrt(vx^2 + vy^2) |
|
| 214 |
arma::vec ax; ///< d^2x/dt^2 |
|
| 215 |
arma::vec ay; ///< d^2y/dt^2 |
|
| 216 |
arma::vec heading_rate; ///< omega = vx*ay - vy*ax (angular turning velocity) |
|
| 217 |
arma::vec curvature; ///< kappa = omega / max(speed^3, eps) |
|
| 218 |
}; |
|
| 219 | ||
| 220 |
/** @brief Evaluate a 1D polynomial sum(c_k * t^k) at a single time t. */ |
|
| 221 | 296x |
inline double eval_poly_scalar(const arma::vec& coeffs, double t) {
|
| 222 | 296x |
double val = 0.0; |
| 223 | 296x |
double t_pow = 1.0; |
| 224 | 956x |
for (size_t k = 0; k < coeffs.n_elem; ++k) {
|
| 225 | 660x |
val += coeffs[k] * t_pow; |
| 226 | 660x |
t_pow *= t; |
| 227 |
} |
|
| 228 | 296x |
return val; |
| 229 |
} |
|
| 230 | ||
| 231 |
/** @brief Evaluate polynomial 1st derivative sum(k * c_k * t^(k-1)) at a single time t. */ |
|
| 232 | 6x |
inline double eval_poly_deriv1_scalar(const arma::vec& coeffs, double t) {
|
| 233 | 6x |
double val = 0.0; |
| 234 | 6x |
double t_pow = 1.0; |
| 235 | 15x |
for (size_t k = 1; k < coeffs.n_elem; ++k) {
|
| 236 | 9x |
val += static_cast<double>(k) * coeffs[k] * t_pow; |
| 237 | 9x |
t_pow *= t; |
| 238 |
} |
|
| 239 | 6x |
return val; |
| 240 |
} |
|
| 241 | ||
| 242 |
/** @brief Evaluate polynomial 2nd derivative sum(k*(k-1) * c_k * t^(k-2)) at a single time t. */ |
|
| 243 | 6x |
inline double eval_poly_deriv2_scalar(const arma::vec& coeffs, double t) {
|
| 244 | 6x |
double val = 0.0; |
| 245 | 6x |
double t_pow = 1.0; |
| 246 | 9x |
for (size_t k = 2; k < coeffs.n_elem; ++k) {
|
| 247 | 3x |
val += static_cast<double>(k * (k - 1)) * coeffs[k] * t_pow; |
| 248 | 3x |
t_pow *= t; |
| 249 |
} |
|
| 250 | 6x |
return val; |
| 251 |
} |
|
| 252 | ||
| 253 |
/** @brief Evaluate 2D parametric curve (x(t), y(t)) at multiple time points. */ |
|
| 254 | 2x |
inline arma::mat eval_poly_curve( |
| 255 |
const arma::vec& coeffs_x, |
|
| 256 |
const arma::vec& coeffs_y, |
|
| 257 |
const arma::vec& t_eval |
|
| 258 |
) {
|
|
| 259 | 2x |
size_t n = t_eval.n_elem; |
| 260 | 2x |
arma::mat out(n, 2); |
| 261 | 22x |
for (size_t i = 0; i < n; ++i) {
|
| 262 | 20x |
double t = t_eval[i]; |
| 263 | 20x |
out(i, 0) = eval_poly_scalar(coeffs_x, t); |
| 264 | 40x |
out(i, 1) = eval_poly_scalar(coeffs_y, t); |
| 265 |
} |
|
| 266 | 2x |
return out; |
| 267 |
} |
|
| 268 | ||
| 269 |
/** @brief Fit a 2D polynomial curve with automatic LOOCV-2D or AIC degree selection. |
|
| 270 |
* |
|
| 271 |
* Given coordinates (x_i, y_i) and times t_i in [0, 1], constructs polynomial models |
|
| 272 |
* for degrees d in [1, max_degree]. LOOCV error is computed via the closed-form hat-matrix formula: |
|
| 273 |
* CV(d) = sum( (r_{x,i} / (1 - h_{ii}))^2 + (r_{y,i} / (1 - h_{ii}))^2 )
|
|
| 274 |
* |
|
| 275 |
* @param[in] points n × 2 matrix of trajectory coordinates. |
|
| 276 |
* @param[in] t Vector of length n of strictly increasing times (default [0, 1] linearly spaced). |
|
| 277 |
* @param[in] max_degree Maximum polynomial degree to evaluate (default 3). |
|
| 278 |
* @param[in] fixed_degree When >= 1, uses this exact degree without search. |
|
| 279 |
* @param[in] criterion "loocv" (default) or "aic". |
|
| 280 |
* |
|
| 281 |
* @returns PolyCurveFit containing optimal degree, coefficients, errors, and fitted points. |
|
| 282 |
*/ |
|
| 283 | 3x |
inline PolyCurveFit fit_poly_loocv2d( |
| 284 |
const arma::mat& points, |
|
| 285 |
const arma::vec& t = arma::vec(), |
|
| 286 |
int max_degree = 3, |
|
| 287 |
int fixed_degree = 0, |
|
| 288 |
const std::string& criterion = "loocv", |
|
| 289 |
const std::string& basis = "orthogonal" |
|
| 290 |
) {
|
|
| 291 | 3x |
int n = static_cast<int>(points.n_rows); |
| 292 | 3x |
if (n < 2 || points.n_cols < 2) {
|
| 293 | ! |
throw std::invalid_argument("fit_poly_loocv2d requires at least 2 points and 2 columns.");
|
| 294 |
} |
|
| 295 | ||
| 296 |
// Default normalized time t in [0, 1] |
|
| 297 | 3x |
arma::vec t_vec; |
| 298 | 3x |
if (t.n_elem == static_cast<size_t>(n)) {
|
| 299 | 3x |
t_vec = t; |
| 300 |
} else {
|
|
| 301 | ! |
t_vec = arma::linspace<arma::vec>(0.0, 1.0, n); |
| 302 |
} |
|
| 303 | ||
| 304 | 3x |
arma::vec x = points.col(0); |
| 305 | 3x |
arma::vec y = points.col(1); |
| 306 | ||
| 307 | 3x |
int deg_upper = std::min(max_degree, std::max(1, n - 2)); |
| 308 | 3x |
if (fixed_degree >= 1) {
|
| 309 | 2x |
deg_upper = std::min(fixed_degree, n - 1); |
| 310 |
} |
|
| 311 | ||
| 312 | 3x |
int best_deg = 1; |
| 313 | 3x |
double best_score = std::numeric_limits<double>::infinity(); |
| 314 | 3x |
PolyCurveFit best_fit; |
| 315 | ||
| 316 | 3x |
int deg_start = (fixed_degree >= 1) ? fixed_degree : 1; |
| 317 | ||
| 318 | 8x |
for (int d = deg_start; d <= deg_upper; ++d) {
|
| 319 | 5x |
int n_params = d + 1; |
| 320 |
if (n_params > n) break; |
|
| 321 | ||
| 322 | 5x |
arma::vec basis_alpha; |
| 323 | 5x |
arma::vec basis_norm2; |
| 324 | 5x |
arma::mat basis_raw_coeffs; |
| 325 | 5x |
arma::mat X = build_poly_design(t_vec, d, basis, basis_alpha, basis_norm2, basis_raw_coeffs); |
| 326 | ||
| 327 |
// Solve least squares: beta = (X^T X)^(-1) X^T y |
|
| 328 | 5x |
arma::mat XtX = X.t() * X; |
| 329 |
// Pseudo-inverse when singular; LAPACK-free in WASM builds. |
|
| 330 | 5x |
arma::mat XtX_inv = qe::linalg::inv_sym(XtX); |
| 331 | ||
| 332 | 5x |
arma::vec beta_x = XtX_inv * (X.t() * x); |
| 333 | 5x |
arma::vec beta_y = XtX_inv * (X.t() * y); |
| 334 | ||
| 335 | 5x |
arma::vec pred_x = X * beta_x; |
| 336 | 5x |
arma::vec pred_y = X * beta_y; |
| 337 | ||
| 338 | 10x |
arma::vec rx = x - pred_x; |
| 339 | 5x |
arma::vec ry = y - pred_y; |
| 340 | ||
| 341 |
// Hat matrix diagonal: h_ii = x_i^T (X^T X)^(-1) x_i |
|
| 342 | 5x |
arma::vec h(n); |
| 343 | 50x |
for (int i = 0; i < n; ++i) {
|
| 344 | 90x |
arma::rowvec xi = X.row(i); |
| 345 | 90x |
h[i] = arma::as_scalar(xi * XtX_inv * xi.t()); |
| 346 | 90x |
h[i] = std::min(0.9999, std::max(0.0, h[i])); |
| 347 |
} |
|
| 348 | ||
| 349 |
// 2D LOOCV error |
|
| 350 | 5x |
double cv_err = 0.0; |
| 351 | 50x |
for (int i = 0; i < n; ++i) {
|
| 352 | 45x |
double denom = 1.0 - h[i]; |
| 353 | 45x |
double rxi = rx[i] / denom; |
| 354 | 45x |
double ryi = ry[i] / denom; |
| 355 | 45x |
cv_err += (rxi * rxi + ryi * ryi); |
| 356 |
} |
|
| 357 | ||
| 358 |
// Bivariate AIC |
|
| 359 | 5x |
double rss = arma::dot(rx, rx) + arma::dot(ry, ry); |
| 360 | 5x |
double aic = static_cast<double>(n) * std::log(std::max(1e-12, rss / (2.0 * n))) + 2.0 * (2.0 * n_params); |
| 361 | ||
| 362 |
double score = (criterion == "aic") ? aic : cv_err; |
|
| 363 | ||
| 364 | 5x |
if (score < best_score || d == deg_start) {
|
| 365 | 3x |
best_score = score; |
| 366 | 3x |
best_deg = d; |
| 367 | 3x |
best_fit.degree = d; |
| 368 | 3x |
best_fit.coeffs_x = basis_raw_coeffs * beta_x; |
| 369 | 3x |
best_fit.coeffs_y = basis_raw_coeffs * beta_y; |
| 370 | 3x |
best_fit.basis = basis; |
| 371 | 3x |
best_fit.basis_alpha = basis_alpha; |
| 372 | 3x |
best_fit.basis_norm2 = basis_norm2; |
| 373 | 3x |
best_fit.cv_error = cv_err; |
| 374 | 3x |
best_fit.aic = aic; |
| 375 | 3x |
best_fit.t = t_vec; |
| 376 | 3x |
best_fit.fitted = arma::join_rows(pred_x, pred_y); |
| 377 |
} |
|
| 378 |
} |
|
| 379 | ||
| 380 | 3x |
return best_fit; |
| 381 |
} |
|
| 382 | ||
| 383 |
/** @brief Compute trajectory velocities, speeds, accelerations, heading turn rates, and curvatures. */ |
|
| 384 | 1x |
inline TrajectoryDerivatives eval_trajectory_derivatives( |
| 385 |
const arma::vec& coeffs_x, |
|
| 386 |
const arma::vec& coeffs_y, |
|
| 387 |
const arma::vec& t_eval |
|
| 388 |
) {
|
|
| 389 | 1x |
size_t n = t_eval.n_elem; |
| 390 | 1x |
TrajectoryDerivatives d; |
| 391 | 1x |
d.t = t_eval; |
| 392 | 1x |
d.vx.set_size(n); |
| 393 | 1x |
d.vy.set_size(n); |
| 394 | 1x |
d.speed.set_size(n); |
| 395 | 1x |
d.ax.set_size(n); |
| 396 | 1x |
d.ay.set_size(n); |
| 397 | 1x |
d.heading_rate.set_size(n); |
| 398 | 1x |
d.curvature.set_size(n); |
| 399 | ||
| 400 | 4x |
for (size_t i = 0; i < n; ++i) {
|
| 401 | 3x |
double t = t_eval[i]; |
| 402 | 3x |
double vx = eval_poly_deriv1_scalar(coeffs_x, t); |
| 403 | 3x |
double vy = eval_poly_deriv1_scalar(coeffs_y, t); |
| 404 | 3x |
double ax = eval_poly_deriv2_scalar(coeffs_x, t); |
| 405 | 3x |
double ay = eval_poly_deriv2_scalar(coeffs_y, t); |
| 406 | ||
| 407 | 3x |
double spd = std::sqrt(vx * vx + vy * vy); |
| 408 | 3x |
double omega = vx * ay - vy * ax; // angular velocity / heading rate |
| 409 | 3x |
double spd3 = spd * spd * spd; |
| 410 |
double kappa = (spd3 > 1e-12) ? (omega / spd3) : 0.0; |
|
| 411 | ||
| 412 | 3x |
d.vx[i] = vx; |
| 413 | 3x |
d.vy[i] = vy; |
| 414 | 3x |
d.speed[i] = spd; |
| 415 | 3x |
d.ax[i] = ax; |
| 416 | 3x |
d.ay[i] = ay; |
| 417 | 3x |
d.heading_rate[i] = omega; |
| 418 | 6x |
d.curvature[i] = kappa; |
| 419 |
} |
|
| 420 | ||
| 421 | 1x |
return d; |
| 422 |
} |
|
| 423 | ||
| 424 |
} // namespace qe |
|
| 425 | ||
| 426 |
#endif // LIBQE_TRAJECTORY_HPP |
| 1 |
// [[Rcpp::depends(RcppArmadillo)]] |
|
| 2 |
#include <RcppArmadillo.h> |
|
| 3 |
#include <libqe/libqe.hpp> |
|
| 4 | ||
| 5 |
using namespace Rcpp; |
|
| 6 | ||
| 7 |
// ============================================================================= |
|
| 8 |
// Adjacency utilities |
|
| 9 |
// ============================================================================= |
|
| 10 | ||
| 11 |
//' Number of upper-triangle pairs for n codes |
|
| 12 |
//' @param n Number of codes |
|
| 13 |
//' @return Integer: n*(n-1)/2 |
|
| 14 |
//' @examples |
|
| 15 |
//' choose_two(4L) # 6 code pairs |
|
| 16 |
//' @export |
|
| 17 |
// [[Rcpp::export]] |
|
| 18 | ! |
int choose_two(int n) {
|
| 19 | ! |
return qe::choose_two(n); |
| 20 |
} |
|
| 21 | ||
| 22 |
//' Number of undirected connection columns |
|
| 23 |
//' @param n Number of codes |
|
| 24 |
//' @param diagonal Include self-connections (A & A): n(n+1)/2 instead of choose(n, 2) |
|
| 25 |
//' @examples |
|
| 26 |
//' n_connections(4L) # 6: choose(4, 2) |
|
| 27 |
//' n_connections(4L, diagonal = TRUE) # 10: 4 * 5 / 2 |
|
| 28 |
//' @export |
|
| 29 |
// [[Rcpp::export]] |
|
| 30 | 2x |
int n_connections(int n, bool diagonal = false) {
|
| 31 | 2x |
return qe::n_connections(n, diagonal); |
| 32 |
} |
|
| 33 | ||
| 34 |
//' Upper-triangle index pairs |
|
| 35 |
//' @param len Number of codes (side length of square matrix) |
|
| 36 |
//' @param row -1 = both rows, 0 = row indices only, 1 = col indices only |
|
| 37 |
//' @param diagonal Include the diagonal (self-connections), in column-major |
|
| 38 |
//' order: (0,0), (0,1), (1,1), (0,2), ... |
|
| 39 |
//' @examples |
|
| 40 |
//' connection_indices(3L) # 2 x 3, 0-based (i, j) pairs |
|
| 41 |
//' connection_indices(3L, row = 0L) # row indices only |
|
| 42 |
//' connection_indices(3L, diagonal = TRUE) # includes (0,0), (1,1), (2,2) |
|
| 43 |
//' @export |
|
| 44 |
// [[Rcpp::export]] |
|
| 45 | 10x |
arma::umat connection_indices(int len, int row = -1, bool diagonal = false) {
|
| 46 | 10x |
return qe::connection_indices(len, row, diagonal); |
| 47 |
} |
|
| 48 | ||
| 49 |
//' Pairwise products as an upper-triangle vector |
|
| 50 |
//' @param v Numeric matrix with one row of code values |
|
| 51 |
//' @examples |
|
| 52 |
//' code_connections(matrix(c(1, 0, 1), nrow = 1)) # A&B = 0, A&C = 1, B&C = 0 |
|
| 53 |
//' @export |
|
| 54 |
// [[Rcpp::export]] |
|
| 55 | 2x |
arma::rowvec code_connections(arma::mat v) {
|
| 56 | 2x |
return qe::code_connections(v); |
| 57 |
} |
|
| 58 | ||
| 59 |
//' Fold a directed (n*n) vector into an undirected upper-triangle vector |
|
| 60 |
//' @param v Numeric vector of length n*n |
|
| 61 |
//' @param diagonal Keep the diagonal (self-connections, each once): A & A, A & B, B & B, ... |
|
| 62 |
//' @examples |
|
| 63 |
//' m <- matrix(c(0, 1, 2, |
|
| 64 |
//' 3, 0, 4, |
|
| 65 |
//' 5, 6, 0), nrow = 3, byrow = TRUE) |
|
| 66 |
//' fold_directed_network(as.vector(m)) # A&B, A&C, B&C |
|
| 67 |
//' fold_directed_network(as.vector(m), diagonal = TRUE) |
|
| 68 |
//' @export |
|
| 69 |
// [[Rcpp::export]] |
|
| 70 | 7x |
arma::rowvec fold_directed_network(arma::vec v, bool diagonal = false) {
|
| 71 | 14x |
return qe::fold_directed_network(v, diagonal); |
| 72 |
} |
|
| 73 | ||
| 74 |
//' Flatten an adjacency matrix to a connection vector |
|
| 75 |
//' @param x Numeric matrix |
|
| 76 |
//' @param full TRUE = full n*n (directed); FALSE = upper triangle (undirected) |
|
| 77 |
//' @param diagonal With full = FALSE: include the diagonal (A & A, A & B, B & B, ...) |
|
| 78 |
//' @examples |
|
| 79 |
//' m <- matrix(c(0, 1, 2, |
|
| 80 |
//' 1, 0, 3, |
|
| 81 |
//' 2, 3, 0), nrow = 3) |
|
| 82 |
//' network_to_vector(m, full = FALSE) # upper triangle: 1 2 3 |
|
| 83 |
//' network_to_vector(m, full = TRUE) # all 9 entries |
|
| 84 |
//' @export |
|
| 85 |
// [[Rcpp::export]] |
|
| 86 | 3x |
arma::rowvec network_to_vector(arma::mat x, bool full = true, bool diagonal = false) {
|
| 87 | 6x |
return qe::network_to_vector(x, full, diagonal); |
| 88 |
} |
|
| 89 | ||
| 90 |
//' Code-name pairs for upper-triangle positions ("A & B")
|
|
| 91 |
//' @param v Character vector of code names |
|
| 92 |
//' @param diagonal Include self-connections: "A & A", "A & B", "B & B", ... |
|
| 93 |
//' @examples |
|
| 94 |
//' connection_names(c("A", "B", "C"))
|
|
| 95 |
//' connection_names(c("A", "B", "C"), diagonal = TRUE)
|
|
| 96 |
//' @export |
|
| 97 |
// [[Rcpp::export]] |
|
| 98 | 4x |
std::vector<std::string> connection_names(std::vector<std::string> v, bool diagonal = false) {
|
| 99 | 8x |
return qe::connection_names(v, diagonal); |
| 100 |
} |
|
| 101 | ||
| 102 |
// ============================================================================= |
|
| 103 |
// Normalization |
|
| 104 |
// ============================================================================= |
|
| 105 | ||
| 106 |
//' Row-wise L2 (sphere) normalization |
|
| 107 |
//' @param m Numeric matrix |
|
| 108 |
//' @examples |
|
| 109 |
//' m <- matrix(c(3, 4, 0, |
|
| 110 |
//' 0, 0, 0, |
|
| 111 |
//' 1, 1, 1), nrow = 3, byrow = TRUE) |
|
| 112 |
//' normalize_networks(m) # rows have unit length; the zero row is unchanged |
|
| 113 |
//' @export |
|
| 114 |
// [[Rcpp::export]] |
|
| 115 | 4x |
arma::mat normalize_networks(arma::mat m) {
|
| 116 | 4x |
return qe::normalize_networks(m); |
| 117 |
} |
|
| 118 | ||
| 119 |
//' Max-norm scaling (divide all rows by the largest row L2 norm) |
|
| 120 |
//' @param m Numeric matrix |
|
| 121 |
//' @examples |
|
| 122 |
//' m <- matrix(c(3, 4, |
|
| 123 |
//' 1, 0), nrow = 2, byrow = TRUE) |
|
| 124 |
//' scale_networks(m) # divided by 5, the largest row norm |
|
| 125 |
//' @export |
|
| 126 |
// [[Rcpp::export]] |
|
| 127 | 3x |
arma::mat scale_networks(arma::mat m) {
|
| 128 | 3x |
return qe::scale_networks(m); |
| 129 |
} |
|
| 130 | ||
| 131 |
// ============================================================================= |
|
| 132 |
// Modeling |
|
| 133 |
// ============================================================================= |
|
| 134 | ||
| 135 |
//' Center points (subtract column means) |
|
| 136 |
//' @param values Numeric matrix |
|
| 137 |
//' @examples |
|
| 138 |
//' pts <- matrix(c(1, 2, 3, 10, 20, 30), ncol = 2) |
|
| 139 |
//' center_points(pts) # column means are now 0 |
|
| 140 |
//' @export |
|
| 141 |
// [[Rcpp::export]] |
|
| 142 | 2x |
arma::mat center_points(arma::mat values) {
|
| 143 | 2x |
return qe::center_points(values); |
| 144 |
} |
|
| 145 | ||
| 146 |
//' Confidence interval for the mean of a group of ENA unit points |
|
| 147 |
//' |
|
| 148 |
//' For each dimension computes a t-based CI: |
|
| 149 |
//' \code{mean +/- t(alpha/2, n-1) * (SD / sqrt(n))} where \code{alpha = 1 - conf_level}.
|
|
| 150 |
//' |
|
| 151 |
//' @param points Numeric matrix (units x dims), one row per unit in the group |
|
| 152 |
//' @param conf_level Confidence level (default 0.95) |
|
| 153 |
//' @return Numeric matrix (n_dims x 3): columns are [mean, ci_lower, ci_upper]. |
|
| 154 |
//' When \code{nrow(points) == 1} the CI bounds are \code{-Inf} and \code{Inf}.
|
|
| 155 |
//' @examples |
|
| 156 |
//' pts <- matrix(c(1, 2, 3, 4, 10, 12, 11, 13), ncol = 2) |
|
| 157 |
//' mean_ci(pts) # rows = dimensions; columns = mean, lower, upper |
|
| 158 |
//' t.test(pts[, 1])$conf.int # same bounds as row 1 |
|
| 159 |
//' @export |
|
| 160 |
// [[Rcpp::export]] |
|
| 161 | 14x |
arma::mat mean_ci(arma::mat points, double conf_level = 0.95) {
|
| 162 | 14x |
return qe::mean_ci(points, conf_level); |
| 163 |
} |
|
| 164 | ||
| 165 |
//' Outlier interval based on IQR (Tukey fence) for a group of ENA unit points |
|
| 166 |
//' |
|
| 167 |
//' For each dimension: \code{lower = -iqr_factor * IQR}, \code{upper = +iqr_factor * IQR}.
|
|
| 168 |
//' Equivalent to rENA's: |
|
| 169 |
//' \preformatted{
|
|
| 170 |
//' oi <- c(IQR(pts[,1]), ...) * iqr_factor |
|
| 171 |
//' matrix(rep(oi, 2), ncol = n_dims, byrow = TRUE) * c(-1, 1) |
|
| 172 |
//' } |
|
| 173 |
//' IQR uses R's default \code{type = 7} quantile.
|
|
| 174 |
//' |
|
| 175 |
//' @param points Numeric matrix (units x dims), one row per unit in the group |
|
| 176 |
//' @param iqr_factor Multiplier applied to the IQR (default 1.5, the Tukey fence) |
|
| 177 |
//' @return Numeric matrix (n_dims x 2): columns are [lower, upper]. |
|
| 178 |
//' @examples |
|
| 179 |
//' pts <- matrix(c(1, 2, 3, 4, 10, 12, 11, 13), ncol = 2) |
|
| 180 |
//' outlier_ci(pts) # rows = dimensions; columns = -1.5 * IQR, +1.5 * IQR |
|
| 181 |
//' @export |
|
| 182 |
// [[Rcpp::export]] |
|
| 183 | 6x |
arma::mat outlier_ci(arma::mat points, double iqr_factor = 1.5) {
|
| 184 | 6x |
return qe::outlier_ci(points, iqr_factor); |
| 185 |
} |
|
| 186 | ||
| 187 |
//' Two-group comparison statistics |
|
| 188 |
//' |
|
| 189 |
//' Computes per-dimension parametric and non-parametric statistics for two |
|
| 190 |
//' groups of ENA unit points. Equivalent to \code{group.stats()} in rENA-api,
|
|
| 191 |
//' moved to the C++ layer so all language bindings share a single implementation. |
|
| 192 |
//' |
|
| 193 |
//' \strong{Parametric (Welch two-sample t-test):}
|
|
| 194 |
//' \itemize{
|
|
| 195 |
//' \item \code{t} - Welch t-statistics
|
|
| 196 |
//' \item \code{parameter} - Welch-Satterthwaite degrees of freedom
|
|
| 197 |
//' \item \code{pvalue} - two-tailed p-values
|
|
| 198 |
//' \item \code{effect} - Cohen's d (pooled SD: \code{(mean1 - mean2) / pooled_sd})
|
|
| 199 |
//' \item \code{mean} - 2 x n_dims matrix, row 1 = group1, row 2 = group2
|
|
| 200 |
//' \item \code{std.dev} - 2 x n_dims matrix of sample standard deviations
|
|
| 201 |
//' } |
|
| 202 |
//' |
|
| 203 |
//' \strong{Non-parametric (Wilcoxon rank-sum):}
|
|
| 204 |
//' \itemize{
|
|
| 205 |
//' \item \code{U} - rank-sum U for group 1 (= R's \code{wilcox.test} W)
|
|
| 206 |
//' \item \code{pvalue} - two-tailed p-values (normal approx, tie + continuity correction)
|
|
| 207 |
//' \item \code{effect} - rank-biserial: \code{1 - 2 * U / (n1 * n2)}
|
|
| 208 |
//' \item \code{median} - 2 x n_dims matrix of medians
|
|
| 209 |
//' } |
|
| 210 |
//' |
|
| 211 |
//' Entries are \code{NA} when a statistic is undefined (e.g. n < 2 for
|
|
| 212 |
//' parametric tests). |
|
| 213 |
//' |
|
| 214 |
//' @param g1 Numeric matrix (n1 x n_dims), unit points for group 1 |
|
| 215 |
//' @param g2 Numeric matrix (n2 x n_dims), unit points for group 2 |
|
| 216 |
//' @return Named list with \code{N}, \code{parametric}, and \code{nonparametric}
|
|
| 217 |
//' sub-lists, matching the structure of rENA-api's \code{group.stats()}.
|
|
| 218 |
//' @examples |
|
| 219 |
//' g1 <- matrix(c(1, 2, 3, 4, 10, 11, 12, 13), ncol = 2) |
|
| 220 |
//' g2 <- matrix(c(2, 3, 5, 6, 9, 9, 10, 12), ncol = 2) |
|
| 221 |
//' s <- group_stats(g1, g2) |
|
| 222 |
//' s$N |
|
| 223 |
//' s$parametric$t |
|
| 224 |
//' s$nonparametric$U |
|
| 225 |
//' @export |
|
| 226 |
// [[Rcpp::export]] |
|
| 227 | ! |
List group_stats(arma::mat g1, arma::mat g2) {
|
| 228 | ! |
qe::GroupStats s = qe::group_stats(g1, g2); |
| 229 |
return List::create( |
|
| 230 | ! |
_("N") = IntegerVector::create(s.n1, s.n2),
|
| 231 | ! |
_("parametric") = List::create(
|
| 232 | ! |
_("t") = s.t,
|
| 233 | ! |
_("parameter") = s.df,
|
| 234 | ! |
_("pvalue") = s.pvalue_t,
|
| 235 | ! |
_("effect") = s.cohens_d,
|
| 236 | ! |
_("mean") = s.means,
|
| 237 | ! |
_("std.dev") = s.sds
|
| 238 |
), |
|
| 239 | ! |
_("nonparametric") = List::create(
|
| 240 | ! |
_("U") = s.U,
|
| 241 | ! |
_("pvalue") = s.pvalue_u,
|
| 242 | ! |
_("effect") = s.effect_r,
|
| 243 | ! |
_("median") = s.medians
|
| 244 |
) |
|
| 245 |
); |
|
| 246 |
} |
|
| 247 | ||
| 248 |
// ============================================================================= |
|
| 249 |
// Door Temporal Pooling & Smoothing |
|
| 250 |
// ============================================================================= |
|
| 251 | ||
| 252 |
//' Door lookback window pooling for a single block |
|
| 253 |
//' @param block Numeric matrix of connection counts |
|
| 254 |
//' @param lookback_size Window lookback size |
|
| 255 |
//' @param aggregate_mean Logical: TRUE = mean, FALSE = sum |
|
| 256 |
//' @param weighting_linear Logical: TRUE = linear weights, FALSE = equal |
|
| 257 |
//' @param segment_ids Integer vector of segment IDs (0-indexed or 1-indexed) |
|
| 258 |
//' @examples |
|
| 259 |
//' block <- matrix(c(1, 0, |
|
| 260 |
//' 2, 1, |
|
| 261 |
//' 3, 0, |
|
| 262 |
//' 4, 2), nrow = 4, byrow = TRUE) |
|
| 263 |
//' door_lookback_block(block, lookback_size = 2L) # sums |
|
| 264 |
//' door_lookback_block(block, lookback_size = 2L, aggregate_mean = TRUE) # means |
|
| 265 |
//' @export |
|
| 266 |
// [[Rcpp::export]] |
|
| 267 | 5x |
arma::mat door_lookback_block( |
| 268 |
arma::mat block, |
|
| 269 |
int lookback_size = 20, |
|
| 270 |
bool aggregate_mean = false, |
|
| 271 |
bool weighting_linear = false, |
|
| 272 |
IntegerVector segment_ids = IntegerVector() |
|
| 273 |
) {
|
|
| 274 | 5x |
std::vector<int> segs(segment_ids.begin(), segment_ids.end()); |
| 275 | 10x |
return qe::lookback_block(block, lookback_size, aggregate_mean, weighting_linear, segs); |
| 276 |
} |
|
| 277 | ||
| 278 |
//' Door EMA smoothing for a single block |
|
| 279 |
//' @param block Numeric matrix of connection counts |
|
| 280 |
//' @param alpha Smoothing factor in (0, 1] |
|
| 281 |
//' @param segment_ids Integer vector of segment IDs |
|
| 282 |
//' @examples |
|
| 283 |
//' block <- matrix(c(10, 0, |
|
| 284 |
//' 0, 10, |
|
| 285 |
//' 0, 0), nrow = 3, byrow = TRUE) |
|
| 286 |
//' door_ema_block(block, alpha = 0.5) |
|
| 287 |
//' @export |
|
| 288 |
// [[Rcpp::export]] |
|
| 289 | 2x |
arma::mat door_ema_block( |
| 290 |
arma::mat block, |
|
| 291 |
double alpha = 0.1, |
|
| 292 |
IntegerVector segment_ids = IntegerVector() |
|
| 293 |
) {
|
|
| 294 | 2x |
std::vector<int> segs(segment_ids.begin(), segment_ids.end()); |
| 295 | 4x |
return qe::ema_block(block, alpha, segs); |
| 296 |
} |
|
| 297 | ||
| 298 |
//' Door lookback window pooling across units |
|
| 299 |
//' @param conn_counts Numeric matrix of connection counts |
|
| 300 |
//' @param unit_row_indices List of integer vectors of row indices (0-indexed or 1-indexed R converted) |
|
| 301 |
//' @param lookback_sizes Integer vector of lookback sizes per unit |
|
| 302 |
//' @param aggregate_mean Logical: TRUE = mean, FALSE = sum |
|
| 303 |
//' @param weighting_linear Logical: TRUE = linear weights, FALSE = equal |
|
| 304 |
//' @param segment_ids Optional integer vector of segment IDs |
|
| 305 |
//' @examples |
|
| 306 |
//' counts <- matrix(c(1, 0, |
|
| 307 |
//' 2, 1, |
|
| 308 |
//' 5, 5, |
|
| 309 |
//' 6, 5), nrow = 4, byrow = TRUE) |
|
| 310 |
//' # two units, rows given 0-based; each unit pools within its own rows |
|
| 311 |
//' door_lookback(counts, list(c(0L, 1L), c(2L, 3L)), lookback_sizes = c(2L, 2L)) |
|
| 312 |
//' @export |
|
| 313 |
// [[Rcpp::export]] |
|
| 314 | ! |
arma::mat door_lookback( |
| 315 |
arma::mat conn_counts, |
|
| 316 |
List unit_row_indices, |
|
| 317 |
IntegerVector lookback_sizes, |
|
| 318 |
bool aggregate_mean = false, |
|
| 319 |
bool weighting_linear = false, |
|
| 320 |
IntegerVector segment_ids = IntegerVector() |
|
| 321 |
) {
|
|
| 322 | ! |
std::vector<std::vector<int>> unit_rows; |
| 323 | ! |
unit_rows.reserve(unit_row_indices.size()); |
| 324 | ! |
for (int i = 0; i < unit_row_indices.size(); ++i) {
|
| 325 | ! |
IntegerVector iv = unit_row_indices[i]; |
| 326 | ! |
unit_rows.push_back(std::vector<int>(iv.begin(), iv.end())); |
| 327 |
} |
|
| 328 | ! |
std::vector<int> lbs(lookback_sizes.begin(), lookback_sizes.end()); |
| 329 | ! |
std::vector<int> segs(segment_ids.begin(), segment_ids.end()); |
| 330 | ||
| 331 | ! |
return qe::apply_door_lookback(conn_counts, unit_rows, lbs, aggregate_mean, weighting_linear, segs); |
| 332 |
} |
|
| 333 | ||
| 334 |
//' Door EMA smoothing across units |
|
| 335 |
//' @param conn_counts Numeric matrix of connection counts |
|
| 336 |
//' @param unit_row_indices List of integer vectors of row indices |
|
| 337 |
//' @param alpha Smoothing factor |
|
| 338 |
//' @param segment_ids Optional integer vector of segment IDs |
|
| 339 |
//' @examples |
|
| 340 |
//' counts <- matrix(c(10, 0, |
|
| 341 |
//' 0, 10, |
|
| 342 |
//' 10, 0, |
|
| 343 |
//' 0, 10), nrow = 4, byrow = TRUE) |
|
| 344 |
//' door_ema(counts, list(c(0L, 1L), c(2L, 3L)), alpha = 0.5) |
|
| 345 |
//' @export |
|
| 346 |
// [[Rcpp::export]] |
|
| 347 | ! |
arma::mat door_ema( |
| 348 |
arma::mat conn_counts, |
|
| 349 |
List unit_row_indices, |
|
| 350 |
double alpha = 0.1, |
|
| 351 |
IntegerVector segment_ids = IntegerVector() |
|
| 352 |
) {
|
|
| 353 | ! |
std::vector<std::vector<int>> unit_rows; |
| 354 | ! |
unit_rows.reserve(unit_row_indices.size()); |
| 355 | ! |
for (int i = 0; i < unit_row_indices.size(); ++i) {
|
| 356 | ! |
IntegerVector iv = unit_row_indices[i]; |
| 357 | ! |
unit_rows.push_back(std::vector<int>(iv.begin(), iv.end())); |
| 358 |
} |
|
| 359 | ! |
std::vector<int> segs(segment_ids.begin(), segment_ids.end()); |
| 360 | ||
| 361 | ! |
return qe::apply_door_ema(conn_counts, unit_rows, alpha, segs); |
| 362 |
} |
|
| 363 | ||
| 364 |
// ============================================================================= |
|
| 365 |
// Trajectory Curve Fitting & Differential Geometry |
|
| 366 |
// ============================================================================= |
|
| 367 | ||
| 368 |
//' Fit 2D parametric polynomial curve with LOOCV-2D or AIC degree selection |
|
| 369 |
//' @param points Numeric matrix (n x 2) of trajectory coordinates |
|
| 370 |
//' @param t Numeric vector of time values (length n) |
|
| 371 |
//' @param max_degree Maximum polynomial degree (default 3) |
|
| 372 |
//' @param fixed_degree Exact degree to use when >= 1 |
|
| 373 |
//' @param criterion Criterion for degree selection: "loocv" or "aic" |
|
| 374 |
//' @param basis Polynomial basis used for fitting: "orthogonal" or "raw" |
|
| 375 |
//' @examples |
|
| 376 |
//' t <- seq(0, 1, length.out = 6) |
|
| 377 |
//' pts <- cbind(1 + 2 * t, 4 - 3 * t) # a straight line |
|
| 378 |
//' fit <- fit_trajectory_poly(pts, t = t, fixed_degree = 1L, basis = "raw") |
|
| 379 |
//' fit$degree |
|
| 380 |
//' fit$coeffs_x # intercept 1, slope 2 |
|
| 381 |
//' fit$coeffs_y # intercept 4, slope -3 |
|
| 382 |
//' @export |
|
| 383 |
// [[Rcpp::export]] |
|
| 384 | 3x |
List fit_trajectory_poly( |
| 385 |
arma::mat points, |
|
| 386 |
NumericVector t = NumericVector(), |
|
| 387 |
int max_degree = 3, |
|
| 388 |
int fixed_degree = 0, |
|
| 389 |
std::string criterion = "loocv", |
|
| 390 |
std::string basis = "orthogonal" |
|
| 391 |
) {
|
|
| 392 | 3x |
arma::vec t_vec; |
| 393 | 3x |
if (t.size() > 0) {
|
| 394 | 3x |
t_vec = arma::vec(t.begin(), t.size()); |
| 395 |
} |
|
| 396 | 3x |
qe::PolyCurveFit fit = qe::fit_poly_loocv2d(points, t_vec, max_degree, fixed_degree, criterion, basis); |
| 397 | ||
| 398 |
return List::create( |
|
| 399 | 6x |
Named("degree") = fit.degree,
|
| 400 | 6x |
Named("coeffs_x") = fit.coeffs_x,
|
| 401 | 6x |
Named("coeffs_y") = fit.coeffs_y,
|
| 402 | 6x |
Named("basis") = fit.basis,
|
| 403 | 6x |
Named("basis_alpha") = fit.basis_alpha,
|
| 404 | 6x |
Named("basis_norm2") = fit.basis_norm2,
|
| 405 | 6x |
Named("cv_error") = fit.cv_error,
|
| 406 | 6x |
Named("aic") = fit.aic,
|
| 407 | 6x |
Named("t") = fit.t,
|
| 408 | 6x |
Named("fitted_points") = fit.fitted
|
| 409 |
); |
|
| 410 |
} |
|
| 411 | ||
| 412 |
//' Evaluate 2D parametric polynomial curve at specified time points |
|
| 413 |
//' @param coeffs_x Polynomial coefficients for X |
|
| 414 |
//' @param coeffs_y Polynomial coefficients for Y |
|
| 415 |
//' @param t_eval Numeric vector of time evaluation points |
|
| 416 |
//' @examples |
|
| 417 |
//' # x(t) = 1 + 2 t, y(t) = 4 - 3 t (raw polynomial coefficients) |
|
| 418 |
//' eval_trajectory_curve(c(1, 2), c(4, -3), c(0, 0.5, 1)) |
|
| 419 |
//' @export |
|
| 420 |
// [[Rcpp::export]] |
|
| 421 | 2x |
arma::mat eval_trajectory_curve( |
| 422 |
arma::vec coeffs_x, |
|
| 423 |
arma::vec coeffs_y, |
|
| 424 |
arma::vec t_eval |
|
| 425 |
) {
|
|
| 426 | 2x |
return qe::eval_poly_curve(coeffs_x, coeffs_y, t_eval); |
| 427 |
} |
|
| 428 | ||
| 429 |
//' Evaluate trajectory differential geometry (velocities, speed, curvature, turns) |
|
| 430 |
//' @param coeffs_x Polynomial coefficients for X |
|
| 431 |
//' @param coeffs_y Polynomial coefficients for Y |
|
| 432 |
//' @param t_eval Numeric vector of time evaluation points |
|
| 433 |
//' @examples |
|
| 434 |
//' # x(t) = t, y(t) = t^2 |
|
| 435 |
//' d <- eval_trajectory_derivatives(c(0, 1), c(0, 0, 1), c(0, 0.5, 1)) |
|
| 436 |
//' d$speed |
|
| 437 |
//' d$curvature |
|
| 438 |
//' @export |
|
| 439 |
// [[Rcpp::export]] |
|
| 440 | 1x |
List eval_trajectory_derivatives( |
| 441 |
arma::vec coeffs_x, |
|
| 442 |
arma::vec coeffs_y, |
|
| 443 |
arma::vec t_eval |
|
| 444 |
) {
|
|
| 445 | 1x |
qe::TrajectoryDerivatives d = qe::eval_trajectory_derivatives(coeffs_x, coeffs_y, t_eval); |
| 446 |
return List::create( |
|
| 447 | 2x |
Named("t") = d.t,
|
| 448 | 2x |
Named("vx") = d.vx,
|
| 449 | 2x |
Named("vy") = d.vy,
|
| 450 | 2x |
Named("speed") = d.speed,
|
| 451 | 2x |
Named("ax") = d.ax,
|
| 452 | 2x |
Named("ay") = d.ay,
|
| 453 | 2x |
Named("heading_rate") = d.heading_rate,
|
| 454 | 2x |
Named("curvature") = d.curvature
|
| 455 |
); |
|
| 456 |
} |
|
| 457 | ||
| 458 |
// ============================================================================= |
|
| 459 |
// Trajectory Distance & Following Dynamics |
|
| 460 |
// ============================================================================= |
|
| 461 | ||
| 462 |
//' Integrated Euclidean distance between two trajectory polynomial curves |
|
| 463 |
//' @param coeffs_ax Polynomial coeffs for Curve A X |
|
| 464 |
//' @param coeffs_ay Polynomial coeffs for Curve A Y |
|
| 465 |
//' @param coeffs_bx Polynomial coeffs for Curve B X |
|
| 466 |
//' @param coeffs_by Polynomial coeffs for Curve B Y |
|
| 467 |
//' @param t_start Integration start (default 0.0) |
|
| 468 |
//' @param t_end Integration end (default 1.0) |
|
| 469 |
//' @examples |
|
| 470 |
//' cx <- c(0, 1); cy <- c(0, 2) |
|
| 471 |
//' integrated_trajectory_distance(cx, cy, cx, cy) # 0: same curve |
|
| 472 |
//' integrated_trajectory_distance(cx, cy, cx + c(3, 0), cy + c(4, 0)) # 5: parallel offset |
|
| 473 |
//' @export |
|
| 474 |
// [[Rcpp::export]] |
|
| 475 | 2x |
double integrated_trajectory_distance( |
| 476 |
arma::vec coeffs_ax, |
|
| 477 |
arma::vec coeffs_ay, |
|
| 478 |
arma::vec coeffs_bx, |
|
| 479 |
arma::vec coeffs_by, |
|
| 480 |
double t_start = 0.0, |
|
| 481 |
double t_end = 1.0 |
|
| 482 |
) {
|
|
| 483 | 2x |
return qe::integrated_curve_distance(coeffs_ax, coeffs_ay, coeffs_bx, coeffs_by, t_start, t_end); |
| 484 |
} |
|
| 485 | ||
| 486 |
//' Lagged curve distance between follower and leader |
|
| 487 |
//' @param coeffs_fol_x Polynomial coeffs for Follower X |
|
| 488 |
//' @param coeffs_fol_y Polynomial coeffs for Follower Y |
|
| 489 |
//' @param coeffs_ldr_x Polynomial coeffs for Leader X |
|
| 490 |
//' @param coeffs_ldr_y Polynomial coeffs for Leader Y |
|
| 491 |
//' @param lag Time lag in [0, 1) |
|
| 492 |
//' @examples |
|
| 493 |
//' cx <- c(0, 1); cy <- c(0, 2) |
|
| 494 |
//' lagged_trajectory_distance(cx, cy, cx, cy, lag = 0) |
|
| 495 |
//' lagged_trajectory_distance(cx, cy, cx, cy, lag = 0.25) |
|
| 496 |
//' @export |
|
| 497 |
// [[Rcpp::export]] |
|
| 498 | ! |
double lagged_trajectory_distance( |
| 499 |
arma::vec coeffs_fol_x, |
|
| 500 |
arma::vec coeffs_fol_y, |
|
| 501 |
arma::vec coeffs_ldr_x, |
|
| 502 |
arma::vec coeffs_ldr_y, |
|
| 503 |
double lag = 0.0 |
|
| 504 |
) {
|
|
| 505 | ! |
return qe::lagged_curve_distance(coeffs_fol_x, coeffs_fol_y, coeffs_ldr_x, coeffs_ldr_y, lag); |
| 506 |
} |
|
| 507 | ||
| 508 |
//' Pairwise integrated trajectory distance matrix |
|
| 509 |
//' @param all_coeffs_x List of X coefficient vectors |
|
| 510 |
//' @param all_coeffs_y List of Y coefficient vectors |
|
| 511 |
//' @examples |
|
| 512 |
//' cx <- list(c(0, 1), c(3, 1), c(0, 1)) |
|
| 513 |
//' cy <- list(c(0, 2), c(4, 2), c(0, 2)) |
|
| 514 |
//' pairwise_trajectory_distance(cx, cy) # 3 x 3; curves 1 and 3 coincide |
|
| 515 |
//' @export |
|
| 516 |
// [[Rcpp::export]] |
|
| 517 | ! |
arma::mat pairwise_trajectory_distance( |
| 518 |
List all_coeffs_x, |
|
| 519 |
List all_coeffs_y |
|
| 520 |
) {
|
|
| 521 | ! |
std::vector<arma::vec> cx; |
| 522 | ! |
std::vector<arma::vec> cy; |
| 523 | ! |
cx.reserve(all_coeffs_x.size()); |
| 524 | ! |
cy.reserve(all_coeffs_y.size()); |
| 525 | ! |
for (int i = 0; i < all_coeffs_x.size(); ++i) {
|
| 526 | ! |
NumericVector nx = all_coeffs_x[i]; |
| 527 | ! |
NumericVector ny = all_coeffs_y[i]; |
| 528 | ! |
cx.push_back(arma::vec(nx.begin(), nx.size())); |
| 529 | ! |
cy.push_back(arma::vec(ny.begin(), ny.size())); |
| 530 |
} |
|
| 531 | ! |
return qe::pairwise_trajectory_distance_matrix(cx, cy); |
| 532 |
} |
|
| 533 | ||
| 534 |
//' Signed turn-lag distance between two agents at specific lag delta |
|
| 535 |
//' @param pts_a Points matrix for agent A (n_a x 2) |
|
| 536 |
//' @param pts_b Points matrix for agent B (n_b x 2) |
|
| 537 |
//' @param times_a Turn times for agent A |
|
| 538 |
//' @param times_b Turn times for agent B |
|
| 539 |
//' @param delta Signed lag in turns |
|
| 540 |
//' @examples |
|
| 541 |
//' times_b <- c(1, 2, 3, 4, 5) |
|
| 542 |
//' pts_b <- cbind(1:5, 1:5) |
|
| 543 |
//' # agent A repeats B's positions two turns later |
|
| 544 |
//' times_a <- times_b + 2 |
|
| 545 |
//' pts_a <- pts_b |
|
| 546 |
//' signed_turn_lag(pts_a, pts_b, times_a, times_b, delta = 2L) # mean distance 0 |
|
| 547 |
//' signed_turn_lag(pts_a, pts_b, times_a, times_b, delta = 0L) |
|
| 548 |
//' @export |
|
| 549 |
// [[Rcpp::export]] |
|
| 550 | ! |
List signed_turn_lag( |
| 551 |
arma::mat pts_a, |
|
| 552 |
arma::mat pts_b, |
|
| 553 |
arma::vec times_a, |
|
| 554 |
arma::vec times_b, |
|
| 555 |
int delta |
|
| 556 |
) {
|
|
| 557 | ! |
auto [mean_d, count] = qe::signed_turn_lag_distance(pts_a, pts_b, times_a, times_b, delta); |
| 558 |
return List::create( |
|
| 559 | ! |
Named("mean_distance") = mean_d,
|
| 560 | ! |
Named("valid_count") = count
|
| 561 |
); |
|
| 562 |
} |
|
| 563 | ||
| 564 |
//' Sweep signed turn lags to find optimal leader-follower lag |
|
| 565 |
//' @param pts_a Points matrix for agent A (n_a x 2) |
|
| 566 |
//' @param pts_b Points matrix for agent B (n_b x 2) |
|
| 567 |
//' @param times_a Turn times for agent A |
|
| 568 |
//' @param times_b Turn times for agent B |
|
| 569 |
//' @param max_lag Maximum turn lag to evaluate (default 15) |
|
| 570 |
//' @examples |
|
| 571 |
//' times_b <- c(1, 2, 3, 4, 5) |
|
| 572 |
//' pts_b <- cbind(1:5, 1:5) |
|
| 573 |
//' times_a <- times_b + 2 # A follows B by two turns |
|
| 574 |
//' pts_a <- pts_b |
|
| 575 |
//' res <- sweep_signed_turn_lags(pts_a, pts_b, times_a, times_b, max_lag = 4L) |
|
| 576 |
//' res$best_lag # 2 |
|
| 577 |
//' res$mean_distances |
|
| 578 |
//' @export |
|
| 579 |
// [[Rcpp::export]] |
|
| 580 | 1x |
List sweep_signed_turn_lags( |
| 581 |
arma::mat pts_a, |
|
| 582 |
arma::mat pts_b, |
|
| 583 |
arma::vec times_a, |
|
| 584 |
arma::vec times_b, |
|
| 585 |
int max_lag = 15 |
|
| 586 |
) {
|
|
| 587 | 1x |
qe::SignedTurnLagResult res = qe::best_signed_turn_lag(pts_a, pts_b, times_a, times_b, max_lag); |
| 588 |
return List::create( |
|
| 589 | 2x |
Named("best_lag") = res.best_lag,
|
| 590 | 2x |
Named("min_mean_distance") = res.min_mean_distance,
|
| 591 | 2x |
Named("lags") = res.lags,
|
| 592 | 2x |
Named("mean_distances") = res.mean_distances,
|
| 593 | 2x |
Named("valid_counts") = res.valid_counts
|
| 594 |
); |
|
| 595 |
} |
|
| 596 | ||
| 597 |
//' Distance-distance matrix correlation for stability analysis |
|
| 598 |
//' @param X Numeric matrix |
|
| 599 |
//' @param Y Numeric matrix |
|
| 600 |
//' @examples |
|
| 601 |
//' X <- matrix(c(0, 1, 0, 1, 0, 0, 1, 1), ncol = 2) |
|
| 602 |
//' Y <- 2 * X # a uniform scaling keeps the distance structure |
|
| 603 |
//' dist_dist_correlation(X, Y) # 1 |
|
| 604 |
//' @export |
|
| 605 |
// [[Rcpp::export]] |
|
| 606 | 1x |
double dist_dist_correlation( |
| 607 |
arma::mat X, |
|
| 608 |
arma::mat Y |
|
| 609 |
) {
|
|
| 610 | 1x |
return qe::dist_dist_correlation(X, Y); |
| 611 |
} |
|
| 612 | ||
| 613 |
// ============================================================================= |
|
| 614 |
// Nesting |
|
| 615 |
// ============================================================================= |
|
| 616 | ||
| 617 |
//' Nested-code candidates from a binary code matrix |
|
| 618 |
//' |
|
| 619 |
//' Which codes are (nearly) always coded on lines that also carry another |
|
| 620 |
//' code. Before accumulation a nested code's parent is split into its |
|
| 621 |
//' subcodes plus a residual and the family's connections are masked, so |
|
| 622 |
//' this is a data step shared by every model (ENA, ONA, TMA). Only the |
|
| 623 |
//' numeric signal is computed here; a codebook hierarchy or the code names |
|
| 624 |
//' are the caller's to combine with it. |
|
| 625 |
//' |
|
| 626 |
//' A pair (sub, parent) is reported when the sub occurs on at least |
|
| 627 |
//' \code{min_rows} lines, at least \code{threshold} of those lines also
|
|
| 628 |
//' carry the parent, and the parent occurs on more lines than the two share. |
|
| 629 |
//' Two codes present on exactly the same lines are duplicates: they appear |
|
| 630 |
//' in \code{duplicates} and never in \code{pairs}.
|
|
| 631 |
//' |
|
| 632 |
//' @param codes Numeric matrix (lines x codes) of 0/1 or counts. A value is |
|
| 633 |
//' present when it is finite and greater than zero; \code{NA} is absent.
|
|
| 634 |
//' @param threshold Minimum share of the sub's lines that also carry the |
|
| 635 |
//' parent, in (0, 1]. Default 0.95. |
|
| 636 |
//' @param min_rows Minimum lines a sub needs to be considered. Default 5. |
|
| 637 |
//' @return Named list: \code{cooccurrence} (codes x codes matrix, diagonal =
|
|
| 638 |
//' lines per code, off-diagonal = lines with both), \code{pairs} (k x 2
|
|
| 639 |
//' matrix of 0-based (sub, parent) column indices, by sub then parent), |
|
| 640 |
//' \code{containment} (length k, \code{cooccurrence[sub, parent] /
|
|
| 641 |
//' cooccurrence[sub, sub]}), \code{duplicates} (d x 2, 0-based (i, j)
|
|
| 642 |
//' with i < j and identical presence). |
|
| 643 |
//' @examples |
|
| 644 |
//' m <- cbind(A = c(1, 1, 1, 1, 1, 1, 1, 1, 0, 0), |
|
| 645 |
//' B = c(1, 1, 1, 1, 1, 0, 0, 0, 0, 0)) |
|
| 646 |
//' r <- nesting_candidates(m) |
|
| 647 |
//' r$pairs # B (column 1) under A (column 0) |
|
| 648 |
//' r$containment # 1 |
|
| 649 |
//' @export |
|
| 650 |
// [[Rcpp::export]] |
|
| 651 | 10x |
List nesting_candidates(arma::mat codes, double threshold = 0.95, int min_rows = 5) {
|
| 652 | 10x |
qe::NestingCandidates r = qe::nesting_candidates(codes, threshold, min_rows); |
| 653 |
return List::create( |
|
| 654 | 16x |
_("cooccurrence") = r.cooccurrence,
|
| 655 | 16x |
_("pairs") = r.pairs,
|
| 656 | 16x |
_("containment") = r.containment,
|
| 657 | 16x |
_("duplicates") = r.duplicates
|
| 658 |
); |
|
| 659 |
} |