| 1 |
#' @useDynLib qestats, .registration = TRUE |
|
| 2 |
#' @importFrom Rcpp evalCpp |
|
| 3 |
NULL |
|
| 4 | ||
| 5 |
.onLoad <- function(libname, pkgname) {
|
|
| 6 | ! |
invisible(NULL) |
| 7 |
} |
| 1 |
#pragma once |
|
| 2 |
#include "metrics.hpp" |
|
| 3 |
#include "inference.hpp" |
|
| 4 |
#include "derivation.hpp" |
|
| 5 |
#include <algorithm> |
|
| 6 |
#include <cmath> |
|
| 7 |
#include <random> |
|
| 8 |
#include <stdexcept> |
|
| 9 |
#include <string> |
|
| 10 |
#include <vector> |
|
| 11 | ||
| 12 |
namespace qestats {
|
|
| 13 | ||
| 14 |
// ── Bootstrap result ────────────────────────────────────────────────────────── |
|
| 15 | ||
| 16 |
struct BootstrapResult {
|
|
| 17 |
Metrics original; |
|
| 18 |
// Per-metric standard errors and CI (parallel to METRIC_NAMES order) |
|
| 19 |
struct MetricStat {
|
|
| 20 |
std::string name; |
|
| 21 |
double se = 0; |
|
| 22 |
double ci_lo = 0; |
|
| 23 |
double ci_hi = 0; |
|
| 24 |
}; |
|
| 25 |
std::vector<MetricStat> stats; |
|
| 26 |
}; |
|
| 27 | ||
| 28 |
// Metric names in canonical order matching qestats R output. |
|
| 29 | ! |
inline const std::vector<std::string>& metric_names() {
|
| 30 |
static const std::vector<std::string> v = {
|
|
| 31 |
"b1","b2","precision","recall","accuracy","FDR","FOR","kappa" |
|
| 32 |
}; |
|
| 33 | ! |
return v; |
| 34 |
} |
|
| 35 | ||
| 36 |
// ── bootstrap_metrics ───────────────────────────────────────────────────────── |
|
| 37 |
// cm: raw confusion matrix (TP, FP, FN, TN counts — not proportions) |
|
| 38 |
// n_sample: resample size |
|
| 39 |
// n_set: population size (0 = infinite) |
|
| 40 |
// n_bootstrap: number of bootstrap replicates |
|
| 41 |
// conf_level: confidence level (e.g. 0.95) |
|
| 42 | ||
| 43 |
inline BootstrapResult |
|
| 44 | ! |
bootstrap_metrics(const ConfusionMatrix& cm, int n_sample, |
| 45 |
const std::vector<std::string>& metrics = {},
|
|
| 46 |
int n_set = 0, int n_bootstrap = 1000, |
|
| 47 |
double conf_level = 0.95) {
|
|
| 48 | ! |
const auto& all = metric_names(); |
| 49 | ! |
const auto& chosen = metrics.empty() ? all : metrics; |
| 50 | ||
| 51 | ! |
double n1 = cm.tp, n2 = cm.fp, n3 = cm.fn, n4 = cm.tn; |
| 52 | ! |
double n0 = n1 + n2 + n3 + n4; |
| 53 | ! |
if (n0 <= 0 || n1 < 0 || n2 < 0 || n3 < 0 || n4 < 0) |
| 54 | ! |
throw std::domain_error("bootstrap_metrics: invalid confusion matrix");
|
| 55 | ||
| 56 | ! |
BootstrapResult result; |
| 57 | ! |
result.original = calculate_metrics(n1, n2, n3, n4); |
| 58 | ||
| 59 |
// Scale to population if requested |
|
| 60 | ! |
if (n_set > 0) {
|
| 61 | ! |
double r = static_cast<double>(n_set) / n0; |
| 62 | ! |
if (r > 1) {
|
| 63 | ! |
n1 = std::ceil(n1 * r); n2 = std::ceil(n2 * r); |
| 64 | ! |
n3 = std::ceil(n3 * r); n4 = std::ceil(n4 * r); |
| 65 |
} else {
|
|
| 66 | ! |
n1 = std::floor(n1 * r); n2 = std::floor(n2 * r); |
| 67 | ! |
n3 = std::floor(n3 * r); n4 = std::floor(n4 * r); |
| 68 |
} |
|
| 69 | ! |
double tot = n1 + n2 + n3 + n4; |
| 70 | ! |
if (n_sample > tot) n_sample = static_cast<int>(tot); |
| 71 |
} |
|
| 72 | ! |
const double n_total = n1 + n2 + n3 + n4; |
| 73 | ! |
const bool with_replacement = (n_set == 0); |
| 74 | ||
| 75 |
// Build population index vector [0..n_total) |
|
| 76 | ! |
const int N = static_cast<int>(n_total); |
| 77 | ! |
const int c1 = static_cast<int>(n1); |
| 78 | ! |
const int c2 = static_cast<int>(n2); |
| 79 | ! |
const int c3 = static_cast<int>(n3); |
| 80 | ||
| 81 | ! |
std::mt19937 rng(42); // fixed seed for reproducibility |
| 82 | ||
| 83 |
// Collect bootstrap replicates |
|
| 84 | ! |
std::vector<std::vector<double>> samples(chosen.size()); |
| 85 | ||
| 86 | ! |
for (int b = 0; b < n_bootstrap; ++b) {
|
| 87 | ! |
std::vector<int> idx(n_sample); |
| 88 | ! |
if (with_replacement) {
|
| 89 | ! |
std::uniform_int_distribution<int> dist(0, N - 1); |
| 90 | ! |
for (int& x : idx) x = dist(rng); |
| 91 |
} else {
|
|
| 92 |
// sample without replacement |
|
| 93 | ! |
std::vector<int> pool(N); |
| 94 | ! |
std::iota(pool.begin(), pool.end(), 0); |
| 95 | ! |
std::shuffle(pool.begin(), pool.end(), rng); |
| 96 | ! |
for (int i = 0; i < n_sample; ++i) idx[i] = pool[i]; |
| 97 |
} |
|
| 98 | ! |
int b1_ = 0, b2_ = 0, b3_ = 0, b4_ = 0; |
| 99 | ! |
for (int x : idx) {
|
| 100 | ! |
if (x < c1) ++b1_; |
| 101 | ! |
else if (x < c1+c2) ++b2_; |
| 102 | ! |
else if (x < c1+c2+c3) ++b3_; |
| 103 | ! |
else ++b4_; |
| 104 |
} |
|
| 105 | ! |
Metrics bm = calculate_metrics(b1_, b2_, b3_, b4_); |
| 106 | ! |
for (std::size_t i = 0; i < chosen.size(); ++i) |
| 107 | ! |
samples[i].push_back(get_metric(bm, chosen[i])); |
| 108 |
} |
|
| 109 | ||
| 110 | ! |
const double alpha = 1.0 - conf_level; |
| 111 | ! |
for (std::size_t i = 0; i < chosen.size(); ++i) {
|
| 112 | ! |
auto& s = samples[i]; |
| 113 | ! |
double mean = 0; for (double x : s) mean += x; mean /= s.size(); |
| 114 | ! |
double var = 0; for (double x : s) var += (x-mean)*(x-mean); |
| 115 | ! |
double se = std::sqrt(var / (s.size() - 1)); |
| 116 | ||
| 117 | ! |
std::sort(s.begin(), s.end()); |
| 118 | ! |
int lo_idx = std::max(0, static_cast<int>(std::floor(alpha/2 * s.size()))); |
| 119 | ! |
int hi_idx = std::min(static_cast<int>(s.size())-1, |
| 120 | ! |
static_cast<int>(std::ceil((1.0-alpha/2) * s.size()))); |
| 121 | ! |
result.stats.push_back({chosen[i], se, s[lo_idx], s[hi_idx]});
|
| 122 |
} |
|
| 123 | ! |
return result; |
| 124 |
} |
|
| 125 | ||
| 126 |
// ── Welch t-test helper ─────────────────────────────────────────────────────── |
|
| 127 | ||
| 128 |
struct TTestResult {
|
|
| 129 |
double t_stat = 0; |
|
| 130 |
double df = 0; |
|
| 131 |
double p_value = 1; |
|
| 132 |
}; |
|
| 133 | ||
| 134 | ! |
inline TTestResult welch_t(double m1, double m2, |
| 135 |
double s1, double s2, |
|
| 136 |
int n1, int n2, |
|
| 137 |
double tol = 1e-9) {
|
|
| 138 | ! |
if (std::abs(m1 - m2) < tol) return {0, 1, 1.0};
|
| 139 | ! |
if (std::abs(s1) < tol && std::abs(s2) < tol) |
| 140 | ! |
return {0, 1, (std::abs(m1-m2) < tol) ? 1.0 : 0.0};
|
| 141 | ||
| 142 | ! |
const double t = (m1 - m2) / std::sqrt(s1*s1 + s2*s2); |
| 143 | ! |
const double num = (s1*s1 + s2*s2) * (s1*s1 + s2*s2); |
| 144 | ! |
const double den = s1*s1*s1*s1/(n1-1) + s2*s2*s2*s2/(n2-1); |
| 145 | ! |
const double df = (den > 0) ? num/den : 1.0; |
| 146 | ||
| 147 |
// Two-sided p-value via regularised incomplete beta (approx via normal for large df) |
|
| 148 |
// For production accuracy use a proper t-distribution CDF; for now use |
|
| 149 |
// normal approximation which is good for df > 30. |
|
| 150 |
double p; |
|
| 151 | ! |
if (df > 30) {
|
| 152 |
// Standard normal approximation |
|
| 153 | ! |
double z = std::abs(t); |
| 154 | ! |
p = 2.0 * (0.5 * std::erfc(z / std::sqrt(2.0))); |
| 155 |
} else {
|
|
| 156 |
// Abramowitz & Stegun approximation to t CDF |
|
| 157 | ! |
double x = df / (df + t*t); |
| 158 |
// regularized incomplete beta I_x(df/2, 1/2) via continued fraction (simple approximation) |
|
| 159 |
// Good enough for n_bootstrap >= 30; exact implementation would need boost or a special function |
|
| 160 | ! |
double z = std::abs(t); |
| 161 | ! |
double a_s = df / 2.0, b_s = 0.5; |
| 162 |
// Use the normal approximation as a safe fallback |
|
| 163 | ! |
p = 2.0 * (0.5 * std::erfc(z / std::sqrt(2.0))); |
| 164 |
} |
|
| 165 | ! |
return {t, df, p};
|
| 166 |
} |
|
| 167 | ||
| 168 |
// ── p_diff ──────────────────────────────────────────────────────────────────── |
|
| 169 |
// p-value for a metric difference between two groups using bootstrap Welch t. |
|
| 170 | ||
| 171 | ! |
inline double p_diff(const ConfusionMatrix& cm1, const ConfusionMatrix& cm2, |
| 172 |
int n_sample, const std::string& metric, |
|
| 173 |
int n_set = 0, int n_bootstrap = 1000, double alpha = 0.05) {
|
|
| 174 | ! |
const double n1 = cm1.tp + cm1.fp + cm1.fn + cm1.tn; |
| 175 | ! |
const double n2 = cm2.tp + cm2.fp + cm2.fn + cm2.tn; |
| 176 | ! |
const double n0 = n1 + n2; |
| 177 | ! |
if (n_sample <= 0) n_sample = static_cast<int>(n0); |
| 178 | ! |
const double r = static_cast<double>(n_sample) / n0; |
| 179 | ! |
const int ns1 = std::max(1, static_cast<int>(std::floor(n1 * (r < 1 ? 1 : r)))); |
| 180 | ! |
const int ns2 = std::max(1, static_cast<int>(std::floor(n2 * (r < 1 ? 1 : r)))); |
| 181 | ! |
const int nset1 = (n_set > 0) ? static_cast<int>(std::floor(n_set * n1 / n0)) : 0; |
| 182 | ! |
const int nset2 = (n_set > 0) ? static_cast<int>(std::floor(n_set * n2 / n0)) : 0; |
| 183 | ||
| 184 | ! |
auto r1 = bootstrap_metrics(cm1, ns1, {metric}, nset1, n_bootstrap, 1.0-alpha);
|
| 185 | ! |
auto r2 = bootstrap_metrics(cm2, ns2, {metric}, nset2, n_bootstrap, 1.0-alpha);
|
| 186 | ||
| 187 | ! |
const double m1v = get_metric(r1.original, metric); |
| 188 | ! |
const double m2v = get_metric(r2.original, metric); |
| 189 | ! |
const double se1 = r1.stats.empty() ? 0 : r1.stats[0].se; |
| 190 | ! |
const double se2 = r2.stats.empty() ? 0 : r2.stats[0].se; |
| 191 | ||
| 192 | ! |
return welch_t(m1v, m2v, se1, se2, n_bootstrap, n_bootstrap).p_value; |
| 193 |
} |
|
| 194 | ||
| 195 |
// ── n_diff ──────────────────────────────────────────────────────────────────── |
|
| 196 |
// Minimum n at which two groups show a significant metric difference. |
|
| 197 | ||
| 198 |
inline int n_diff(const ConfusionMatrix& cm1, const ConfusionMatrix& cm2, |
|
| 199 |
const std::string& metric, int n_set = 0, |
|
| 200 |
int n_bootstrap = 1000, double alpha = 0.05) {
|
|
| 201 |
const int n1 = static_cast<int>(cm1.tp+cm1.fp+cm1.fn+cm1.tn); |
|
| 202 |
const int n2 = static_cast<int>(cm2.tp+cm2.fp+cm2.fn+cm2.tn); |
|
| 203 |
int n = n1 + n2; |
|
| 204 | ||
| 205 |
double p0 = p_diff(cm1, cm2, n, metric, n_set, n_bootstrap, alpha); |
|
| 206 |
if (p0 < alpha) return n; |
|
| 207 | ||
| 208 |
int left = n, right = n_set > 0 ? n_set : n; |
|
| 209 |
if (n_set <= 0) {
|
|
| 210 |
while (p0 >= alpha) {
|
|
| 211 |
left = right; |
|
| 212 |
right *= 2; |
|
| 213 |
p0 = p_diff(cm1, cm2, right, metric, n_set, n_bootstrap, alpha); |
|
| 214 |
} |
|
| 215 |
} else {
|
|
| 216 |
double p1 = p_diff(cm1, cm2, n_set, metric, n_set, n_bootstrap, alpha); |
|
| 217 |
if (p1 >= alpha) return n_set; |
|
| 218 |
} |
|
| 219 | ||
| 220 |
while (right - left > 1) {
|
|
| 221 |
int mid = (left + right + 1) / 2; |
|
| 222 |
p0 = p_diff(cm1, cm2, mid, metric, n_set, n_bootstrap, alpha); |
|
| 223 |
if (p0 >= alpha) left = mid; |
|
| 224 |
else right = mid; |
|
| 225 |
} |
|
| 226 |
return (left + right + 1) / 2; |
|
| 227 |
} |
|
| 228 | ||
| 229 |
// ── N_Diff2 ─────────────────────────────────────────────────────────────────── |
|
| 230 |
// Minimum n at which CI intervals for two groups no longer overlap by > d. |
|
| 231 | ||
| 232 | ! |
inline double interval_overlap(const MetricRange& r1, const MetricRange& r2) {
|
| 233 | ! |
double span = std::min(r1.max - r1.min, r2.max - r2.min); |
| 234 | ! |
if (span <= 0) return 0.0; |
| 235 | ! |
double overlap = std::min(r1.max, r2.max) - std::max(r1.min, r2.min); |
| 236 | ! |
return (overlap <= 0) ? 0.0 : overlap / span; |
| 237 |
} |
|
| 238 | ||
| 239 |
// range_est: hyper-based CI for a metric given raw counts + population N |
|
| 240 | ! |
inline MetricRange range_est(const ConfusionMatrix& cm, |
| 241 |
const std::string& metric, int N, double alpha = 0.05) {
|
|
| 242 | ! |
const int tp = static_cast<int>(cm.tp), fp = static_cast<int>(cm.fp); |
| 243 | ! |
const int fn = static_cast<int>(cm.fn), tn = static_cast<int>(cm.tn); |
| 244 | ! |
auto prec_ci = hyper_range(tp, tp+fp, N, alpha); |
| 245 | ! |
auto for_ci = hyper_range(fn, fn+tn, N, alpha); // FOR = fn/(fn+tn) |
| 246 | ! |
double b1 = static_cast<double>(tp+fp) / (tp+fp+fn+tn); |
| 247 | ||
| 248 | ! |
auto eval_m = [&](double fdr, double for_v) -> double {
|
| 249 | ! |
Metrics m = derive_from_rates(b1, fdr, for_v); |
| 250 | ! |
return get_metric(m, metric); |
| 251 |
}; |
|
| 252 | ||
| 253 |
// Precision = 1 - FDR; CI is [lo, hi] for precision → FDR is [1-hi, 1-lo] |
|
| 254 | ! |
double fdr_lo = 1.0 - prec_ci.upper, fdr_hi = 1.0 - prec_ci.lower; |
| 255 | ! |
double for_lo = for_ci.lower, for_hi = for_ci.upper; |
| 256 | ||
| 257 | ! |
double v_lo = eval_m(fdr_hi, for_hi); // worst case |
| 258 | ! |
double v_hi = eval_m(fdr_lo, for_lo); // best case |
| 259 | ! |
if (v_lo > v_hi) std::swap(v_lo, v_hi); |
| 260 | ! |
return {v_lo, v_hi};
|
| 261 |
} |
|
| 262 | ||
| 263 | ! |
inline int N_Diff2(const ConfusionMatrix& cm1, const ConfusionMatrix& cm2, |
| 264 |
const std::string& metric, int N, double d = 0.1, |
|
| 265 |
double alpha = 0.05) {
|
|
| 266 | ! |
const double n1 = cm1.tp+cm1.fp+cm1.fn+cm1.tn; |
| 267 | ! |
const double n2 = cm2.tp+cm2.fp+cm2.fn+cm2.tn; |
| 268 | ! |
const int n = static_cast<int>(n1 + n2); |
| 269 | ||
| 270 | ! |
auto scale = [](const ConfusionMatrix& cm, double r) -> ConfusionMatrix {
|
| 271 | ! |
return {std::floor(cm.tp*r), std::floor(cm.fp*r),
|
| 272 | ! |
std::floor(cm.fn*r), std::floor(cm.tn*r)}; |
| 273 |
}; |
|
| 274 | ||
| 275 | ! |
auto check_overlap = [&](double x) -> bool {
|
| 276 | ! |
auto s1 = scale(cm1, x); auto s2 = scale(cm2, x); |
| 277 | ! |
auto r1 = range_est(s1, metric, N, alpha); |
| 278 | ! |
auto r2 = range_est(s2, metric, N, alpha); |
| 279 | ! |
return interval_overlap(r1, r2) >= d; |
| 280 |
}; |
|
| 281 | ||
| 282 | ! |
if (!check_overlap(1.0)) return n; // already separable |
| 283 | ||
| 284 | ! |
const double x_max = static_cast<double>(N) / n; |
| 285 | ! |
if (check_overlap(x_max)) return N; // never separable at population size |
| 286 | ||
| 287 | ! |
double left = 1.0, right = x_max; |
| 288 | ! |
while (right - left > 1.0 / n) {
|
| 289 | ! |
double mid = (left + right) / 2.0; |
| 290 | ! |
if (check_overlap(mid)) left = mid; |
| 291 | ! |
else right = mid; |
| 292 |
} |
|
| 293 | ! |
return static_cast<int>(std::ceil(n * (left + right) / 2.0)); |
| 294 |
} |
|
| 295 | ||
| 296 |
// ── ps_est_kappa ────────────────────────────────────────────────────────────── |
|
| 297 |
// Given a perfect sample of size n and a baserate, estimate the minimum kappa. |
|
| 298 | ||
| 299 | ! |
inline double ps_est_kappa(int n, double baserate, int N = 0, |
| 300 |
int allowed_errors = 0, double alpha = 0.05) {
|
|
| 301 | ! |
double accuracy = ps_est_proportion(n, N, allowed_errors, alpha); |
| 302 | ! |
if (accuracy < 0) return -1; |
| 303 | ! |
MetricRange kappa_range = range("baserate", baserate, "accuracy", accuracy, "kappa");
|
| 304 | ! |
return kappa_range.min; |
| 305 |
} |
|
| 306 | ||
| 307 |
// ── ps_kappa ───────────────────────────────────────────────────────────────── |
|
| 308 |
// Perfect sample size for kappa test — delegates to ps_proportion via |
|
| 309 |
// max accuracy from range(baserate, kappa). |
|
| 310 | ||
| 311 | ! |
inline PsResult ps_kappa(double kappa0, double baserate, |
| 312 |
double kappa1 = 0, int N = 0, |
|
| 313 |
int allowed_errors = 0, double alpha = 0.05) {
|
|
| 314 | ! |
if (alpha < 0.001 || alpha > 0.1) return {};
|
| 315 | ! |
if (kappa0 <= 0 || kappa0 >= 1) return {};
|
| 316 | ||
| 317 | ! |
MetricRange acc_range = range("baserate", baserate, "kappa", kappa0, "accuracy");
|
| 318 | ! |
return ps_proportion(acc_range.max, kappa1, N, allowed_errors, alpha); |
| 319 |
} |
|
| 320 | ||
| 321 |
} // namespace qestats |
| 1 |
#pragma once |
|
| 2 |
#include "metrics.hpp" |
|
| 3 |
#include <algorithm> |
|
| 4 |
#include <cmath> |
|
| 5 |
#include <functional> |
|
| 6 |
#include <stdexcept> |
|
| 7 |
#include <string> |
|
| 8 |
#include <vector> |
|
| 9 | ||
| 10 |
// C++ port of the qestats 22-row formula table plus range() and derive(). |
|
| 11 |
// |
|
| 12 |
// Each FormulaRow encodes a pair of independent metrics (var1, var2) and a |
|
| 13 |
// "key" variable (var3). Given var1 and var2, the key variable sweeps an |
|
| 14 |
// interval [l3, u3] and three formulas produce tp, fp, fn for every key value. |
|
| 15 |
// This lets derive() find the key value that matches a desired third metric |
|
| 16 |
// (5-point interval narrowing), and lets range() find the min/max of any |
|
| 17 |
// fourth metric over the full key interval. |
|
| 18 | ||
| 19 |
namespace qestats {
|
|
| 20 | ||
| 21 |
// ── Formula table row ───────────────────────────────────────────────────────── |
|
| 22 | ||
| 23 |
struct FormulaRow {
|
|
| 24 |
std::string var1, var2, var3; // names of the three independent variables |
|
| 25 | ||
| 26 |
// Bounds on var1 (for validation). |
|
| 27 |
std::function<double(double v1, double v2)> l1, u1; |
|
| 28 |
// Bounds on var2 (for validation). |
|
| 29 |
std::function<double(double v1, double v2)> l2, u2; |
|
| 30 |
// Bounds on the key (var3) given v1, v2. |
|
| 31 |
std::function<double(double v1, double v2)> l3, u3; |
|
| 32 |
// Confusion matrix from (v1, v2, key). |
|
| 33 |
std::function<double(double v1, double v2, double key)> tp, fp, fn; |
|
| 34 |
}; |
|
| 35 | ||
| 36 |
// ── Build the 22-row table ──────────────────────────────────────────────────── |
|
| 37 | ||
| 38 | ! |
inline std::vector<FormulaRow> make_formula_table() {
|
| 39 |
using F2 = std::function<double(double, double)>; |
|
| 40 |
using F3 = std::function<double(double, double, double)>; |
|
| 41 | ||
| 42 | ! |
auto c0 = [](double, double) { return 0.0; };
|
| 43 | ! |
auto c1 = [](double, double) { return 1.0; };
|
| 44 | ||
| 45 | ! |
std::vector<FormulaRow> t; |
| 46 | ||
| 47 |
// 1: b1, b2 — key=tp |
|
| 48 | ! |
t.push_back({"b1","b2","tp",
|
| 49 |
c0, c1, c0, c1, |
|
| 50 | ! |
c0, [](double b1, double b2){ return std::min(b1,b2); },
|
| 51 | ! |
[](double, double, double k){ return k; },
|
| 52 | ! |
[](double b1, double, double k){ return b1 - k; },
|
| 53 | ! |
[](double, double b2, double k){ return b2 - k; }
|
| 54 |
}); |
|
| 55 | ||
| 56 |
// 2: b1, precision — key=fn |
|
| 57 | ! |
t.push_back({"b1","precision","fn",
|
| 58 |
c0, c1, c0, c1, |
|
| 59 | ! |
c0, [](double b1, double){ return 1.0 - b1; },
|
| 60 | ! |
[](double b1, double pr, double){ return b1 * pr; },
|
| 61 | ! |
[](double b1, double pr, double){ return b1 - b1*pr; },
|
| 62 | ! |
[](double, double, double k){ return k; }
|
| 63 |
}); |
|
| 64 | ||
| 65 |
// 3: b1, recall — key=tp |
|
| 66 | ! |
t.push_back({"b1","recall","tp",
|
| 67 |
c0, c1, c0, c1, |
|
| 68 | ! |
c0, [](double b1, double rc){ return std::min(b1, rc); },
|
| 69 | ! |
[](double, double, double k){ return k; },
|
| 70 | ! |
[](double b1, double, double k){ return b1 - k; },
|
| 71 | ! |
[](double, double rc, double k){ return (rc > 0) ? k/rc - k : 0.0; }
|
| 72 |
}); |
|
| 73 | ||
| 74 |
// 4: b1, accuracy — key=tp |
|
| 75 | ! |
t.push_back({"b1","accuracy","tp",
|
| 76 |
c0, c1, c0, c1, |
|
| 77 | ! |
[](double b1, double ac){ return std::max(0.0, b1 + ac - 1.0); },
|
| 78 | ! |
[](double b1, double){ return b1; },
|
| 79 | ! |
[](double, double, double k){ return k; },
|
| 80 | ! |
[](double b1, double, double k){ return b1 - k; },
|
| 81 | ! |
[](double b1, double ac, double k){ return 1.0 - b1 - ac + k; }
|
| 82 |
}); |
|
| 83 | ||
| 84 |
// 5: b1, FOR — key=tp |
|
| 85 | ! |
t.push_back({"b1","FOR","tp",
|
| 86 |
c0, c1, c0, c1, |
|
| 87 | ! |
c0, [](double b1, double){ return b1; },
|
| 88 | ! |
[](double, double, double k){ return k; },
|
| 89 | ! |
[](double b1, double, double k){ return b1 - k; },
|
| 90 | ! |
[](double b1, double f, double){ return f * (1.0 - b1); }
|
| 91 |
}); |
|
| 92 | ||
| 93 |
// 6: b1, kappa — key=tp |
|
| 94 | ! |
t.push_back({"b1","kappa","tp",
|
| 95 |
c0, c1, c0, c1, |
|
| 96 | ! |
[](double b1, double kp){
|
| 97 | ! |
double d = 2.0 - 2.0*b1 - (1.0 - 2.0*b1)*kp; |
| 98 | ! |
return (std::abs(d) > 1e-12) ? std::max(0.0, b1*kp/d) : 0.0; |
| 99 |
}, |
|
| 100 | ! |
[](double b1, double kp){
|
| 101 | ! |
double d = 2.0 - 2.0*b1 - (1.0 - 2.0*b1)*kp; |
| 102 | ! |
return (std::abs(d) > 1e-12) |
| 103 | ! |
? std::min(b1, (2.0*b1 + (1.0-b1)*kp)/d) : b1; |
| 104 |
}, |
|
| 105 | ! |
[](double, double, double k){ return k; },
|
| 106 | ! |
[](double b1, double, double k){ return b1 - k; },
|
| 107 | ! |
[](double b1, double kp, double k){
|
| 108 | ! |
double d = (1.0 - 2.0*b1)*kp + 2.0*b1; |
| 109 | ! |
return (std::abs(d) > 1e-12) ? (2.0*k - b1*kp)/d - k : 0.0; |
| 110 |
} |
|
| 111 |
}); |
|
| 112 | ||
| 113 |
// 7: b2, precision — key=tp |
|
| 114 | ! |
t.push_back({"b2","precision","tp",
|
| 115 |
c0, c1, c0, c1, |
|
| 116 | ! |
c0, [](double pr, double b2){ return std::min(pr, b2); },
|
| 117 | ! |
[](double, double, double k){ return k; },
|
| 118 | ! |
[](double, double pr, double k){ return (pr > 0) ? k/pr - k : 0.0; },
|
| 119 | ! |
[](double b2, double, double k){ return b2 - k; }
|
| 120 |
}); |
|
| 121 | ||
| 122 |
// 8: b2, recall — key=fp |
|
| 123 | ! |
t.push_back({"b2","recall","fp",
|
| 124 |
c0, c1, c0, c1, |
|
| 125 | ! |
c0, [](double b2, double){ return 1.0 - b2; },
|
| 126 | ! |
[](double b2, double rc, double){ return b2 * rc; },
|
| 127 | ! |
[](double, double, double k){ return k; },
|
| 128 | ! |
[](double b2, double rc, double){ return b2 - b2*rc; }
|
| 129 |
}); |
|
| 130 | ||
| 131 |
// 9: b2, accuracy — key=tp |
|
| 132 | ! |
t.push_back({"b2","accuracy","tp",
|
| 133 |
c0, c1, c0, c1, |
|
| 134 | ! |
[](double b2, double ac){ return std::max(0.0, ac + b2 - 1.0); },
|
| 135 | ! |
[](double b2, double ac){ return std::min(b2, ac); },
|
| 136 | ! |
[](double, double, double k){ return k; },
|
| 137 | ! |
[](double b2, double ac, double k){ return 1.0 - ac - b2 + k; },
|
| 138 | ! |
[](double b2, double, double k){ return b2 - k; }
|
| 139 |
}); |
|
| 140 | ||
| 141 |
// 10: b2, FOR — key=tp |
|
| 142 | ! |
t.push_back({"b2","FOR","tp",
|
| 143 |
c0, c1, c0, c1, |
|
| 144 | ! |
[](double b2, double f){ return (1.0-f > 1e-12) ? (b2-f)/(1.0-f) : 0.0; },
|
| 145 | ! |
[](double b2, double){ return b2; },
|
| 146 | ! |
[](double, double, double k){ return k; },
|
| 147 | ! |
[](double b2, double f, double k){
|
| 148 | ! |
return (std::abs(f) > 1e-12) ? 1.0 - (b2-k)/f - k : 0.0; }, |
| 149 | ! |
[](double b2, double, double k){ return b2 - k; }
|
| 150 |
}); |
|
| 151 | ||
| 152 |
// 11: b2, kappa — key=tp |
|
| 153 | ! |
t.push_back({"b2","kappa","tp",
|
| 154 |
c0, c1, c0, c1, |
|
| 155 | ! |
[](double b2, double kp){
|
| 156 | ! |
double d = 2.0 - 2.0*b2 - (1.0-2.0*b2)*kp; |
| 157 | ! |
return (std::abs(d) > 1e-12) ? std::max(0.0, b2*kp/d) : 0.0; |
| 158 |
}, |
|
| 159 | ! |
[](double b2, double kp){
|
| 160 | ! |
double d = 2.0 - 2.0*b2 - (1.0-2.0*b2)*kp; |
| 161 | ! |
return (std::abs(d) > 1e-12) |
| 162 | ! |
? std::min(b2, (2.0*b2 + (1.0-b2)*kp)/d) : b2; |
| 163 |
}, |
|
| 164 | ! |
[](double, double, double k){ return k; },
|
| 165 | ! |
[](double b2, double kp, double k){
|
| 166 | ! |
double d = (1.0-2.0*b2)*kp + 2.0*b2; |
| 167 | ! |
return (std::abs(d) > 1e-12) ? (2.0*k - b2*kp)/d - k : 0.0; }, |
| 168 | ! |
[](double b2, double, double k){ return b2 - k; }
|
| 169 |
}); |
|
| 170 | ||
| 171 |
// 12: precision, recall — key=tp |
|
| 172 | ! |
t.push_back({"precision","recall","tp",
|
| 173 |
c0, c1, c0, c1, |
|
| 174 |
c0, |
|
| 175 | ! |
[](double pr, double rc){
|
| 176 | ! |
double x = (pr + rc - pr*rc > 1e-12) |
| 177 | ! |
? rc*pr / (pr + rc - rc*pr) : 1.0; |
| 178 | ! |
return std::min({1.0, pr, rc, x});
|
| 179 |
}, |
|
| 180 | ! |
[](double, double, double k){ return k; },
|
| 181 | ! |
[](double pr, double, double k){ return (pr > 1e-12) ? k/pr - k : 0.0; },
|
| 182 | ! |
[](double, double rc, double k){ return (rc > 1e-12) ? k/rc - k : 0.0; }
|
| 183 |
}); |
|
| 184 | ||
| 185 |
// 13: precision, accuracy — key=tp |
|
| 186 | ! |
t.push_back({"precision","accuracy","tp",
|
| 187 |
c0, c1, c0, c1, |
|
| 188 |
c0, |
|
| 189 | ! |
[](double pr, double ac){
|
| 190 | ! |
double x = (1.0-pr > 1e-12) ? (1.0-ac)*pr/(1.0-pr) : 1.0; |
| 191 | ! |
return std::min({1.0, ac, x});
|
| 192 |
}, |
|
| 193 | ! |
[](double, double, double k){ return k; },
|
| 194 | ! |
[](double pr, double, double k){ return (pr > 1e-12) ? k/pr - k : 0.0; },
|
| 195 | ! |
[](double pr, double ac, double k){ return 1.0 - k/pr - ac + k; }
|
| 196 |
}); |
|
| 197 | ||
| 198 |
// 14: precision, FOR — key=tp |
|
| 199 | ! |
t.push_back({"precision","FOR","tp",
|
| 200 |
c0, c1, c0, c1, |
|
| 201 | ! |
c0, [](double pr, double){ return pr; },
|
| 202 | ! |
[](double, double, double k){ return k; },
|
| 203 | ! |
[](double pr, double, double k){ return (pr > 1e-12) ? k/pr - k : 0.0; },
|
| 204 | ! |
[](double pr, double f, double k){
|
| 205 | ! |
double neg = (pr > 1e-12) ? 1.0 - k/pr : 0.0; |
| 206 | ! |
return f * neg; } |
| 207 |
}); |
|
| 208 | ||
| 209 |
// 15: precision, kappa — key=tp |
|
| 210 | ! |
t.push_back({"precision","kappa","tp",
|
| 211 |
c0, c1, c0, c1, |
|
| 212 |
c0, |
|
| 213 | ! |
[](double pr, double kp){
|
| 214 | ! |
double d = 2.0*(1.0-kp); |
| 215 |
return (d > 1e-12) |
|
| 216 | ! |
? std::min(pr, (2.0*pr - (1.0+pr)*kp) / d) : pr; |
| 217 |
}, |
|
| 218 | ! |
[](double, double, double k){ return k; },
|
| 219 | ! |
[](double pr, double, double k){ return (pr > 1e-12) ? k/pr - k : 0.0; },
|
| 220 | ! |
[](double pr, double kp, double k){
|
| 221 | ! |
double d = pr*kp + 2.0*k*(1.0-kp); |
| 222 | ! |
return (std::abs(d) > 1e-12) |
| 223 | ! |
? k * ((2.0*pr - kp)/d - 1.0) : 0.0; } |
| 224 |
}); |
|
| 225 | ||
| 226 |
// 16: recall, accuracy — key=tp |
|
| 227 | ! |
t.push_back({"recall","accuracy","tp",
|
| 228 |
c0, c1, c0, c1, |
|
| 229 |
c0, |
|
| 230 | ! |
[](double rc, double ac){
|
| 231 | ! |
double x = (1.0-rc > 1e-12) ? (1.0-ac)*rc/(1.0-rc) : 1.0; |
| 232 | ! |
return std::min({1.0, ac, x});
|
| 233 |
}, |
|
| 234 | ! |
[](double, double, double k){ return k; },
|
| 235 | ! |
[](double rc, double ac, double k){
|
| 236 | ! |
return 1.0 - (rc > 1e-12 ? (1.0-rc)/rc : 0.0)*k - ac; }, |
| 237 | ! |
[](double rc, double, double k){
|
| 238 | ! |
return (rc > 1e-12) ? (1.0-rc)/rc * k : 0.0; } |
| 239 |
}); |
|
| 240 | ||
| 241 |
// 17: recall, FOR — key=tp |
|
| 242 | ! |
t.push_back({"recall","FOR","tp",
|
| 243 |
c0, c1, c0, c1, |
|
| 244 |
c0, |
|
| 245 | ! |
[](double rc, double f){
|
| 246 | ! |
double d = 1.0 - rc + rc*f; |
| 247 | ! |
return (d > 1e-12) ? std::min(rc, rc*f/d) : rc; |
| 248 |
}, |
|
| 249 | ! |
[](double, double, double k){ return k; },
|
| 250 | ! |
[](double rc, double f, double k){
|
| 251 | ! |
double d = (rc > 1e-12 && f > 1e-12) |
| 252 | ! |
? (1.0-rc+rc*f)/(rc*f) : 1.0; |
| 253 | ! |
return 1.0 - d*k; }, |
| 254 | ! |
[](double rc, double, double k){
|
| 255 | ! |
return (rc > 1e-12) ? (1.0-rc)/rc * k : 0.0; } |
| 256 |
}); |
|
| 257 | ||
| 258 |
// 18: recall, kappa — key=tp |
|
| 259 | ! |
t.push_back({"recall","kappa","tp",
|
| 260 |
c0, c1, c0, c1, |
|
| 261 |
c0, |
|
| 262 | ! |
[](double rc, double kp){
|
| 263 | ! |
return rc - (1.0-rc)/(2.0*(1.0-kp)) * kp; |
| 264 |
}, |
|
| 265 | ! |
[](double, double, double k){ return k; },
|
| 266 | ! |
[](double rc, double kp, double k){
|
| 267 | ! |
double d = 2.0*k*(1.0-kp) + kp*rc; |
| 268 | ! |
return (std::abs(d) > 1e-12) |
| 269 | ! |
? (2.0*(rc-k)*(1.0-kp) - (1.0-rc)*kp) * k / d : 0.0; }, |
| 270 | ! |
[](double rc, double, double k){
|
| 271 | ! |
return (rc > 1e-12) ? (1.0-rc)/rc * k : 0.0; } |
| 272 |
}); |
|
| 273 | ||
| 274 |
// 19: accuracy, FOR — key=tp |
|
| 275 | ! |
t.push_back({"accuracy","FOR","tp",
|
| 276 |
c0, c1, c0, c1, |
|
| 277 | ! |
[](double ac, double f){ return std::max(0.0, (ac + f - 1.0)/f); },
|
| 278 | ! |
[](double ac, double){ return ac; },
|
| 279 | ! |
[](double, double, double k){ return k; },
|
| 280 | ! |
[](double ac, double f, double k){
|
| 281 | ! |
return (1.0-f > 1e-12) |
| 282 | ! |
? 1.0 - ac - (ac - k)*f/(1.0-f) : 0.0; }, |
| 283 | ! |
[](double ac, double f, double k){
|
| 284 | ! |
return (1.0-f > 1e-12) ? (ac-k)*f/(1.0-f) : 0.0; } |
| 285 |
}); |
|
| 286 | ||
| 287 |
// 20-A: accuracy, kappa — key=tp (branch A: fp uses + sqrt, fn uses - sqrt) |
|
| 288 |
{
|
|
| 289 | ! |
auto l3_ak = [](double ac, double kp){
|
| 290 | ! |
double disc = ac*ac - 2.0*(1.0-ac)*kp/(1.0-kp); |
| 291 |
return (disc >= 0) |
|
| 292 | ! |
? (ac - std::sqrt(disc)) / 2.0 : 0.0; |
| 293 |
}; |
|
| 294 | ! |
auto u3_ak = [](double ac, double kp){
|
| 295 | ! |
double disc = ac*ac - 2.0*(1.0-ac)*kp/(1.0-kp); |
| 296 |
return (disc >= 0) |
|
| 297 | ! |
? (ac + std::sqrt(disc)) / 2.0 : ac; |
| 298 |
}; |
|
| 299 | ! |
auto fp_A = [](double ac, double kp, double k) -> double {
|
| 300 | ! |
double inner = (1.0-ac)*(1.0-ac) |
| 301 | ! |
+ 2.0/(1.0-kp) * (2.0*(k-ac)*k*(1.0-kp) - (1.0-ac)*kp); |
| 302 | ! |
if (inner < 0) inner = 0; |
| 303 | ! |
return ((1.0-ac) + std::sqrt(inner)) / 2.0; |
| 304 |
}; |
|
| 305 | ! |
auto fn_A = [](double ac, double kp, double k) -> double {
|
| 306 | ! |
double inner = (1.0-ac)*(1.0-ac) |
| 307 | ! |
+ 2.0/(1.0-kp) * (2.0*(k-ac)*k*(1.0-kp) - (1.0-ac)*kp); |
| 308 | ! |
if (inner < 0) inner = 0; |
| 309 | ! |
return ((1.0-ac) - std::sqrt(inner)) / 2.0; |
| 310 |
}; |
|
| 311 | ! |
t.push_back({"accuracy","kappa","tp",
|
| 312 |
c0, c1, c0, c1, l3_ak, u3_ak, |
|
| 313 | ! |
[](double,double,double k){ return k; },
|
| 314 |
fp_A, fn_A |
|
| 315 |
}); |
|
| 316 |
} |
|
| 317 | ||
| 318 |
// 20-B: accuracy, kappa — key=tp (branch B: fp uses - sqrt, fn uses + sqrt) |
|
| 319 |
{
|
|
| 320 | ! |
auto l3_ak = [](double ac, double kp){
|
| 321 | ! |
double disc = ac*ac - 2.0*(1.0-ac)*kp/(1.0-kp); |
| 322 | ! |
return (disc >= 0) ? (ac - std::sqrt(disc)) / 2.0 : 0.0; |
| 323 |
}; |
|
| 324 | ! |
auto u3_ak = [](double ac, double kp){
|
| 325 | ! |
double disc = ac*ac - 2.0*(1.0-ac)*kp/(1.0-kp); |
| 326 | ! |
return (disc >= 0) ? (ac + std::sqrt(disc)) / 2.0 : ac; |
| 327 |
}; |
|
| 328 | ! |
auto fp_B = [](double ac, double kp, double k) -> double {
|
| 329 | ! |
double inner = (1.0-ac)*(1.0-ac) |
| 330 | ! |
+ 2.0/(1.0-kp) * (2.0*(k-ac)*k*(1.0-kp) - (1.0-ac)*kp); |
| 331 | ! |
if (inner < 0) inner = 0; |
| 332 | ! |
return ((1.0-ac) - std::sqrt(inner)) / 2.0; |
| 333 |
}; |
|
| 334 | ! |
auto fn_B = [](double ac, double kp, double k) -> double {
|
| 335 | ! |
double inner = (1.0-ac)*(1.0-ac) |
| 336 | ! |
+ 2.0/(1.0-kp) * (2.0*(k-ac)*k*(1.0-kp) - (1.0-ac)*kp); |
| 337 | ! |
if (inner < 0) inner = 0; |
| 338 | ! |
return ((1.0-ac) + std::sqrt(inner)) / 2.0; |
| 339 |
}; |
|
| 340 | ! |
t.push_back({"accuracy","kappa","tp",
|
| 341 |
c0, c1, c0, c1, l3_ak, u3_ak, |
|
| 342 | ! |
[](double,double,double k){ return k; },
|
| 343 |
fp_B, fn_B |
|
| 344 |
}); |
|
| 345 |
} |
|
| 346 | ||
| 347 |
// 21: FOR, kappa — key=tp; u3 = 1 (tp key upper bound is always 1) |
|
| 348 | ! |
t.push_back({"FOR","kappa","tp",
|
| 349 |
c0, c1, |
|
| 350 | ! |
c0, [](double f, double){ return std::min(1.0, 2.0*(1.0-f)/(2.0-f)); },
|
| 351 | ! |
[](double f, double kp){
|
| 352 | ! |
double d = 2.0*(1.0-kp)*(1.0-f); |
| 353 | ! |
return (d > 1e-12) ? kp*f/d : 0.0; }, |
| 354 | ! |
[](double, double){ return 1.0; },
|
| 355 | ! |
[](double f, double kp, double k) -> double {
|
| 356 | ! |
double A = 2.0*(1.0-kp)*((k*(1.0-f)+f*(1.0-k))) + (1.0-f)*kp; |
| 357 | ! |
double B = 8.0*(1.0-kp)*f*(2.0*(1.0-kp)*k*(1.0-(k*(1.0-f)+f)) - (1.0-k)*f*kp); |
| 358 | ! |
double d = 4.0*(1.0-kp)*f; |
| 359 | ! |
if (std::abs(d) < 1e-12) return 0.0; |
| 360 | ! |
double disc = A*A - B; |
| 361 | ! |
if (disc < 0) disc = 0; |
| 362 | ! |
return (A - std::sqrt(disc)) / d; }, |
| 363 | ! |
[](double f, double kp, double k) -> double {
|
| 364 |
// fn = (1-tp)*FOR - fp |
|
| 365 | ! |
double A = 2.0*(1.0-kp)*((k*(1.0-f)+f*(1.0-k))) + (1.0-f)*kp; |
| 366 | ! |
double B = 8.0*(1.0-kp)*f*(2.0*(1.0-kp)*k*(1.0-(k*(1.0-f)+f)) - (1.0-k)*f*kp); |
| 367 | ! |
double d = 4.0*(1.0-kp)*f; |
| 368 | ! |
if (std::abs(d) < 1e-12) return 0.0; |
| 369 | ! |
double disc = A*A - B; |
| 370 | ! |
if (disc < 0) disc = 0; |
| 371 | ! |
double fp_val = (A - std::sqrt(disc)) / d; |
| 372 | ! |
return (1.0-k)*f - fp_val; } |
| 373 |
}); |
|
| 374 | ||
| 375 | ! |
return t; |
| 376 |
} |
|
| 377 | ||
| 378 |
// ── Global table (built once) ───────────────────────────────────────────────── |
|
| 379 | ||
| 380 | ! |
inline const std::vector<FormulaRow>& formula_table() {
|
| 381 | ! |
static const auto t = make_formula_table(); |
| 382 | ! |
return t; |
| 383 |
} |
|
| 384 | ||
| 385 |
// ── Name normalization ──────────────────────────────────────────────────────── |
|
| 386 | ||
| 387 | ! |
inline std::string normalize_metric_name(const std::string& n) {
|
| 388 | ! |
if (n == "baserate") return "b1"; |
| 389 | ! |
if (n == "baserate2") return "b2"; |
| 390 | ! |
if (n == "FDR") return "precision"; // FDR = 1 - precision |
| 391 | ! |
return n; |
| 392 |
} |
|
| 393 | ||
| 394 |
// ── Find a unique formula row for a pair (v1, v2) ──────────────────────────── |
|
| 395 |
// Returns nullptr if not found or ambiguous (accuracy+kappa has two rows). |
|
| 396 | ! |
inline const FormulaRow* find_formula(const std::string& v1, |
| 397 |
const std::string& v2) {
|
|
| 398 | ! |
const auto& t = formula_table(); |
| 399 | ! |
const FormulaRow* found = nullptr; |
| 400 | ! |
int count = 0; |
| 401 | ! |
for (const auto& row : t) {
|
| 402 | ! |
if ((row.var1 == v1 && row.var2 == v2) || |
| 403 | ! |
(row.var1 == v2 && row.var2 == v1)) {
|
| 404 | ! |
found = &row; |
| 405 | ! |
++count; |
| 406 |
} |
|
| 407 |
} |
|
| 408 | ! |
if (count != 1) return nullptr; // 0 = not found; >1 = ambiguous (accuracy+kappa) |
| 409 | ! |
return found; |
| 410 |
} |
|
| 411 | ||
| 412 |
// ── Evaluate tp/fp/fn from a formula row, given v1, v2, key ────────────────── |
|
| 413 |
struct CMResult {
|
|
| 414 |
double tp, fp, fn, tn; |
|
| 415 |
bool valid = true; |
|
| 416 |
}; |
|
| 417 | ||
| 418 | ! |
inline CMResult eval_formula(const FormulaRow& row, |
| 419 |
double v1, double v2, double key) {
|
|
| 420 |
// Ensure (var1, var2) are in canonical order |
|
| 421 | ! |
double a = v1, b = v2; |
| 422 | ! |
if (row.var1 != row.var1) { a = v2; b = v1; } // no-op placeholder
|
| 423 | ||
| 424 | ! |
double tp = row.tp(a, b, key); |
| 425 | ! |
double fp = row.fp(a, b, key); |
| 426 | ! |
double fn = row.fn(a, b, key); |
| 427 |
// Clamp near-zero numerical noise |
|
| 428 | ! |
if (tp < 0 && std::abs(tp) < 1e-10) tp = 1e-10; |
| 429 | ! |
if (fp < 0 && std::abs(fp) < 1e-10) fp = 0.0; |
| 430 | ! |
if (fn < 0 && std::abs(fn) < 1e-10) fn = 0.0; |
| 431 | ! |
double tn = 1.0 - tp - fp - fn; |
| 432 | ! |
if (tn < 0 && std::abs(tn) < 1e-10) tn = 1e-10; |
| 433 | ! |
bool ok = (tp >= 0 && tp <= 1 && fp >= 0 && fp <= 1 && |
| 434 | ! |
fn >= 0 && fn <= 1 && tn >= 0 && tn <= 1); |
| 435 | ! |
return {tp, fp, fn, tn, ok};
|
| 436 |
} |
|
| 437 | ||
| 438 |
// ── range() ────────────────────────────────────────────────────────────────── |
|
| 439 |
// Given two metric values, return the [min, max] range of a third metric. |
|
| 440 |
// Mirrors R's range(list(baserate=0.1, precision=0.85), "kappa"). |
|
| 441 | ||
| 442 | ! |
inline MetricRange range(const std::string& name1, double val1, |
| 443 |
const std::string& name2, double val2, |
|
| 444 |
const std::string& target) {
|
|
| 445 | ! |
const std::string n1 = normalize_metric_name(name1); |
| 446 | ! |
const std::string n2 = normalize_metric_name(name2); |
| 447 | ! |
const std::string nt = normalize_metric_name(target); |
| 448 | ||
| 449 |
// FDR input: convert to precision value |
|
| 450 | ! |
double v1 = val1, v2 = val2; |
| 451 | ! |
if (name1 == "FDR") v1 = 1.0 - val1; |
| 452 | ! |
if (name2 == "FDR") v2 = 1.0 - val2; |
| 453 | ||
| 454 | ! |
const FormulaRow* row = find_formula(n1, n2); |
| 455 | ! |
if (!row) throw std::invalid_argument("range: formula not found for pair");
|
| 456 | ||
| 457 |
// Ensure v1/v2 align with row->var1/var2 |
|
| 458 | ! |
double a = v1, b = v2; |
| 459 | ! |
if (row->var1 == n2) { a = v2; b = v1; }
|
| 460 | ||
| 461 |
// Key bounds |
|
| 462 | ! |
const double l3 = row->l3(a, b) + 1e-10; |
| 463 | ! |
const double u3 = row->u3(a, b) - 1e-10; |
| 464 | ! |
if (l3 >= u3) throw std::domain_error("range: invalid key range");
|
| 465 | ||
| 466 | ! |
auto eval_target = [&](double key) -> double {
|
| 467 | ! |
auto cm = eval_formula(*row, a, b, key); |
| 468 | ! |
if (!cm.valid) throw std::domain_error("range: invalid confusion matrix");
|
| 469 | ! |
double s = cm.tp + cm.fp + cm.fn + cm.tn; |
| 470 | ! |
Metrics m = metrics_from_cm({cm.tp/s, cm.fp/s, cm.fn/s, cm.tn/s});
|
| 471 | ! |
return get_metric(m, nt); |
| 472 |
}; |
|
| 473 | ||
| 474 |
// 5-point interval narrowing for max |
|
| 475 | ! |
double max_v = -1e300, min_v = 1e300; |
| 476 |
{
|
|
| 477 | ! |
double left = l3, right = u3; |
| 478 | ! |
int max_pos = 0; |
| 479 | ! |
bool monotonic = false; |
| 480 | ! |
while (right - left > 1e-6) {
|
| 481 | ! |
double step = (right - left) / 4.0; |
| 482 | ! |
double best = -1e300; |
| 483 | ! |
for (int i = 0; i <= 4; ++i) {
|
| 484 | ! |
double key = left + i * step; |
| 485 | ! |
double v = eval_target(key); |
| 486 | ! |
if (v > best) { best = v; max_pos = i; }
|
| 487 |
} |
|
| 488 | ! |
max_v = best; |
| 489 | ! |
if (max_pos == 0 || max_pos == 4) { monotonic = true; break; }
|
| 490 | ! |
left = left + (max_pos - 1) * step; |
| 491 | ! |
right = left + 2.0 * step; |
| 492 |
} |
|
| 493 |
(void)monotonic; |
|
| 494 |
} |
|
| 495 |
// 5-point interval narrowing for min |
|
| 496 |
{
|
|
| 497 | ! |
double left = l3, right = u3; |
| 498 | ! |
bool monotonic = false; |
| 499 | ! |
while (right - left > 1e-6) {
|
| 500 | ! |
double step = (right - left) / 4.0; |
| 501 | ! |
double best = 1e300; |
| 502 | ! |
int min_pos = 0; |
| 503 | ! |
for (int i = 0; i <= 4; ++i) {
|
| 504 | ! |
double key = left + i * step; |
| 505 | ! |
double v = eval_target(key); |
| 506 | ! |
if (v < best) { best = v; min_pos = i; }
|
| 507 |
} |
|
| 508 | ! |
min_v = best; |
| 509 | ! |
if (min_pos == 0 || min_pos == 4) { monotonic = true; break; }
|
| 510 | ! |
left = left + (min_pos - 1) * step; |
| 511 | ! |
right = left + 2.0 * step; |
| 512 |
} |
|
| 513 |
(void)monotonic; |
|
| 514 |
} |
|
| 515 | ||
| 516 | ! |
return {min_v, max_v};
|
| 517 |
} |
|
| 518 | ||
| 519 |
// ── derive() ───────────────────────────────────────────────────────────────── |
|
| 520 |
// Given values for three independent metrics, return all metrics. |
|
| 521 |
// Mirrors R's derive(list(baserate=0.1, FDR=0.15, FOR=0.05)). |
|
| 522 |
// |
|
| 523 |
// Inputs: three (name, value) pairs. "kappa" must not be one of the first two |
|
| 524 |
// (it is the target variable in the 5-point search). |
|
| 525 | ||
| 526 | ! |
inline Metrics derive(const std::string& name1, double val1, |
| 527 |
const std::string& name2, double val2, |
|
| 528 |
const std::string& name3, double val3) {
|
|
| 529 |
// Normalize names |
|
| 530 |
std::string n[3] = {
|
|
| 531 |
normalize_metric_name(name1), |
|
| 532 |
normalize_metric_name(name2), |
|
| 533 |
normalize_metric_name(name3) |
|
| 534 |
}; |
|
| 535 | ! |
double v[3] = {val1, val2, val3};
|
| 536 | ||
| 537 |
// FDR → precision (inverted value) |
|
| 538 | ! |
if (name1 == "FDR") v[0] = 1.0 - val1; |
| 539 | ! |
if (name2 == "FDR") v[1] = 1.0 - val2; |
| 540 | ! |
if (name3 == "FDR") v[2] = 1.0 - val3; |
| 541 | ||
| 542 |
// Move kappa to position 2 (it becomes the search target) |
|
| 543 | ! |
for (int i = 0; i < 2; ++i) {
|
| 544 | ! |
if (n[i] == "kappa") {
|
| 545 | ! |
std::swap(n[i], n[2]); std::swap(v[i], v[2]); |
| 546 | ! |
break; |
| 547 |
} |
|
| 548 |
} |
|
| 549 | ! |
const std::string& target_name = n[2]; |
| 550 | ! |
const double target_val = v[2]; |
| 551 | ||
| 552 |
// Validate range of the target |
|
| 553 | ! |
MetricRange rng = range(name1, val1, name2, val2, name3); |
| 554 |
// For normalized names after FDR conversion the range call uses the |
|
| 555 |
// converted names — call with original names so alias handling fires. |
|
| 556 | ! |
rng = range(name1, val1, name2, val2, name3); |
| 557 | ! |
if (target_val < rng.min || target_val > rng.max) |
| 558 | ! |
throw std::domain_error("derive: target metric value outside valid range");
|
| 559 | ||
| 560 |
// Get formula for (n[0], n[1]) |
|
| 561 | ! |
const FormulaRow* row = find_formula(n[0], n[1]); |
| 562 | ! |
if (!row) throw std::invalid_argument("derive: formula not found for pair");
|
| 563 | ||
| 564 | ! |
double a = v[0], b = v[1]; |
| 565 | ! |
if (row->var1 == n[1]) { a = v[1]; b = v[0]; }
|
| 566 | ||
| 567 | ! |
const double l3 = row->l3(a, b) + 1e-10; |
| 568 | ! |
const double u3 = row->u3(a, b) - 1e-10; |
| 569 | ||
| 570 | ! |
auto eval_target = [&](double key) -> double {
|
| 571 | ! |
auto cm = eval_formula(*row, a, b, key); |
| 572 | ! |
if (!cm.valid) return target_val; // keep search moving |
| 573 | ! |
double s = cm.tp + cm.fp + cm.fn + cm.tn; |
| 574 | ! |
Metrics m = metrics_from_cm({cm.tp/s, cm.fp/s, cm.fn/s, cm.tn/s});
|
| 575 | ! |
return get_metric(m, target_name); |
| 576 |
}; |
|
| 577 | ||
| 578 |
// 5-point interval narrowing to find the key value |
|
| 579 | ! |
double left = l3, right = u3; |
| 580 | ! |
while (right - left > 1e-6) {
|
| 581 |
double x5[5]; |
|
| 582 | ! |
double step = (right - left) / 4.0; |
| 583 | ! |
for (int i = 0; i < 5; ++i) x5[i] = left + i * step; |
| 584 | ||
| 585 | ! |
double best_err = 1e300; |
| 586 | ! |
int best_idx = 0; |
| 587 | ! |
for (int i = 0; i < 5; ++i) {
|
| 588 | ! |
double err = std::abs(eval_target(x5[i]) - target_val); |
| 589 | ! |
if (err < best_err) { best_err = err; best_idx = i; }
|
| 590 |
} |
|
| 591 | ! |
if (best_idx == 0) right = x5[1]; |
| 592 | ! |
else if (best_idx == 4) left = x5[3]; |
| 593 | ! |
else { left = x5[best_idx-1]; right = x5[best_idx+1]; }
|
| 594 |
} |
|
| 595 | ! |
const double key_final = (left + right) / 2.0; |
| 596 | ! |
auto cm = eval_formula(*row, a, b, key_final); |
| 597 | ! |
double s = cm.tp + cm.fp + cm.fn + cm.tn; |
| 598 | ! |
return metrics_from_cm({cm.tp/s, cm.fp/s, cm.fn/s, cm.tn/s});
|
| 599 |
} |
|
| 600 | ||
| 601 |
// Convenience: derive from a closed-form (b1 + FDR + FOR) — fast path used |
|
| 602 |
// by libcodey without the formula search. |
|
| 603 | ! |
inline Metrics derive_from_rates(double b1, double fdr, double for_) {
|
| 604 | ! |
const double fp = fdr * b1; |
| 605 | ! |
const double tp = b1 - fp; |
| 606 | ! |
const double fn = for_ * (1.0 - b1); |
| 607 | ! |
const double tn = 1.0 - fp - tp - fn; |
| 608 | ! |
return metrics_from_cm({tp, fp, fn, tn});
|
| 609 |
} |
|
| 610 | ||
| 611 |
} // namespace qestats |
| 1 |
#pragma once |
|
| 2 |
#include "metrics.hpp" |
|
| 3 |
#include <cmath> |
|
| 4 |
#include <stdexcept> |
|
| 5 |
#include <vector> |
|
| 6 | ||
| 7 |
namespace qestats {
|
|
| 8 | ||
| 9 |
// ── Hypergeometric CDF ──────────────────────────────────────────────────────── |
|
| 10 |
// P(X <= q) where X ~ Hypergeometric(N, K, n) |
|
| 11 |
// K = number of successes in population |
|
| 12 |
// N - K = failures in population |
|
| 13 |
// n = sample size |
|
| 14 |
// |
|
| 15 |
// Computed in log-space to avoid overflow. Equivalent to R's phyper(q,K,N-K,n). |
|
| 16 | ||
| 17 | ! |
inline double phyper(int q, int K, int N_minus_K, int n) {
|
| 18 | ! |
if (q < 0) return 0.0; |
| 19 | ! |
const int N = K + N_minus_K; |
| 20 | ! |
if (n > N || K < 0 || N_minus_K < 0) return 0.0; |
| 21 | ||
| 22 |
// log of C(a, b) |
|
| 23 | ! |
auto lnchoose = [](int a, int b) -> double {
|
| 24 | ! |
if (b < 0 || b > a) return -1e300; |
| 25 | ! |
if (b == 0 || b == a) return 0.0; |
| 26 | ! |
return std::lgamma(a + 1) - std::lgamma(b + 1) - std::lgamma(a - b + 1); |
| 27 |
}; |
|
| 28 | ||
| 29 | ! |
const double log_denom = lnchoose(N, n); |
| 30 | ! |
double sum = 0.0; |
| 31 | ! |
const int hi = std::min(q, std::min(K, n)); |
| 32 | ! |
for (int k = std::max(0, n - N_minus_K); k <= hi; ++k) {
|
| 33 | ! |
double log_num = lnchoose(K, k) + lnchoose(N_minus_K, n - k); |
| 34 | ! |
sum += std::exp(log_num - log_denom); |
| 35 |
} |
|
| 36 | ! |
return std::min(sum, 1.0); |
| 37 |
} |
|
| 38 | ||
| 39 |
// ── Hypergeometric confidence interval for proportion ───────────────────────── |
|
| 40 |
// Estimates CI for the proportion of positives in a population. |
|
| 41 |
// R equivalent: hyper_range(n_pos, n, N, alpha) |
|
| 42 |
// n_pos = positives in sample |
|
| 43 |
// n = sample size |
|
| 44 |
// N = population size |
|
| 45 |
// alpha = significance level |
|
| 46 | ||
| 47 | ! |
inline Interval hyper_range(int n_pos, int n, int N, double alpha = 0.05) {
|
| 48 | ! |
if (n_pos < 0 || n_pos > n || n > N || alpha <= 0 || alpha >= 1) |
| 49 | ! |
throw std::domain_error("hyper_range: invalid input parameters");
|
| 50 | ||
| 51 | ! |
const int n_neg = n - n_pos; |
| 52 | ! |
int M_lower = n_neg; |
| 53 | ! |
int M_upper = N - n_pos; |
| 54 | ||
| 55 |
// Shrink M_lower upward until phyper tail >= alpha/2 |
|
| 56 |
{
|
|
| 57 | ! |
double p_lower = phyper(n_pos, N - M_lower, M_lower, n); |
| 58 | ! |
if (p_lower < alpha / 2) {
|
| 59 | ! |
int left = M_lower, right = M_upper; |
| 60 | ! |
while (right - left > 2) {
|
| 61 | ! |
M_lower = (left + right + 1) / 2; |
| 62 | ! |
double p = phyper(n_pos - 1, N - M_lower, M_lower, n); |
| 63 | ! |
if (p < alpha / 2) left = M_lower; |
| 64 | ! |
else right = M_lower; |
| 65 |
} |
|
| 66 | ! |
M_lower = left; |
| 67 |
} |
|
| 68 |
} |
|
| 69 | ||
| 70 |
// Shrink M_upper downward until upper tail >= alpha/2 |
|
| 71 |
{
|
|
| 72 | ! |
double p_upper = 1.0 - phyper(n_pos, N - M_upper, M_upper, n); |
| 73 | ! |
if (p_upper < alpha / 2) {
|
| 74 | ! |
int left = M_lower, right = M_upper; |
| 75 | ! |
while (right - left > 2) {
|
| 76 | ! |
M_upper = (left + right + 1) / 2; |
| 77 | ! |
double p = 1.0 - phyper(n_pos - 1, N - M_upper, M_upper, n); |
| 78 | ! |
if (p < alpha / 2) right = M_upper; |
| 79 | ! |
else left = M_upper; |
| 80 |
} |
|
| 81 | ! |
M_upper = right; |
| 82 |
} |
|
| 83 |
} |
|
| 84 | ||
| 85 |
return {
|
|
| 86 | ! |
1.0 - static_cast<double>(M_upper) / N, |
| 87 | ! |
(n > 0) ? static_cast<double>(n_pos) / n : 0.0, |
| 88 | ! |
1.0 - static_cast<double>(M_lower) / N |
| 89 |
}; |
|
| 90 |
} |
|
| 91 | ||
| 92 |
// ── Perfect sampling: proportion ────────────────────────────────────────────── |
|
| 93 |
// Minimum sample size n such that if all items in the sample satisfy the |
|
| 94 |
// property, H0: x <= x0 is rejected at level alpha. |
|
| 95 |
// Returns {n, power, cost}; n = -1 on invalid input.
|
|
| 96 | ||
| 97 |
struct PsResult {
|
|
| 98 |
int n = -1; |
|
| 99 |
double power = 0; |
|
| 100 |
double cost = 0; |
|
| 101 |
}; |
|
| 102 | ||
| 103 | ! |
inline PsResult ps_proportion(double x0, double x1 = 0, |
| 104 |
int N = 0, int allowed_errors = 0, |
|
| 105 |
double alpha = 0.05) {
|
|
| 106 | ! |
if (alpha < 0.001 || alpha > 0.1) return {};
|
| 107 | ! |
if (x0 < 0.5 || x0 >= 1.0) return {};
|
| 108 | ||
| 109 | ! |
const int a = allowed_errors; |
| 110 | ! |
if (N <= 0) N = 1000000; |
| 111 | ! |
const int K = static_cast<int>(std::ceil(x0 * N)); |
| 112 | ! |
if (K == N) return {N, 1.0, 1.0};
|
| 113 | ! |
if (a >= static_cast<int>(K * (1.0 - x0))) return {};
|
| 114 | ||
| 115 |
// Binary search for min n: P(X >= n - a | K, N-K, n) <= alpha |
|
| 116 |
// i.e. 1 - phyper(n-1-a, K, N-K, n) <= alpha |
|
| 117 | ! |
int left = a, right = K; |
| 118 | ! |
double p = 1.0; |
| 119 | ! |
while (left < right - 1) {
|
| 120 | ! |
int n = (left + right + 1) / 2; |
| 121 | ! |
p = 1.0 - phyper(n - 1 - a, K, N - K, n); |
| 122 | ! |
if (p > alpha) left = n; |
| 123 | ! |
else right = n; |
| 124 |
} |
|
| 125 | ! |
const int n_out = right; |
| 126 | ||
| 127 | ! |
if (x1 <= x0 || x1 >= 1.0) x1 = (x0 + 1.0) / 2.0; |
| 128 | ! |
const double K1 = x1 * N; |
| 129 | ! |
const int K1i = static_cast<int>(K1); |
| 130 | ! |
const double power = 1.0 - phyper(n_out - 1 - a, K1i, N - K1i, n_out); |
| 131 | ! |
return {n_out, power, static_cast<double>(n_out) / power};
|
| 132 |
} |
|
| 133 | ||
| 134 |
// ── Perfect sampling: kappa ─────────────────────────────────────────────────── |
|
| 135 |
// Derives the maximum achievable accuracy for (baserate, kappa) via range(), |
|
| 136 |
// then delegates to ps_proportion. |
|
| 137 |
// Declared here; implemented after derivation.hpp is included in qestats.hpp. |
|
| 138 |
// Forward declaration only — see qestats.hpp for the full definition. |
|
| 139 | ||
| 140 |
// ── ps_est_proportion ──────────────────────────────────────────────────────── |
|
| 141 |
// Given a perfect sample of size n, estimate the minimum proportion of |
|
| 142 |
// positives in the population. |
|
| 143 | ||
| 144 | ! |
inline double ps_est_proportion(int n, int N = 0, int allowed_errors = 0, |
| 145 |
double alpha = 0.05) {
|
|
| 146 | ! |
if (alpha < 0.001 || alpha > 0.1) return -1; |
| 147 | ! |
const int a = allowed_errors; |
| 148 | ! |
if (a >= n) return -1; |
| 149 | ! |
if (N <= 0) N = 1000000; |
| 150 | ! |
if (n < 1 || n >= N) return -1; |
| 151 | ||
| 152 | ! |
int left = 1, right = N; |
| 153 | ! |
double x = 0; |
| 154 | ! |
while (right - left > 1) {
|
| 155 | ! |
int K = (left + right + 1) / 2; |
| 156 | ! |
double p = 1.0 - phyper(n - 1 - a, K, N - K, n); |
| 157 | ! |
x = static_cast<double>(K) / N; |
| 158 | ! |
if (p < alpha) left = K; |
| 159 | ! |
else right = K; |
| 160 |
} |
|
| 161 | ! |
return x; |
| 162 |
} |
|
| 163 | ||
| 164 |
// ── ps_est_kappa ────────────────────────────────────────────────────────────── |
|
| 165 |
// Given a perfect sample of size n, estimate the minimum kappa achievable. |
|
| 166 |
// Declared here; depends on range() from derivation.hpp — implemented in |
|
| 167 |
// comparison.hpp after derivation.hpp is included. |
|
| 168 |
// Forward-declared below; full definition is in comparison.hpp. |
|
| 169 | ||
| 170 |
// ── metrics_range ───────────────────────────────────────────────────────────── |
|
| 171 |
// Estimates CI for precision, recall, accuracy, FDR, FOR, and kappa given a |
|
| 172 |
// sampled confusion matrix (raw counts) and population size N. |
|
| 173 |
// Returns a vector of MetricRangeRow in the same order as R's metrics_range(). |
|
| 174 | ||
| 175 |
inline std::vector<MetricRangeRow> |
|
| 176 | ! |
metrics_range(double tp, double fp, double fn, double tn, |
| 177 |
int N, double baserate, double alpha = 0.05) {
|
|
| 178 |
// Helper: derive metrics from (baserate, FDR, FOR) |
|
| 179 | ! |
auto metrics_from_bFF = [](double b, double FDR, double FOR) -> Metrics {
|
| 180 | ! |
const double fp_ = FDR * b; |
| 181 | ! |
const double tp_ = b - fp_; |
| 182 | ! |
const double fn_ = FOR * (1.0 - b); |
| 183 | ! |
const double tn_ = 1.0 - fp_ - tp_ - fn_; |
| 184 | ! |
return metrics_from_cm({tp_, fp_, fn_, tn_});
|
| 185 |
}; |
|
| 186 | ||
| 187 |
// Precision CI via hyper_range (full alpha) |
|
| 188 | ! |
Interval prec = hyper_range(static_cast<int>(tp), |
| 189 | ! |
static_cast<int>(tp + fp), N, alpha); |
| 190 | ! |
Interval FDR_ci = {1.0 - prec.upper, 1.0 - prec.mean, 1.0 - prec.lower};
|
| 191 | ||
| 192 |
// FOR CI: tn / (fn + tn) via hyper_range, then invert |
|
| 193 | ! |
Interval one_minus_FOR = hyper_range(static_cast<int>(tn), |
| 194 | ! |
static_cast<int>(fn + tn), N, alpha); |
| 195 | ! |
Interval FOR_ci = {1.0 - one_minus_FOR.upper,
|
| 196 | ! |
1.0 - one_minus_FOR.mean, |
| 197 | ! |
1.0 - one_minus_FOR.lower}; |
| 198 | ||
| 199 |
// Derive recall, accuracy, kappa at lower/mean/upper FDR+FOR extremes |
|
| 200 | ! |
Metrics lower_m = metrics_from_bFF(baserate, FDR_ci.upper, FOR_ci.upper); |
| 201 | ! |
Metrics mean_m = metrics_from_bFF(baserate, FDR_ci.mean, FOR_ci.mean); |
| 202 | ! |
Metrics upper_m = metrics_from_bFF(baserate, FDR_ci.lower, FOR_ci.lower); |
| 203 | ||
| 204 |
return {
|
|
| 205 | ! |
{"precision", prec.lower, prec.mean, prec.upper},
|
| 206 | ! |
{"recall", lower_m.recall, mean_m.recall, upper_m.recall},
|
| 207 | ! |
{"accuracy", lower_m.accuracy, mean_m.accuracy, upper_m.accuracy},
|
| 208 | ! |
{"FDR", FDR_ci.lower, FDR_ci.mean, FDR_ci.upper},
|
| 209 | ! |
{"FOR", FOR_ci.lower, FOR_ci.mean, FOR_ci.upper},
|
| 210 | ! |
{"kappa", lower_m.kappa, mean_m.kappa, upper_m.kappa},
|
| 211 |
}; |
|
| 212 |
} |
|
| 213 | ||
| 214 |
} // namespace qestats |
| 1 |
#pragma once |
|
| 2 |
#include <algorithm> |
|
| 3 |
#include <cmath> |
|
| 4 |
#include <stdexcept> |
|
| 5 |
#include <string> |
|
| 6 | ||
| 7 |
// Functions that must reproduce R's arithmetic (rhoR parity) start their body |
|
| 8 |
// with QESTATS_R_ARITHMETIC and carry QESTATS_R_ARITHMETIC_FN: R never fuses |
|
| 9 |
// a * b + c, so these keep the compiler from contracting it into an FMA. |
|
| 10 |
#if defined(__clang__) |
|
| 11 |
# define QESTATS_R_ARITHMETIC _Pragma("STDC FP_CONTRACT OFF")
|
|
| 12 |
# define QESTATS_R_ARITHMETIC_FN |
|
| 13 |
#elif defined(__GNUC__) |
|
| 14 |
# define QESTATS_R_ARITHMETIC |
|
| 15 |
# define QESTATS_R_ARITHMETIC_FN __attribute__((optimize("fp-contract=off")))
|
|
| 16 |
#else |
|
| 17 |
# define QESTATS_R_ARITHMETIC |
|
| 18 |
# define QESTATS_R_ARITHMETIC_FN |
|
| 19 |
#endif |
|
| 20 | ||
| 21 |
namespace qestats {
|
|
| 22 | ||
| 23 |
// Confusion matrix proportions (must sum to 1 after normalization). |
|
| 24 |
struct ConfusionMatrix {
|
|
| 25 |
double tp = 0, fp = 0, fn = 0, tn = 0; |
|
| 26 |
}; |
|
| 27 | ||
| 28 |
// All derived classification metrics. |
|
| 29 |
struct Metrics {
|
|
| 30 |
// confusion matrix proportions |
|
| 31 |
double tp = 0, fp = 0, fn = 0, tn = 0; |
|
| 32 |
// derived |
|
| 33 |
double b1 = 0; // classifier positive rate (tp + fp) |
|
| 34 |
double b2 = 0; // true positive rate (tp + fn) |
|
| 35 |
double precision = 0; // tp / (tp + fp) |
|
| 36 |
double recall = 0; // tp / (tp + fn) |
|
| 37 |
double accuracy = 0; // tp + tn |
|
| 38 |
double fdr = 0; // fp / (tp + fp) — False Discovery Rate |
|
| 39 |
double for_ = 0; // fn / (fn + tn) — False Omission Rate |
|
| 40 |
double kappa = 0; |
|
| 41 |
}; |
|
| 42 | ||
| 43 |
// Range result — min/max of a metric over the key variable interval. |
|
| 44 |
struct MetricRange {
|
|
| 45 |
double min = 0; |
|
| 46 |
double max = 0; |
|
| 47 |
}; |
|
| 48 | ||
| 49 |
// Confidence interval from a hypergeometric estimate. |
|
| 50 |
struct Interval {
|
|
| 51 |
double lower = 0; |
|
| 52 |
double mean = 0; |
|
| 53 |
double upper = 0; |
|
| 54 |
}; |
|
| 55 | ||
| 56 |
// One row in the metrics_range output table. |
|
| 57 |
struct MetricRangeRow {
|
|
| 58 |
std::string metric; |
|
| 59 |
double lower = 0, mean = 0, upper = 0; |
|
| 60 |
}; |
|
| 61 | ||
| 62 |
// ── Core arithmetic ─────────────────────────────────────────────────────────── |
|
| 63 | ||
| 64 |
// Cohen's kappa from confusion-matrix counts (or proportions), computed in |
|
| 65 |
// the same order as rhoR's calcKappa() so the two agree to the bit: perfect |
|
| 66 |
// agreement on one class (all TP or all TN) is 1. |
|
| 67 |
QESTATS_R_ARITHMETIC_FN |
|
| 68 | 3203x |
inline double kappa_from_counts(double tp, double fp, double fn, double tn) {
|
| 69 |
QESTATS_R_ARITHMETIC |
|
| 70 | 3203x |
const double s = tp + fp + fn + tn; |
| 71 | 3203x |
if (tp == s || tn == s) return 1.0; |
| 72 | 3201x |
const double agreement = (tp + tn) / s; |
| 73 | 3201x |
const double rate1 = (tp + fn) / s; // first rater's positive rate |
| 74 | 3201x |
const double rate2 = (tp + fp) / s; // second rater's positive rate |
| 75 | 3201x |
const double pe = rate1 * rate2 + (1 - rate1) * (1 - rate2); |
| 76 |
return (pe < 1.0) ? (agreement - pe) / (1 - pe) : 0.0; |
|
| 77 |
} |
|
| 78 | ||
| 79 |
// Rates are computed from the counts, not the normalized cells, so precision |
|
| 80 |
// and recall match rhoR's TP / (TP + FP) and TP / (TP + FN) exactly. |
|
| 81 | 3x |
inline Metrics metrics_from_cm(const ConfusionMatrix& cm) {
|
| 82 | 3x |
const double s = cm.tp + cm.fp + cm.fn + cm.tn; |
| 83 |
if (s <= 0) throw std::domain_error("metrics_from_cm: confusion matrix is empty");
|
|
| 84 | 3x |
Metrics m; |
| 85 | 3x |
m.tp = cm.tp / s; m.fp = cm.fp / s; |
| 86 | 3x |
m.fn = cm.fn / s; m.tn = cm.tn / s; |
| 87 | 3x |
const double pos1 = cm.tp + cm.fp, pos2 = cm.tp + cm.fn, neg1 = cm.fn + cm.tn; |
| 88 | 3x |
m.b1 = pos1 / s; |
| 89 | 3x |
m.b2 = pos2 / s; |
| 90 | 3x |
m.precision = (pos1 > 0) ? cm.tp / pos1 : 0.0; |
| 91 | 3x |
m.recall = (pos2 > 0) ? cm.tp / pos2 : 0.0; |
| 92 | 3x |
m.accuracy = (cm.tp + cm.tn) / s; |
| 93 | 3x |
m.fdr = (pos1 > 0) ? cm.fp / pos1 : 0.0; |
| 94 | 3x |
m.for_ = (neg1 > 0) ? cm.fn / neg1 : 0.0; |
| 95 | 3x |
m.kappa = kappa_from_counts(cm.tp, cm.fp, cm.fn, cm.tn); |
| 96 | 3x |
return m; |
| 97 |
} |
|
| 98 | ||
| 99 |
// Convenience: from raw counts (negative values clamped to 0). |
|
| 100 | 3x |
inline Metrics calculate_metrics(double tp, double fp, double fn, double tn) {
|
| 101 |
if (tp < 0) tp = 0; if (fp < 0) fp = 0; |
|
| 102 |
if (fn < 0) fn = 0; if (tn < 0) tn = 0; |
|
| 103 | 3x |
return metrics_from_cm({tp, fp, fn, tn});
|
| 104 |
} |
|
| 105 | ||
| 106 |
// Retrieve a single metric value by name (supports alias names). |
|
| 107 | ! |
inline double get_metric(const Metrics& m, const std::string& name) {
|
| 108 | ! |
if (name == "b1" || name == "baserate") return m.b1; |
| 109 | ! |
if (name == "b2" || name == "baserate2") return m.b2; |
| 110 | ! |
if (name == "precision") return m.precision; |
| 111 | ! |
if (name == "recall") return m.recall; |
| 112 | ! |
if (name == "accuracy") return m.accuracy; |
| 113 | ! |
if (name == "FDR") return m.fdr; |
| 114 | ! |
if (name == "FOR") return m.for_; |
| 115 | ! |
if (name == "kappa") return m.kappa; |
| 116 | ! |
if (name == "tp") return m.tp; |
| 117 | ! |
if (name == "fp") return m.fp; |
| 118 | ! |
if (name == "fn") return m.fn; |
| 119 | ! |
if (name == "tn") return m.tn; |
| 120 | ! |
throw std::invalid_argument("get_metric: unknown metric '" + name + "'");
|
| 121 |
} |
|
| 122 | ||
| 123 |
} // namespace qestats |
| 1 |
#pragma once |
|
| 2 |
#include "metrics.hpp" |
|
| 3 |
#include <algorithm> |
|
| 4 |
#include <cmath> |
|
| 5 |
#include <cstdint> |
|
| 6 |
#include <cstdlib> |
|
| 7 |
#include <random> |
|
| 8 |
#include <stdexcept> |
|
| 9 |
#include <string> |
|
| 10 |
#include <type_traits> |
|
| 11 |
#include <vector> |
|
| 12 | ||
| 13 |
// Rho (Shaffer's rho): how likely a test set's kappa this high would be if the |
|
| 14 |
// coding process's population kappa were below a threshold. A port of rhoR: |
|
| 15 |
// given an adapter over R's RNG, a seeded rho() returns exactly what rhoR does. |
|
| 16 |
// |
|
| 17 |
// rhoR computes rho two ways, and both are kept, quirks included: |
|
| 18 |
// |
|
| 19 |
// RhoMethod::Standard rhoR's default path, written in R (calcRho): grids |
|
| 20 |
// from seq(), draws from sample.int(), the hand set |
|
| 21 |
// sampled as rows of the full set. qestats::rho_standard. |
|
| 22 |
// RhoMethod::Fast rhoR's C++ path (calcRho_c, any other `method`): |
|
| 23 |
// Armadillo regspace() grids, RcppArmadillo sample() / |
|
| 24 |
// randi() draws, the hand set sampled from the |
|
| 25 |
// contingency table. qestats::rho_fast. |
|
| 26 |
// |
|
| 27 |
// Random numbers come from an Rng: any type with |
|
| 28 |
// |
|
| 29 |
// double unif_rand(); // uniform on (0, 1) — R's unif_rand() |
|
| 30 |
// double unif_index(double n); // uniform integer in [0, n) — R's R_unif_index() |
|
| 31 |
// |
|
| 32 |
// The Standard method draws only through unif_index, the Fast method only |
|
| 33 |
// through unif_rand. StdRng is a standalone default. |
|
| 34 | ||
| 35 |
namespace qestats {
|
|
| 36 | ||
| 37 |
// mt19937_64 with the draws derived by hand (the std distributions differ |
|
| 38 |
// between standard libraries), so a seed gives the same rho everywhere. |
|
| 39 |
struct StdRng {
|
|
| 40 |
std::mt19937_64 gen; |
|
| 41 |
explicit StdRng(std::uint64_t seed = 5489u) : gen(seed) {}
|
|
| 42 |
double unif_rand() {
|
|
| 43 |
double u; |
|
| 44 |
do { u = static_cast<double>(gen() >> 11) * 0x1.0p-53; } while (u <= 0.0);
|
|
| 45 |
return u; |
|
| 46 |
} |
|
| 47 |
// Rejection on the top bits, like R_unif_index. |
|
| 48 |
double unif_index(double n) {
|
|
| 49 |
const auto m = static_cast<std::uint64_t>(n); |
|
| 50 |
if (m <= 1) return 0; |
|
| 51 |
int bits = 0; |
|
| 52 |
while (bits < 64 && (std::uint64_t(1) << bits) < m) ++bits; |
|
| 53 |
std::uint64_t v; |
|
| 54 |
do { v = gen() >> (64 - bits); } while (v >= m);
|
|
| 55 |
return static_cast<double>(v); |
|
| 56 |
} |
|
| 57 |
}; |
|
| 58 | ||
| 59 |
enum class RhoMethod { Standard, Fast };
|
|
| 60 | ||
| 61 |
// rhoR's rhoK() arguments after the observed kappa, base rate and test set |
|
| 62 |
// length, with the same defaults. |
|
| 63 |
struct RhoOptions {
|
|
| 64 |
double test_set_baserate_inflation = 0; |
|
| 65 |
int ocs_length = 10000; // OcSLength |
|
| 66 |
int replicates = 800; |
|
| 67 |
double scs_kappa_threshold = 0.9; // ScSKappaThreshold |
|
| 68 |
double scs_kappa_min = 0.40; // ScSKappaMin |
|
| 69 |
double scs_precision_min = 0.6; // ScSPrecisionMin |
|
| 70 |
double scs_precision_max = 1; // ScSPrecisionMax |
|
| 71 |
RhoMethod method = RhoMethod::Standard; |
|
| 72 |
}; |
|
| 73 | ||
| 74 |
struct KappaPrecision {
|
|
| 75 |
double kappa = 0, precision = 0; |
|
| 76 |
}; |
|
| 77 | ||
| 78 |
// Both rhoR paths recurse until a valid (kappa, precision) pair turns up and |
|
| 79 |
// give up with this message; here the retries are capped instead. |
|
| 80 |
inline constexpr int RHO_MAX_ATTEMPTS = 10000; |
|
| 81 |
inline const char* const RHO_NO_VALID_SET = |
|
| 82 |
"Could not generate a valid set given ranges of kappa and precision"; |
|
| 83 | ||
| 84 |
// ── Standard method: rhoR's R implementation ───────────────────────────────── |
|
| 85 | ||
| 86 |
namespace rho_standard {
|
|
| 87 | ||
| 88 |
// getR(): the recall that gives `kappa` at this base rate and precision. |
|
| 89 |
QESTATS_R_ARITHMETIC_FN |
|
| 90 | 3201x |
inline double recall(double kappa, double baserate, double precision) {
|
| 91 |
QESTATS_R_ARITHMETIC |
|
| 92 | 3201x |
const double top = kappa * precision; |
| 93 | 3201x |
return top / (2 * precision - 2 * baserate - kappa + 2 * baserate * kappa); |
| 94 |
} |
|
| 95 | ||
| 96 |
// checkBRPKcombo(): whether (base rate, precision, kappa) can form a set. |
|
| 97 |
QESTATS_R_ARITHMETIC_FN |
|
| 98 | 4000x |
inline bool valid_combo(double baserate, double precision, double kappa) {
|
| 99 |
QESTATS_R_ARITHMETIC |
|
| 100 | 4000x |
const double right = (2 * baserate * kappa - 2 * baserate - kappa) / (kappa - 2); |
| 101 | 4000x |
return precision > right; |
| 102 |
} |
|
| 103 | ||
| 104 |
// seq(from, to, length.out = n). |
|
| 105 |
QESTATS_R_ARITHMETIC_FN |
|
| 106 | 10x |
inline std::vector<double> seq(double from, double to, int n) {
|
| 107 |
QESTATS_R_ARITHMETIC |
|
| 108 |
if (n <= 0) return {};
|
|
| 109 | 10x |
if (n <= 2) {
|
| 110 | ! |
std::vector<double> v = {from, to};
|
| 111 | ! |
v.resize(n); |
| 112 | ! |
return v; |
| 113 |
} |
|
| 114 |
if (from == to) return std::vector<double>(n, from); |
|
| 115 | 10x |
const double by = (to - from) / (n - 1); |
| 116 | 10x |
std::vector<double> v(n); |
| 117 | 10x |
v[0] = from; |
| 118 | 549990x |
for (int i = 1; i < n - 1; ++i) v[i] = from + static_cast<double>(i) * by; |
| 119 | 10x |
v[n - 1] = to; |
| 120 | 10x |
return v; |
| 121 |
} |
|
| 122 | ||
| 123 |
// sample.int(n, k) without replacement, 0-based. |
|
| 124 |
template <class Rng> |
|
| 125 | 12477x |
std::vector<std::size_t> sample_int(std::size_t n, std::size_t k, Rng& rng) {
|
| 126 | 12477x |
if (k > n) |
| 127 | ! |
throw std::invalid_argument( |
| 128 |
"cannot take a sample larger than the population when 'replace = FALSE'"); |
|
| 129 | 12477x |
std::vector<std::size_t> out(k); |
| 130 | 12477x |
if (k < 2) {
|
| 131 | 16954x |
for (std::size_t i = 0; i < k; ++i) |
| 132 | 8477x |
out[i] = static_cast<std::size_t>(rng.unif_index(static_cast<double>(n))); |
| 133 | 8477x |
return out; |
| 134 |
} |
|
| 135 | 4000x |
std::vector<std::size_t> x(n); |
| 136 | 32794400x |
for (std::size_t i = 0; i < n; ++i) x[i] = i; |
| 137 | 244000x |
for (std::size_t i = 0; i < k; ++i) {
|
| 138 | 240000x |
const auto j = static_cast<std::size_t>(rng.unif_index(static_cast<double>(n))); |
| 139 | 240000x |
out[i] = x[j]; |
| 140 | 240000x |
x[j] = x[--n]; |
| 141 |
} |
|
| 142 | 4000x |
return out; |
| 143 |
} |
|
| 144 | ||
| 145 |
// genPKcombo(): one (kappa, precision) draw from the two grids. When the pair |
|
| 146 |
// is invalid, precision is redrawn from the grid values above the minimum |
|
| 147 |
// valid precision; when none are, kappa is redrawn too. |
|
| 148 |
template <class Rng> |
|
| 149 |
QESTATS_R_ARITHMETIC_FN |
|
| 150 | 4000x |
KappaPrecision kappa_precision(const std::vector<double>& kappas, |
| 151 |
const std::vector<double>& precisions, |
|
| 152 |
double baserate, Rng& rng) {
|
|
| 153 |
QESTATS_R_ARITHMETIC |
|
| 154 |
for (int attempt = 0; attempt < RHO_MAX_ATTEMPTS; ++attempt) {
|
|
| 155 | 4000x |
const double kappa = kappas[sample_int(kappas.size(), 1, rng)[0]]; |
| 156 | 4000x |
double precision = precisions[sample_int(precisions.size(), 1, rng)[0]]; |
| 157 | 4477x |
if (valid_combo(baserate, precision, kappa)) return {kappa, precision};
|
| 158 | 477x |
const double precision_min = |
| 159 | 477x |
(2 * baserate * kappa - 2 * baserate - kappa) / (kappa - 2); |
| 160 | 477x |
std::vector<double> above; |
| 161 | 9540954x |
for (double p : precisions) if (p > precision_min) above.push_back(p); |
| 162 |
if (above.empty()) continue; |
|
| 163 | 477x |
precision = above[sample_int(above.size(), 1, rng)[0]]; |
| 164 | 477x |
return {kappa, precision};
|
| 165 |
} |
|
| 166 | ! |
throw std::runtime_error(RHO_NO_VALID_SET); |
| 167 |
} |
|
| 168 | ||
| 169 |
// generateKPs() with the flat distribution: kappa from a 100,000-point grid, |
|
| 170 |
// precision from a 10,000-point grid. |
|
| 171 |
template <class Rng> |
|
| 172 |
std::vector<KappaPrecision> |
|
| 173 | 5x |
generate_kps(int n, double baserate, double kappa_min, double kappa_max, |
| 174 |
double precision_min, double precision_max, Rng& rng) {
|
|
| 175 | 5x |
const auto kappas = seq(kappa_min, kappa_max, 100000); |
| 176 | 5x |
const auto precisions = seq(precision_min, precision_max, 10000); |
| 177 | 5x |
std::vector<KappaPrecision> kps; |
| 178 | 5x |
kps.reserve(n > 0 ? n : 0); |
| 179 | 4005x |
for (int i = 0; i < n; ++i) kps.push_back(kappa_precision(kappas, precisions, baserate, rng)); |
| 180 | 5x |
return kps; |
| 181 |
} |
|
| 182 | ||
| 183 |
// prset() / pr.ct(): the full set's contingency table (counts). R's round() |
|
| 184 |
// rounds halves to even. |
|
| 185 |
QESTATS_R_ARITHMETIC_FN |
|
| 186 | 3201x |
inline ConfusionMatrix contingency_table(double precision, double recall, |
| 187 |
int length, double baserate) {
|
|
| 188 |
QESTATS_R_ARITHMETIC |
|
| 189 | 3201x |
const double gold1s = std::max(std::nearbyint(baserate * length), 1.0); |
| 190 | 3201x |
const double gold0s = length - gold1s; |
| 191 | 3201x |
const double tp = std::max(std::nearbyint(gold1s * recall), 1.0); |
| 192 | 3201x |
const double fp = std::min(gold0s, std::max(std::nearbyint(tp * (1 - precision) / precision), 1.0)); |
| 193 | 3201x |
return {tp, fp, gold1s - tp, gold0s - fp};
|
| 194 |
} |
|
| 195 | ||
| 196 |
// getHandSet(): kappa of a test set of `length` rows sampled from the full |
|
| 197 |
// set laid out as prset() does (TP rows, FN rows, FP rows, TN rows). With an |
|
| 198 |
// inflated base rate, ceiling(length * inflation) rows are first sampled from |
|
| 199 |
// the first rater's positives. |
|
| 200 |
template <class Rng> |
|
| 201 |
QESTATS_R_ARITHMETIC_FN |
|
| 202 | 3201x |
double hand_kappa(const ConfusionMatrix& full, int length, double inflation, Rng& rng) {
|
| 203 |
QESTATS_R_ARITHMETIC |
|
| 204 | 3201x |
const auto tp = static_cast<std::size_t>(full.tp), fn = static_cast<std::size_t>(full.fn); |
| 205 | 3201x |
const auto fp = static_cast<std::size_t>(full.fp), tn = static_cast<std::size_t>(full.tn); |
| 206 | 3201x |
const std::size_t gold1s = tp + fn, rows = gold1s + fp + tn; |
| 207 | 3201x |
double counts[4] = {0, 0, 0, 0}; // tp, fp, fn, tn
|
| 208 | 483201x |
auto add = [&](std::size_t row) {
|
| 209 | 240000x |
if (row < tp) counts[0] += 1; |
| 210 | 206958x |
else if (row < gold1s) counts[2] += 1; |
| 211 | 188108x |
else if (row < gold1s + fp) counts[1] += 1; |
| 212 | 181641x |
else counts[3] += 1; |
| 213 |
}; |
|
| 214 | 3201x |
const double positives = std::ceil(length * inflation); |
| 215 | 3201x |
if (positives > static_cast<double>(gold1s)) |
| 216 | 1x |
throw std::invalid_argument("Not enough positives in first rater to inflate to this level");
|
| 217 | 3200x |
if (positives > 0) {
|
| 218 | 800x |
const auto k = static_cast<std::size_t>(positives); |
| 219 | 800x |
std::vector<bool> taken(rows, false); |
| 220 | 20800x |
for (std::size_t row : sample_int(gold1s, k, rng)) { taken[row] = true; add(row); }
|
| 221 | 800x |
std::vector<std::size_t> others; |
| 222 | 800x |
others.reserve(rows - k); |
| 223 | 8000800x |
for (std::size_t row = 0; row < rows; ++row) if (!taken[row]) others.push_back(row); |
| 224 | 40000x |
for (std::size_t i : sample_int(others.size(), static_cast<std::size_t>(length) - k, rng)) |
| 225 | 38400x |
add(others[i]); |
| 226 |
} else {
|
|
| 227 | 388800x |
for (std::size_t row : sample_int(rows, static_cast<std::size_t>(length), rng)) add(row); |
| 228 |
} |
|
| 229 | 6400x |
return kappa_from_counts(counts[0], counts[1], counts[2], counts[3]); |
| 230 |
} |
|
| 231 | ||
| 232 |
// mean(x, na.rm = TRUE): R sums in long double and adds a correction pass. |
|
| 233 | 4x |
inline double r_mean(const std::vector<double>& x) {
|
| 234 | 4x |
long double s = 0; |
| 235 | 4x |
std::size_t n = 0; |
| 236 | 6408x |
for (double v : x) if (!std::isnan(v)) { s += v; ++n; }
|
| 237 | 4x |
s /= static_cast<long double>(n); |
| 238 | 4x |
if (std::isfinite(static_cast<double>(s))) {
|
| 239 | 4x |
long double t = 0; |
| 240 | 6408x |
for (double v : x) if (!std::isnan(v)) t += (v - s); |
| 241 | 4x |
s += t / static_cast<long double>(n); |
| 242 |
} |
|
| 243 | 4x |
return static_cast<double>(s); |
| 244 |
} |
|
| 245 | ||
| 246 |
// getBootPvalue(): 1 below the distribution's mean, else the share of the |
|
| 247 |
// distribution at or above `observed`. |
|
| 248 | 4x |
inline double boot_pvalue(const std::vector<double>& distribution, double observed) {
|
| 249 |
if (observed < r_mean(distribution)) return 1; |
|
| 250 | 4x |
std::size_t at_or_above = 0; |
| 251 | 6408x |
for (double v : distribution) if (v >= observed) ++at_or_above; |
| 252 | 4x |
return static_cast<double>(at_or_above) / static_cast<double>(distribution.size()); |
| 253 |
} |
|
| 254 | ||
| 255 |
// calcRho() with given (kappa, precision) pairs. |
|
| 256 |
template <class Rng> |
|
| 257 | 5x |
double rho(double observed_kappa, double baserate, int test_set_length, |
| 258 |
const std::vector<KappaPrecision>& kps, const RhoOptions& opts, Rng& rng) {
|
|
| 259 | 5x |
const std::size_t replicates = |
| 260 | 5x |
std::min(kps.size(), static_cast<std::size_t>(std::max(opts.replicates, 0))); |
| 261 | 5x |
std::vector<double> kappas(replicates, 0.0); |
| 262 | 3205x |
for (std::size_t i = 0; i < replicates; ++i) {
|
| 263 | 3201x |
const double r = recall(kps[i].kappa, baserate, kps[i].precision); |
| 264 | 3201x |
const auto full = contingency_table(kps[i].precision, r, opts.ocs_length, baserate); |
| 265 | 3201x |
kappas[i] = hand_kappa(full, test_set_length, opts.test_set_baserate_inflation, rng); |
| 266 |
} |
|
| 267 | 8x |
return boot_pvalue(kappas, observed_kappa); |
| 268 |
} |
|
| 269 | ||
| 270 |
} // namespace rho_standard |
|
| 271 | ||
| 272 |
// ── Fast method: rhoR's C++ implementation ─────────────────────────────────── |
|
| 273 |
// |
|
| 274 |
// Compiled as plain C++, like rhoR's src/rhor.cpp, so the compiler treats its |
|
| 275 |
// arithmetic the way it treated rhoR's. |
|
| 276 | ||
| 277 |
namespace rho_fast {
|
|
| 278 | ||
| 279 |
// recall(): the same formula as rho_standard::recall. |
|
| 280 | 3200x |
inline double recall(double kappa, double baserate, double precision) {
|
| 281 | 3200x |
const double top = kappa * precision; |
| 282 | 3200x |
return top / (2 * precision - 2 * baserate - kappa + 2 * baserate * kappa); |
| 283 |
} |
|
| 284 | ||
| 285 |
// check_BRK_combo(). |
|
| 286 | 3200x |
inline bool valid_combo(double baserate, double precision, double kappa) {
|
| 287 | 3200x |
const double right = ((2 * baserate * kappa) - (2 * baserate) - kappa) / (kappa - 2); |
| 288 | 3200x |
return precision > right; |
| 289 |
} |
|
| 290 | ||
| 291 |
// Armadillo's regspace(start, delta, end). |
|
| 292 | 8x |
inline std::vector<double> regspace(double start, double delta, double end) {
|
| 293 |
if (((start < end) && (delta < 0)) || ((start > end) && (delta > 0)) || (delta == 0)) return {};
|
|
| 294 | 8x |
const bool ascend = (start <= end); |
| 295 |
const double inc = (delta < 0) ? -delta : delta; |
|
| 296 |
const double m = (ascend ? (end - start) : (start - end)) / inc; |
|
| 297 | 8x |
const auto n = static_cast<std::size_t>(1 + static_cast<std::size_t>(std::floor(m))); |
| 298 |
// Two loops, as Armadillo writes them: a compiler that fuses |
|
| 299 |
// start + i * inc into an FMA then does it the same way here. |
|
| 300 | 8x |
std::vector<double> v(n); |
| 301 | 8x |
double* mem = v.data(); |
| 302 | 8x |
if (ascend) {
|
| 303 | 80008x |
for (std::size_t i = 0; i < n; ++i) { mem[i] = double(start + double(i * inc)); }
|
| 304 |
} else {
|
|
| 305 | ! |
for (std::size_t i = 0; i < n; ++i) { mem[i] = double(start - double(i * inc)); }
|
| 306 |
} |
|
| 307 | 8x |
return v; |
| 308 |
} |
|
| 309 | ||
| 310 |
// One index from RcppArmadillo's sample(x, 1, FALSE): int(n * unif_rand()). |
|
| 311 |
template <class Rng> |
|
| 312 | 6829x |
std::size_t sample_one(std::size_t n, Rng& rng) {
|
| 313 | 6829x |
const int n_orig = static_cast<int>(n); |
| 314 | 6829x |
const int j = static_cast<int>(n_orig * rng.unif_rand()); |
| 315 | 6829x |
return static_cast<std::size_t>(j); |
| 316 |
} |
|
| 317 | ||
| 318 |
// Armadillo's randi<int>(distr_param(a, b)) on R's RNG (RcppArmadillo's |
|
| 319 |
// Alt_R_RNG.h): runif(0, RAND_MAX) scaled to b - a + 1 values. |
|
| 320 |
template <class Rng> |
|
| 321 | 240000x |
int randi(int a, int b, Rng& rng) {
|
| 322 | 240000x |
if (!(a <= b)) |
| 323 | ! |
throw std::logic_error("randi(): incorrect distribution parameters; a must be less than b");
|
| 324 | 240000x |
const double rand_max = static_cast<double>(RAND_MAX); |
| 325 | 240000x |
const double draw = 0.0 + (rand_max - 0.0) * rng.unif_rand(); |
| 326 |
if (a == 0 && b == RAND_MAX) return static_cast<int>(draw); |
|
| 327 | 240000x |
const auto length = static_cast<unsigned long long>(b - a + 1); |
| 328 | 240000x |
const double scale = double(length) / rand_max; |
| 329 | 240000x |
return (std::min)(b, int(draw * scale) + a); |
| 330 |
} |
|
| 331 | ||
| 332 |
// find_valid_pk(). |
|
| 333 |
template <class Rng> |
|
| 334 | 3200x |
KappaPrecision kappa_precision(const std::vector<double>& kappas, |
| 335 |
const std::vector<double>& precisions, |
|
| 336 |
double baserate, Rng& rng) {
|
|
| 337 |
for (int attempt = 0; attempt < RHO_MAX_ATTEMPTS; ++attempt) {
|
|
| 338 | 3200x |
const double kappa = kappas[sample_one(kappas.size(), rng)]; |
| 339 | 3200x |
double precision = precisions[sample_one(precisions.size(), rng)]; |
| 340 | 3200x |
if (!valid_combo(baserate, precision, kappa)) {
|
| 341 | 429x |
const double precision_min = |
| 342 | 429x |
(2 * baserate * kappa - 2 * baserate - kappa) / (kappa - 2); |
| 343 | 429x |
std::vector<double> above; |
| 344 | 8580858x |
for (double p : precisions) if (p > precision_min) above.push_back(p); |
| 345 |
if (above.empty()) continue; |
|
| 346 | 429x |
precision = above[sample_one(above.size(), rng)]; |
| 347 |
} |
|
| 348 | 3200x |
return {kappa, precision};
|
| 349 |
} |
|
| 350 | ! |
throw std::runtime_error(RHO_NO_VALID_SET); |
| 351 |
} |
|
| 352 | ||
| 353 |
// generate_kp_list() with the flat distribution (distributionType 0); rhoR's |
|
| 354 |
// bell-shaped option (distributionType 1) is not ported. |
|
| 355 |
template <class Rng> |
|
| 356 |
std::vector<KappaPrecision> |
|
| 357 | 4x |
generate_kps(int n, double baserate, double kappa_min, double kappa_max, |
| 358 |
double precision_min, double precision_max, Rng& rng, |
|
| 359 |
long distribution_length = 10000) {
|
|
| 360 | 4x |
const double kappa_step = (kappa_max - kappa_min) / (distribution_length - 1); |
| 361 | 4x |
const auto kappas = regspace(kappa_min, kappa_step, kappa_max); |
| 362 | 4x |
const double precision_step = (precision_max - precision_min) / (10000 - 1); |
| 363 | 4x |
const auto precisions = regspace(precision_min, precision_step, precision_max); |
| 364 |
if (kappas.empty() || precisions.empty()) |
|
| 365 | ! |
throw std::invalid_argument("generate_kps: empty kappa or precision range");
|
| 366 | 4x |
std::vector<KappaPrecision> kps; |
| 367 | 4x |
kps.reserve(n > 0 ? n : 0); |
| 368 | 3204x |
for (int i = 0; i < n; ++i) kps.push_back(kappa_precision(kappas, precisions, baserate, rng)); |
| 369 | 4x |
return kps; |
| 370 |
} |
|
| 371 | ||
| 372 |
// contingency_table(): the full set's table (counts). C's round() rounds |
|
| 373 |
// halves away from zero. |
|
| 374 | 3200x |
inline ConfusionMatrix contingency_table(double precision, double recall, |
| 375 |
int length, double baserate) {
|
|
| 376 | 3200x |
const int gold1s = static_cast<int>(std::max(std::round(baserate * length), 1.0)); |
| 377 | 3200x |
const int gold0s = length - gold1s; |
| 378 | 3200x |
const int tp = static_cast<int>(std::max(std::round(gold1s * recall), 1.0)); |
| 379 | 3200x |
const int fp = static_cast<int>(std::min(static_cast<double>(gold0s), |
| 380 | 3200x |
std::max(std::round(tp * (1 - precision) / precision), 1.0))); |
| 381 | 3200x |
return {double(tp), double(fp), double(gold1s - tp), double(gold0s - fp)};
|
| 382 |
} |
|
| 383 | ||
| 384 |
// sample_contingency_table(): draw n items without replacement from cells |
|
| 385 |
// (a 1x2 row or a 2x2 table in column order: TP, FP, FN, TN), returning each |
|
| 386 |
// draw's 0-based cell. Kept as rhoR has it: the draw spans sum + 1 values and |
|
| 387 |
// the first cell takes the extra one, so an empty first cell can be drawn. |
|
| 388 |
// The draw spans every cell given; draws past the third cell land in the |
|
| 389 |
// fourth. |
|
| 390 |
template <class Rng> |
|
| 391 | 4000x |
std::vector<int> sample_contingency_table(std::vector<int> cells, int n, Rng& rng) {
|
| 392 | 4000x |
if (cells.size() < 4) cells.resize(4, 0); |
| 393 | 4000x |
std::vector<int> out(n > 0 ? n : 0); |
| 394 | 244000x |
for (int w = 0; w < n; ++w) {
|
| 395 | 240000x |
int max_int = 0; |
| 396 | 2400000x |
for (int c : cells) max_int += c; |
| 397 | 240000x |
const int s = randi(0, max_int, rng); |
| 398 | 446640x |
const int cell = (s <= cells[0]) ? 0 |
| 399 | 206640x |
: (s <= cells[0] + cells[1]) ? 1 |
| 400 | 196492x |
: (s <= cells[0] + cells[1] + cells[2]) ? 2 : 3; |
| 401 | 240000x |
out[w] = cell; |
| 402 | 240000x |
cells[cell]--; |
| 403 |
} |
|
| 404 | 4000x |
return out; |
| 405 |
} |
|
| 406 | ||
| 407 |
// getHand_ct(): the test set's table, drawn from the full set's table. |
|
| 408 |
template <class Rng> |
|
| 409 | 3200x |
ConfusionMatrix hand_contingency_table(const ConfusionMatrix& full, int length, |
| 410 |
double inflation, Rng& rng) {
|
|
| 411 | 3200x |
const int positives = static_cast<int>(std::ceil(length * inflation)); |
| 412 | 3200x |
int tp = static_cast<int>(full.tp), fp = static_cast<int>(full.fp); |
| 413 | 3200x |
int fn = static_cast<int>(full.fn), tn = static_cast<int>(full.tn); |
| 414 | 3200x |
int counts[4] = {0, 0, 0, 0}; // column order: tp, fp, fn, tn
|
| 415 | 3200x |
if (positives > 0) {
|
| 416 | 800x |
int pos_tp = 0, pos_fn = 0; |
| 417 | 12800x |
for (int cell : sample_contingency_table({tp, fn}, positives, rng))
|
| 418 | 9600x |
(cell == 0 ? pos_tp : pos_fn)++; |
| 419 | 800x |
tp -= pos_tp; |
| 420 | 800x |
fn -= pos_fn; |
| 421 | 800x |
counts[0] += pos_tp; |
| 422 | 800x |
counts[2] += pos_fn; |
| 423 |
} |
|
| 424 | 243200x |
for (int cell : sample_contingency_table({tp, fp, fn, tn}, length - positives, rng))
|
| 425 | 230400x |
counts[cell]++; |
| 426 | 3200x |
return {double(counts[0]), double(counts[1]), double(counts[2]), double(counts[3])};
|
| 427 |
} |
|
| 428 | ||
| 429 |
// kappa_ct(): kappa as rhoR's C++ computes it. Within an ulp of |
|
| 430 |
// kappa_from_counts, but NaN (not 1) when one cell holds every item. |
|
| 431 | 3200x |
inline double kappa_ct(const ConfusionMatrix& ct) {
|
| 432 | 3200x |
const double a = ct.tp, c = ct.fn, b = ct.fp, d = ct.tn; |
| 433 | 3200x |
const double size = a + b + c + d; |
| 434 | 3200x |
const double p_zero = (a + d) / size; |
| 435 | 3200x |
const double p_yes = ((a + b) / size) * ((a + c) / size); |
| 436 | 3200x |
const double p_no = ((c + d) / size) * ((b + d) / size); |
| 437 | 3200x |
const double p_e = (p_yes + p_no); |
| 438 | 3200x |
return (p_zero - p_e) / (1 - p_e); |
| 439 |
} |
|
| 440 | ||
| 441 |
// Armadillo's mean(): two interleaved accumulators. |
|
| 442 | 4x |
inline double arma_mean(const std::vector<double>& x) {
|
| 443 | 4x |
const std::size_t n = x.size(); |
| 444 |
if (n == 0) throw std::logic_error("mean(): object has no elements");
|
|
| 445 | 4x |
double acc1 = 0, acc2 = 0; |
| 446 |
std::size_t j; |
|
| 447 | 1604x |
for (j = 1; j < n; j += 2) { acc1 += x[j - 1]; acc2 += x[j]; }
|
| 448 |
if ((j - 1) < n) acc1 += x[j - 1]; |
|
| 449 | 4x |
const double mean = (acc1 + acc2) / double(n); |
| 450 | 4x |
if (std::isfinite(mean)) return mean; |
| 451 | ! |
for (double v : x) if (!std::isfinite(v)) return mean; |
| 452 | ! |
double r_mean = 0; |
| 453 | ! |
for (std::size_t i = 0; i < n; ++i) r_mean = r_mean + (x[i] - r_mean) / double(i + 1); |
| 454 | ! |
return r_mean; |
| 455 |
} |
|
| 456 | ||
| 457 |
// getBootPvalue_c(). |
|
| 458 | 4x |
inline double boot_pvalue(const std::vector<double>& distribution, double observed) {
|
| 459 |
if (observed < arma_mean(distribution)) return 1.0; |
|
| 460 | 4x |
std::size_t at_or_above = 0; |
| 461 | 6408x |
for (double v : distribution) if (v >= observed) ++at_or_above; |
| 462 | 4x |
return (at_or_above * 1.0) / distribution.size(); |
| 463 |
} |
|
| 464 | ||
| 465 |
// calcRho_c() with given (kappa, precision) pairs. |
|
| 466 |
template <class Rng> |
|
| 467 | 4x |
double rho(double observed_kappa, double baserate, int test_set_length, |
| 468 |
const std::vector<KappaPrecision>& kps, const RhoOptions& opts, Rng& rng) {
|
|
| 469 | 4x |
const std::size_t replicates = |
| 470 | 4x |
std::min(kps.size(), static_cast<std::size_t>(std::max(opts.replicates, 0))); |
| 471 | 4x |
std::vector<double> kappas(replicates); |
| 472 | 3204x |
for (std::size_t i = 0; i < replicates; ++i) {
|
| 473 | 3200x |
const double r = recall(kps[i].kappa, baserate, kps[i].precision); |
| 474 | 3200x |
const auto full = contingency_table(kps[i].precision, r, opts.ocs_length, baserate); |
| 475 | 3200x |
kappas[i] = kappa_ct(hand_contingency_table(full, test_set_length, |
| 476 | 3200x |
opts.test_set_baserate_inflation, rng)); |
| 477 |
} |
|
| 478 | 8x |
return boot_pvalue(kappas, observed_kappa); |
| 479 |
} |
|
| 480 | ||
| 481 |
} // namespace rho_fast |
|
| 482 | ||
| 483 |
// ── rho ────────────────────────────────────────────────────────────────────── |
|
| 484 | ||
| 485 |
// rhoK()'s argument checks, with its messages. |
|
| 486 | 11x |
inline void check_rho_args(double observed_kappa, double baserate, const RhoOptions& o) {
|
| 487 | 11x |
if ((observed_kappa > 1 || observed_kappa < 0) || (baserate > 1 || baserate < 0)) |
| 488 | 1x |
throw std::invalid_argument("x and OcSBaserate must be between 0 and 1.");
|
| 489 | 10x |
if (o.scs_kappa_threshold < 0 || o.scs_kappa_min < 0) |
| 490 | ! |
throw std::invalid_argument("OcSBaserate, ScSKappaThreshold, and ScSKappaMin must be positive.");
|
| 491 | 10x |
if (o.scs_kappa_threshold < o.scs_kappa_min) |
| 492 | 1x |
throw std::invalid_argument("ScSKappaThreshold must be greater than ScSKappaMin.");
|
| 493 | 9x |
if ((o.scs_precision_min < 0 || o.scs_precision_min > 1) || |
| 494 | 9x |
(o.scs_precision_max < 0 || o.scs_precision_max > 1)) |
| 495 | ! |
throw std::invalid_argument("ScSPrecisionMin and ScSPrecisionMax must be between 0 and 1.");
|
| 496 | 9x |
if (o.scs_precision_max < o.scs_precision_min) |
| 497 | ! |
throw std::invalid_argument("ScSPrecisionMax must be greater than ScSPrecisionMin.");
|
| 498 |
} |
|
| 499 | ||
| 500 |
// The (kappa, precision) pairs rho() simulates: opts.replicates draws with |
|
| 501 |
// kappa in [scs_kappa_min, scs_kappa_threshold] and precision in |
|
| 502 |
// [scs_precision_min, scs_precision_max]. |
|
| 503 |
template <class Rng> |
|
| 504 | 9x |
std::vector<KappaPrecision> rho_kps(double baserate, const RhoOptions& opts, Rng& rng) {
|
| 505 | 9x |
if (opts.method == RhoMethod::Fast) |
| 506 | 4x |
return rho_fast::generate_kps(opts.replicates, baserate, opts.scs_kappa_min, |
| 507 | 4x |
opts.scs_kappa_threshold, opts.scs_precision_min, |
| 508 | 4x |
opts.scs_precision_max, rng); |
| 509 | 5x |
return rho_standard::generate_kps(opts.replicates, baserate, opts.scs_kappa_min, |
| 510 | 5x |
opts.scs_kappa_threshold, opts.scs_precision_min, |
| 511 | 5x |
opts.scs_precision_max, rng); |
| 512 |
} |
|
| 513 | ||
| 514 |
// rhoK(): rho for an observed kappa on a test set of `test_set_length` items |
|
| 515 |
// from a population with this base rate. Only a class type is taken as an Rng, |
|
| 516 |
// so an integer seed of any type picks the overload below. |
|
| 517 |
template <class Rng, std::enable_if_t<std::is_class_v<Rng>, int> = 0> |
|
| 518 | 11x |
double rho(double observed_kappa, double baserate, int test_set_length, |
| 519 |
const RhoOptions& opts, Rng& rng) {
|
|
| 520 | 11x |
check_rho_args(observed_kappa, baserate, opts); |
| 521 | 9x |
const auto kps = rho_kps(baserate, opts, rng); |
| 522 | 9x |
if (opts.method == RhoMethod::Fast) |
| 523 | 4x |
return rho_fast::rho(observed_kappa, baserate, test_set_length, kps, opts, rng); |
| 524 | 5x |
return rho_standard::rho(observed_kappa, baserate, test_set_length, kps, opts, rng); |
| 525 |
} |
|
| 526 | ||
| 527 |
inline double rho(double observed_kappa, double baserate, int test_set_length, |
|
| 528 |
const RhoOptions& opts = {}, std::uint64_t seed = 5489u) {
|
|
| 529 |
StdRng rng(seed); |
|
| 530 |
return rho(observed_kappa, baserate, test_set_length, opts, rng); |
|
| 531 |
} |
|
| 532 | ||
| 533 |
} // namespace qestats |
| 1 |
// [[Rcpp::depends(Rcpp)]] |
|
| 2 |
// [[Rcpp::plugins(cpp17)]] |
|
| 3 |
#include <Rcpp.h> |
|
| 4 |
#include "qestats/qestats.hpp" |
|
| 5 | ||
| 6 |
// The R binding mirrors qestats function signatures closely. |
|
| 7 |
// Complex inputs/outputs use named R lists; JSON is NOT used here |
|
| 8 |
// (unlike the WASM boundary) because Rcpp's native list/vector types |
|
| 9 |
// are more idiomatic for R callers. |
|
| 10 | ||
| 11 |
using namespace Rcpp; |
|
| 12 | ||
| 13 |
// ── Helper: Metrics → R named list ─────────────────────────────────────────── |
|
| 14 | ||
| 15 | 3x |
static List metrics_to_rlist(const qestats::Metrics& m) {
|
| 16 |
return List::create( |
|
| 17 | 9x |
Named("tp") = m.tp,
|
| 18 | 9x |
Named("fp") = m.fp,
|
| 19 | 9x |
Named("fn") = m.fn,
|
| 20 | 9x |
Named("tn") = m.tn,
|
| 21 | 9x |
Named("baserate") = m.b1,
|
| 22 | 9x |
Named("baserate2") = m.b2,
|
| 23 | 9x |
Named("precision") = m.precision,
|
| 24 | 9x |
Named("recall") = m.recall,
|
| 25 | 9x |
Named("accuracy") = m.accuracy,
|
| 26 | 9x |
Named("FDR") = m.fdr,
|
| 27 | 9x |
Named("FOR") = m.for_,
|
| 28 | 12x |
Named("kappa") = m.kappa
|
| 29 |
); |
|
| 30 |
} |
|
| 31 | ||
| 32 | ! |
static List ps_to_rlist(const qestats::PsResult& r) {
|
| 33 | ! |
return List::create(Named("n") = r.n,
|
| 34 | ! |
Named("power") = r.power,
|
| 35 | ! |
Named("cost") = r.cost);
|
| 36 |
} |
|
| 37 | ||
| 38 |
// ── calculate_metrics ───────────────────────────────────────────────────────── |
|
| 39 |
//' Calculate classification metrics from a confusion matrix |
|
| 40 |
//' @param tp True positives |
|
| 41 |
//' @param fp False positives |
|
| 42 |
//' @param fn False negatives |
|
| 43 |
//' @param tn True negatives |
|
| 44 |
//' @return Named list of all classification metrics |
|
| 45 |
//' @export |
|
| 46 |
// [[Rcpp::export]] |
|
| 47 | 3x |
List qestats_calculate_metrics(double tp, double fp, double fn, double tn) {
|
| 48 | 3x |
return metrics_to_rlist(qestats::calculate_metrics(tp, fp, fn, tn)); |
| 49 |
} |
|
| 50 | ||
| 51 |
// ── derive_from_rates ───────────────────────────────────────────────────────── |
|
| 52 |
//' Derive all metrics from baserate + FDR + FOR (closed-form, fast) |
|
| 53 |
//' @param b1 Classifier positive rate (baserate) |
|
| 54 |
//' @param fdr False Discovery Rate |
|
| 55 |
//' @param for_rate False Omission Rate |
|
| 56 |
//' @return Named list of all classification metrics |
|
| 57 |
//' @export |
|
| 58 |
// [[Rcpp::export]] |
|
| 59 | ! |
List qestats_derive_from_rates(double b1, double fdr, double for_rate) {
|
| 60 | ! |
return metrics_to_rlist(qestats::derive_from_rates(b1, fdr, for_rate)); |
| 61 |
} |
|
| 62 | ||
| 63 |
// ── derive ──────────────────────────────────────────────────────────────────── |
|
| 64 |
//' Derive all metrics from three independent known metrics (general) |
|
| 65 |
//' @param name1,val1 First metric name and value |
|
| 66 |
//' @param name2,val2 Second metric name and value |
|
| 67 |
//' @param name3,val3 Third metric name and value |
|
| 68 |
//' @details Accepted names: "baserate","baserate2","b1","b2","precision", |
|
| 69 |
//' "recall","accuracy","FDR","FOR","kappa" |
|
| 70 |
//' @return Named list of all classification metrics |
|
| 71 |
//' @export |
|
| 72 |
// [[Rcpp::export]] |
|
| 73 | ! |
List qestats_derive(const std::string& name1, double val1, |
| 74 |
const std::string& name2, double val2, |
|
| 75 |
const std::string& name3, double val3) {
|
|
| 76 | ! |
return metrics_to_rlist(qestats::derive(name1, val1, name2, val2, name3, val3)); |
| 77 |
} |
|
| 78 | ||
| 79 |
// ── range ───────────────────────────────────────────────────────────────────── |
|
| 80 |
//' Compute the [min, max] range of a third metric given two known metrics |
|
| 81 |
//' @param name1,val1 First metric name and value |
|
| 82 |
//' @param name2,val2 Second metric name and value |
|
| 83 |
//' @param target Name of the metric whose range to compute |
|
| 84 |
//' @return Named list with \code{min} and \code{max}
|
|
| 85 |
//' @export |
|
| 86 |
// [[Rcpp::export]] |
|
| 87 | ! |
List qestats_range(const std::string& name1, double val1, |
| 88 |
const std::string& name2, double val2, |
|
| 89 |
const std::string& target) {
|
|
| 90 | ! |
auto r = qestats::range(name1, val1, name2, val2, target); |
| 91 | ! |
return List::create(Named("min") = r.min, Named("max") = r.max);
|
| 92 |
} |
|
| 93 | ||
| 94 |
// ── phyper ──────────────────────────────────────────────────────────────────── |
|
| 95 |
//' Hypergeometric CDF P(X <= q) where X ~ Hypergeometric(N, K, n) |
|
| 96 |
//' @param q Upper bound |
|
| 97 |
//' @param K Successes in population |
|
| 98 |
//' @param N_minus_K Failures in population |
|
| 99 |
//' @param n Sample size |
|
| 100 |
//' @return Cumulative probability |
|
| 101 |
//' @export |
|
| 102 |
// [[Rcpp::export]] |
|
| 103 | ! |
double qestats_phyper(int q, int K, int N_minus_K, int n) {
|
| 104 | ! |
return qestats::phyper(q, K, N_minus_K, n); |
| 105 |
} |
|
| 106 | ||
| 107 |
// ── hyper_range ─────────────────────────────────────────────────────────────── |
|
| 108 |
//' Confidence interval for a proportion via hypergeometric distribution |
|
| 109 |
//' @param n_pos Positives in sample |
|
| 110 |
//' @param n Sample size |
|
| 111 |
//' @param N Population size |
|
| 112 |
//' @param alpha Significance level (default 0.05) |
|
| 113 |
//' @return Named list with \code{lower}, \code{mean}, \code{upper}
|
|
| 114 |
//' @export |
|
| 115 |
// [[Rcpp::export]] |
|
| 116 | ! |
List qestats_hyper_range(int n_pos, int n, int N, double alpha = 0.05) {
|
| 117 | ! |
auto iv = qestats::hyper_range(n_pos, n, N, alpha); |
| 118 | ! |
return List::create(Named("lower") = iv.lower,
|
| 119 | ! |
Named("mean") = iv.mean,
|
| 120 | ! |
Named("upper") = iv.upper);
|
| 121 |
} |
|
| 122 | ||
| 123 |
// ── ps_proportion ───────────────────────────────────────────────────────────── |
|
| 124 |
//' Perfect sample size for a proportion test |
|
| 125 |
//' @param x0 Null hypothesis proportion (must be in [0.5, 1)) |
|
| 126 |
//' @param x1 Alternative proportion for power calculation (0 = auto) |
|
| 127 |
//' @param N Population size (0 = infinite) |
|
| 128 |
//' @param allowed_errors Maximum allowable errors |
|
| 129 |
//' @param alpha Significance level (0.001–0.1) |
|
| 130 |
//' @return Named list with \code{n}, \code{power}, \code{cost}
|
|
| 131 |
//' @export |
|
| 132 |
// [[Rcpp::export]] |
|
| 133 | ! |
List qestats_ps_proportion(double x0, double x1 = 0, int N = 0, |
| 134 |
int allowed_errors = 0, double alpha = 0.05) {
|
|
| 135 | ! |
return ps_to_rlist(qestats::ps_proportion(x0, x1, N, allowed_errors, alpha)); |
| 136 |
} |
|
| 137 | ||
| 138 |
// ── ps_kappa ────────────────────────────────────────────────────────────────── |
|
| 139 |
//' Perfect sample size for a kappa test |
|
| 140 |
//' @param kappa0 Target kappa value (0, 1) |
|
| 141 |
//' @param baserate Base rate (proportion of positives) |
|
| 142 |
//' @param kappa1 Alternative kappa for power calculation (0 = auto) |
|
| 143 |
//' @param N Population size (0 = infinite) |
|
| 144 |
//' @param allowed_errors Maximum allowable errors |
|
| 145 |
//' @param alpha Significance level (0.001–0.1) |
|
| 146 |
//' @return Named list with \code{n}, \code{power}, \code{cost}
|
|
| 147 |
//' @export |
|
| 148 |
// [[Rcpp::export]] |
|
| 149 | ! |
List qestats_ps_kappa(double kappa0, double baserate, double kappa1 = 0, |
| 150 |
int N = 0, int allowed_errors = 0, double alpha = 0.05) {
|
|
| 151 | ! |
return ps_to_rlist(qestats::ps_kappa(kappa0, baserate, kappa1, N, allowed_errors, alpha)); |
| 152 |
} |
|
| 153 | ||
| 154 |
// ── ps_est_proportion ───────────────────────────────────────────────────────── |
|
| 155 |
//' Estimate minimum proportion from a given perfect sample size |
|
| 156 |
//' @param n Perfect sample size |
|
| 157 |
//' @param N Population size (0 = infinite) |
|
| 158 |
//' @param allowed_errors Maximum allowable errors |
|
| 159 |
//' @param alpha Significance level (0.001–0.1) |
|
| 160 |
//' @return Lower bound for the proportion |
|
| 161 |
//' @export |
|
| 162 |
// [[Rcpp::export]] |
|
| 163 | ! |
double qestats_ps_est_proportion(int n, int N = 0, |
| 164 |
int allowed_errors = 0, double alpha = 0.05) {
|
|
| 165 | ! |
return qestats::ps_est_proportion(n, N, allowed_errors, alpha); |
| 166 |
} |
|
| 167 | ||
| 168 |
//' Estimate minimum kappa from a given perfect sample size |
|
| 169 |
//' @param n Perfect sample size |
|
| 170 |
//' @param baserate Classifier base rate |
|
| 171 |
//' @param N Population size (0 = infinite) |
|
| 172 |
//' @param allowed_errors Maximum allowable errors |
|
| 173 |
//' @param alpha Significance level (0.001–0.1) |
|
| 174 |
//' @return Lower bound for kappa |
|
| 175 |
//' @export |
|
| 176 |
// [[Rcpp::export]] |
|
| 177 | ! |
double qestats_ps_est_kappa(int n, double baserate, int N = 0, |
| 178 |
int allowed_errors = 0, double alpha = 0.05) {
|
|
| 179 | ! |
return qestats::ps_est_kappa(n, baserate, N, allowed_errors, alpha); |
| 180 |
} |
|
| 181 | ||
| 182 |
// ── metrics_range ───────────────────────────────────────────────────────────── |
|
| 183 |
//' Estimate confidence intervals for classification metrics |
|
| 184 |
//' @param tp,fp,fn,tn Confusion matrix counts |
|
| 185 |
//' @param N Population size |
|
| 186 |
//' @param baserate Proportion of positives in population |
|
| 187 |
//' @param alpha Significance level (default 0.05) |
|
| 188 |
//' @return Data frame with columns: metric, lower, mean, upper |
|
| 189 |
//' @export |
|
| 190 |
// [[Rcpp::export]] |
|
| 191 | ! |
DataFrame qestats_metrics_range(double tp, double fp, double fn, double tn, |
| 192 |
int N, double baserate, double alpha = 0.05) {
|
|
| 193 | ! |
auto rows = qestats::metrics_range(tp, fp, fn, tn, N, baserate, alpha); |
| 194 | ! |
std::vector<std::string> metric; |
| 195 | ! |
std::vector<double> lower, mean_v, upper; |
| 196 | ! |
for (const auto& r : rows) {
|
| 197 | ! |
metric.push_back(r.metric); |
| 198 | ! |
lower.push_back(r.lower); |
| 199 | ! |
mean_v.push_back(r.mean); |
| 200 | ! |
upper.push_back(r.upper); |
| 201 |
} |
|
| 202 | ! |
return DataFrame::create(Named("metric") = metric,
|
| 203 | ! |
Named("lower") = lower,
|
| 204 | ! |
Named("mean") = mean_v,
|
| 205 | ! |
Named("upper") = upper);
|
| 206 |
} |
|
| 207 | ||
| 208 |
// ── bootstrap_metrics ───────────────────────────────────────────────────────── |
|
| 209 |
//' Bootstrap confidence intervals for classification metrics |
|
| 210 |
//' @param cm Named list with TP, FP, FN, TN counts |
|
| 211 |
//' @param n_sample Resample size |
|
| 212 |
//' @param n_set Population size (0 = infinite) |
|
| 213 |
//' @param n_bootstrap Number of bootstrap replicates |
|
| 214 |
//' @param conf_level Confidence level (default 0.95) |
|
| 215 |
//' @return List with \code{original} (metrics list) and \code{stats} (data frame)
|
|
| 216 |
//' @export |
|
| 217 |
// [[Rcpp::export]] |
|
| 218 | ! |
List qestats_bootstrap_metrics(List cm, int n_sample, int n_set = 0, |
| 219 |
int n_bootstrap = 1000, double conf_level = 0.95) {
|
|
| 220 |
qestats::ConfusionMatrix c{
|
|
| 221 | ! |
as<double>(cm["TP"]), as<double>(cm["FP"]), |
| 222 | ! |
as<double>(cm["FN"]), as<double>(cm["TN"]) |
| 223 |
}; |
|
| 224 | ! |
auto r = qestats::bootstrap_metrics(c, n_sample, {}, n_set, n_bootstrap, conf_level);
|
| 225 | ||
| 226 | ! |
std::vector<std::string> names; |
| 227 | ! |
std::vector<double> se, ci_lo, ci_hi; |
| 228 | ! |
for (const auto& s : r.stats) {
|
| 229 | ! |
names.push_back(s.name); |
| 230 | ! |
se.push_back(s.se); |
| 231 | ! |
ci_lo.push_back(s.ci_lo); |
| 232 | ! |
ci_hi.push_back(s.ci_hi); |
| 233 |
} |
|
| 234 |
return List::create( |
|
| 235 | ! |
Named("original") = metrics_to_rlist(r.original),
|
| 236 | ! |
Named("stats") = DataFrame::create(
|
| 237 | ! |
Named("metric") = names,
|
| 238 | ! |
Named("se") = se,
|
| 239 | ! |
Named("ci_lo") = ci_lo,
|
| 240 | ! |
Named("ci_hi") = ci_hi)
|
| 241 |
); |
|
| 242 |
} |
|
| 243 | ||
| 244 |
// ── p_diff ──────────────────────────────────────────────────────────────────── |
|
| 245 |
//' P-value for metric difference between two groups (bootstrap Welch t-test) |
|
| 246 |
//' @param cm1,cm2 Named lists with TP, FP, FN, TN for each group |
|
| 247 |
//' @param n_sample Sample size (0 = sum of both groups) |
|
| 248 |
//' @param metric Metric name to compare |
|
| 249 |
//' @param n_set Population size (0 = infinite) |
|
| 250 |
//' @param n_bootstrap Number of bootstrap replicates |
|
| 251 |
//' @param alpha Significance level |
|
| 252 |
//' @return p-value (double) |
|
| 253 |
//' @export |
|
| 254 |
// [[Rcpp::export]] |
|
| 255 | ! |
double qestats_p_diff(List cm1, List cm2, int n_sample, |
| 256 |
const std::string& metric, int n_set = 0, |
|
| 257 |
int n_bootstrap = 1000, double alpha = 0.05) {
|
|
| 258 |
qestats::ConfusionMatrix c1{
|
|
| 259 | ! |
as<double>(cm1["TP"]), as<double>(cm1["FP"]), |
| 260 | ! |
as<double>(cm1["FN"]), as<double>(cm1["TN"]) |
| 261 |
}; |
|
| 262 |
qestats::ConfusionMatrix c2{
|
|
| 263 | ! |
as<double>(cm2["TP"]), as<double>(cm2["FP"]), |
| 264 | ! |
as<double>(cm2["FN"]), as<double>(cm2["TN"]) |
| 265 |
}; |
|
| 266 | ! |
return qestats::p_diff(c1, c2, n_sample, metric, n_set, n_bootstrap, alpha); |
| 267 |
} |
|
| 268 | ||
| 269 |
// ── N_Diff2 ─────────────────────────────────────────────────────────────────── |
|
| 270 |
//' Minimum n at which two groups' metric CIs no longer overlap |
|
| 271 |
//' @param cm1,cm2 Named lists with TP, FP, FN, TN |
|
| 272 |
//' @param metric Metric name |
|
| 273 |
//' @param N Population size |
|
| 274 |
//' @param d Overlap threshold (default 0.1) |
|
| 275 |
//' @param alpha Significance level (default 0.05) |
|
| 276 |
//' @return Required sample size (integer) |
|
| 277 |
//' @export |
|
| 278 |
// [[Rcpp::export]] |
|
| 279 | ! |
int qestats_N_Diff2(List cm1, List cm2, const std::string& metric, |
| 280 |
int N, double d = 0.1, double alpha = 0.05) {
|
|
| 281 |
qestats::ConfusionMatrix c1{
|
|
| 282 | ! |
as<double>(cm1["TP"]), as<double>(cm1["FP"]), |
| 283 | ! |
as<double>(cm1["FN"]), as<double>(cm1["TN"]) |
| 284 |
}; |
|
| 285 |
qestats::ConfusionMatrix c2{
|
|
| 286 | ! |
as<double>(cm2["TP"]), as<double>(cm2["FP"]), |
| 287 | ! |
as<double>(cm2["FN"]), as<double>(cm2["TN"]) |
| 288 |
}; |
|
| 289 | ! |
return qestats::N_Diff2(c1, c2, metric, N, d, alpha); |
| 290 |
} |
|
| 291 | ||
| 292 |
// ── rho ─────────────────────────────────────────────────────────────────────── |
|
| 293 |
// R's RNG for qestats::rho: a seeded call returns what rhoR returns. |
|
| 294 |
struct RRng {
|
|
| 295 | 246829x |
double unif_rand() { return ::unif_rand(); }
|
| 296 | 248477x |
double unif_index(double n) { return R_unif_index(n); }
|
| 297 |
}; |
|
| 298 | ||
| 299 |
//' Shaffer's rho for an observed kappa (rhoR's rhoK) |
|
| 300 |
//' @param x Observed kappa of the test set |
|
| 301 |
//' @param baserate Base rate of the coded population (OcSBaserate) |
|
| 302 |
//' @param test_set_length Number of items in the test set |
|
| 303 |
//' @param test_set_baserate_inflation Minimum share of positives in the test set |
|
| 304 |
//' @param ocs_length Size of each simulated population (OcSLength) |
|
| 305 |
//' @param replicates Number of simulated populations |
|
| 306 |
//' @param scs_kappa_threshold Kappa threshold (ScSKappaThreshold) |
|
| 307 |
//' @param scs_kappa_min Minimum simulated kappa (ScSKappaMin) |
|
| 308 |
//' @param scs_precision_min Minimum simulated precision (ScSPrecisionMin) |
|
| 309 |
//' @param scs_precision_max Maximum simulated precision (ScSPrecisionMax) |
|
| 310 |
//' @param method "standard" (rhoR's default R path) or "fast" |
|
| 311 |
//' (rhoR's C++ path) |
|
| 312 |
//' @return rho (double); uses R's random number generator |
|
| 313 |
//' @export |
|
| 314 |
// [[Rcpp::export]] |
|
| 315 | 11x |
double qestats_rho(double x, double baserate, int test_set_length, |
| 316 |
double test_set_baserate_inflation = 0, int ocs_length = 10000, |
|
| 317 |
int replicates = 800, double scs_kappa_threshold = 0.9, |
|
| 318 |
double scs_kappa_min = 0.40, double scs_precision_min = 0.6, |
|
| 319 |
double scs_precision_max = 1, std::string method = "standard") {
|
|
| 320 | 11x |
qestats::RhoOptions opts; |
| 321 | 11x |
opts.test_set_baserate_inflation = test_set_baserate_inflation; |
| 322 | 11x |
opts.ocs_length = ocs_length; |
| 323 | 11x |
opts.replicates = replicates; |
| 324 | 11x |
opts.scs_kappa_threshold = scs_kappa_threshold; |
| 325 | 11x |
opts.scs_kappa_min = scs_kappa_min; |
| 326 | 11x |
opts.scs_precision_min = scs_precision_min; |
| 327 | 11x |
opts.scs_precision_max = scs_precision_max; |
| 328 | 11x |
opts.method = (method == "standard") ? qestats::RhoMethod::Standard |
| 329 |
: qestats::RhoMethod::Fast; |
|
| 330 |
RRng rng; |
|
| 331 | 19x |
return qestats::rho(x, baserate, test_set_length, opts, rng); |
| 332 |
} |