Add Satterthwaite and Kenward-Roger fixed-effect tests
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
#include "spectra_reml/reml.hpp"
|
||||
#include "spectra_reml/fixed_effects.hpp"
|
||||
#include "spectra_reml/distributions.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -163,6 +165,119 @@ void test_ai_reml_fit_improves_likelihood() {
|
||||
"reported sigma_e2 is inconsistent");
|
||||
require_near(fitted.sigma_g2, fitted.sigma_g * fitted.sigma_g, 1e-14,
|
||||
"reported sigma_g2 is inconsistent");
|
||||
|
||||
spectra::reml::FixedEffectHypothesis joint;
|
||||
joint.contrast = spectra::reml::ColMajorMatrix(2, fixture.x.cols());
|
||||
joint.contrast(0, 1) = 1.0;
|
||||
joint.contrast(1, 2) = 1.0;
|
||||
const auto satterthwaite = spectra::reml::infer_fixed_effects_spectral(
|
||||
fixture.y, fixture.x, fixture.lambda, fitted,
|
||||
spectra::reml::FixedEffectTestMethod::satterthwaite, {joint});
|
||||
require(satterthwaite.status ==
|
||||
spectra::reml::FixedEffectInferenceStatus::ok,
|
||||
"interior Satterthwaite inference failed: " + satterthwaite.error);
|
||||
require(satterthwaite.coefficient_tests.size() == fixture.x.cols(),
|
||||
"interior Satterthwaite coefficient tests are missing");
|
||||
require(satterthwaite.hypothesis_tests.size() == 1 &&
|
||||
satterthwaite.hypothesis_tests.front().numerator_df == 2,
|
||||
"interior Satterthwaite joint test is missing");
|
||||
require(satterthwaite.hypothesis_tests.front().valid &&
|
||||
satterthwaite.hypothesis_tests.front().p_value >= 0.0 &&
|
||||
satterthwaite.hypothesis_tests.front().p_value <= 1.0,
|
||||
"interior Satterthwaite joint p-value is invalid");
|
||||
|
||||
const auto kr = spectra::reml::infer_fixed_effects_spectral(
|
||||
fixture.y, fixture.x, fixture.lambda, fitted,
|
||||
spectra::reml::FixedEffectTestMethod::kenward_roger, {joint});
|
||||
require(kr.status == spectra::reml::FixedEffectInferenceStatus::ok,
|
||||
"interior KR inference failed: " + kr.error);
|
||||
require(kr.hypothesis_tests.size() == 1 &&
|
||||
kr.hypothesis_tests.front().valid &&
|
||||
kr.hypothesis_tests.front().numerator_df == 2,
|
||||
"interior KR joint test is invalid");
|
||||
}
|
||||
|
||||
void test_probability_distributions_against_r() {
|
||||
require_near(spectra::reml::student_t_two_sided_p(2.1, 7.3),
|
||||
0.072246713424853351, 2e-14,
|
||||
"Student-t tail probability disagrees with R");
|
||||
require_near(spectra::reml::f_upper_tail(3.7, 2.5, 11.2),
|
||||
0.051114185450108554, 2e-14,
|
||||
"F tail probability disagrees with R");
|
||||
require_near(spectra::reml::student_t_two_sided_p(12.0, 3.5),
|
||||
0.0005745341928561279, 2e-14,
|
||||
"extreme Student-t tail probability disagrees with R");
|
||||
require_near(spectra::reml::f_upper_tail(100.0, 1.0, 8.25),
|
||||
6.803231179451616e-06, 2e-14,
|
||||
"extreme F tail probability disagrees with R");
|
||||
}
|
||||
|
||||
void test_fixed_effect_inference_against_dense_reference() {
|
||||
Fixture fixture;
|
||||
constexpr double sigma_e = 0.73;
|
||||
constexpr double sigma_g = 0.46;
|
||||
const auto evaluated = spectra::reml::evaluate_reml_spectral(
|
||||
fixture.y, fixture.x, fixture.lambda, sigma_e, sigma_g, false, true);
|
||||
require(evaluated.valid, "fixed-effect reference evaluation failed");
|
||||
spectra::reml::RemlResult fit;
|
||||
fit.status = spectra::reml::FitStatus::converged;
|
||||
fit.sigma_e = sigma_e;
|
||||
fit.sigma_g = sigma_g;
|
||||
fit.sigma_e2 = sigma_e * sigma_e;
|
||||
fit.sigma_g2 = sigma_g * sigma_g;
|
||||
fit.beta = evaluated.beta;
|
||||
|
||||
spectra::reml::FixedEffectHypothesis joint;
|
||||
joint.contrast = spectra::reml::ColMajorMatrix(2, fixture.x.cols());
|
||||
joint.contrast(0, 1) = 1.0;
|
||||
joint.contrast(1, 2) = 1.0;
|
||||
const auto satt = spectra::reml::infer_fixed_effects_spectral(
|
||||
fixture.y, fixture.x, fixture.lambda, fit,
|
||||
spectra::reml::FixedEffectTestMethod::satterthwaite, {joint});
|
||||
require(satt.status == spectra::reml::FixedEffectInferenceStatus::ok,
|
||||
"dense-reference Satterthwaite inference failed: " + satt.error);
|
||||
const std::vector<double> expected_satt_df = {
|
||||
11.271857730561006, 11.012661088553992, 8.7818755201562855};
|
||||
const std::vector<double> expected_satt_t = {
|
||||
1.9994269321713118, -1.6229945279594145, 0.26284041305853334};
|
||||
for (std::size_t i = 0; i < fixture.x.cols(); ++i) {
|
||||
require_near(satt.coefficient_tests[i].denominator_df,
|
||||
expected_satt_df[i], 3e-11,
|
||||
"Satterthwaite df disagrees with dense reference");
|
||||
require_near(satt.coefficient_tests[i].statistic,
|
||||
expected_satt_t[i], 3e-12,
|
||||
"Satterthwaite t disagrees with dense reference");
|
||||
}
|
||||
require_near(satt.hypothesis_tests[0].statistic,
|
||||
1.3172663846541173, 3e-12,
|
||||
"Satterthwaite joint F disagrees with dense reference");
|
||||
require_near(satt.hypothesis_tests[0].denominator_df,
|
||||
9.7667759456518599, 3e-11,
|
||||
"Satterthwaite joint df disagrees with dense reference");
|
||||
|
||||
const auto kr = spectra::reml::infer_fixed_effects_spectral(
|
||||
fixture.y, fixture.x, fixture.lambda, fit,
|
||||
spectra::reml::FixedEffectTestMethod::kenward_roger, {joint});
|
||||
require(kr.status == spectra::reml::FixedEffectInferenceStatus::ok,
|
||||
"dense-reference KR inference failed: " + kr.error);
|
||||
const std::vector<double> expected_kr_df = {
|
||||
14.488430939129156, 14.905537974410061, 14.020384661469432};
|
||||
const std::vector<double> expected_kr_f = {
|
||||
3.9170644186264694, 2.1360773364244796, 0.0544837858871276};
|
||||
for (std::size_t i = 0; i < fixture.x.cols(); ++i) {
|
||||
require_near(kr.coefficient_tests[i].denominator_df,
|
||||
expected_kr_df[i], 3e-11,
|
||||
"KR df disagrees with dense reference");
|
||||
require_near(kr.coefficient_tests[i].statistic,
|
||||
expected_kr_f[i], 3e-11,
|
||||
"KR F disagrees with dense reference");
|
||||
}
|
||||
require_near(kr.hypothesis_tests[0].statistic,
|
||||
1.0684514230854425, 3e-11,
|
||||
"KR joint F disagrees with dense reference");
|
||||
require_near(kr.hypothesis_tests[0].denominator_df,
|
||||
14.615505880683108, 3e-11,
|
||||
"KR joint df disagrees with dense reference");
|
||||
}
|
||||
|
||||
void test_internal_phenotype_scaling_restores_original_units() {
|
||||
@@ -212,15 +327,15 @@ void test_residual_only_kkt_boundary() {
|
||||
constexpr std::size_t n = 14;
|
||||
spectra::reml::ColMajorMatrix x(n, 1);
|
||||
std::vector<double> lambda(n);
|
||||
std::vector<double> y(n, 0.0);
|
||||
std::vector<double> y(n, 1.5);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
x(i, 0) = 1.0;
|
||||
lambda[i] = 0.1 * static_cast<double>(i + 1);
|
||||
}
|
||||
// The residual energy is confined to the two smallest GRM eigenvalues,
|
||||
// making the one-sided score for v_g at zero strictly negative.
|
||||
y[0] = 1.0;
|
||||
y[1] = -1.0;
|
||||
y[0] += 1.0;
|
||||
y[1] -= 1.0;
|
||||
const double expected_sigma_e2 = 2.0 / static_cast<double>(n - 1);
|
||||
const auto boundary = spectra::reml::evaluate_reml_spectral(
|
||||
y, x, lambda, std::sqrt(expected_sigma_e2), 0.0, false, true);
|
||||
@@ -250,6 +365,42 @@ void test_residual_only_kkt_boundary() {
|
||||
"residual-only variance is not RSS/(n-p)");
|
||||
require_near(fitted.log_likelihood, boundary.log_likelihood, 2e-12,
|
||||
"reported boundary likelihood is inconsistent");
|
||||
|
||||
const double expected_se = std::sqrt(expected_sigma_e2 / n);
|
||||
const double expected_t = 1.5 / expected_se;
|
||||
const auto satterthwaite = spectra::reml::infer_fixed_effects_spectral(
|
||||
y, x, lambda, fitted,
|
||||
spectra::reml::FixedEffectTestMethod::satterthwaite);
|
||||
require(satterthwaite.status ==
|
||||
spectra::reml::FixedEffectInferenceStatus::boundary_conditional,
|
||||
"Satterthwaite boundary inference did not report conditional status");
|
||||
require(satterthwaite.coefficient_tests.size() == 1,
|
||||
"Satterthwaite coefficient test is missing");
|
||||
const auto& satt = satterthwaite.coefficient_tests.front();
|
||||
require(satt.valid, "Satterthwaite boundary test is invalid");
|
||||
require_near(satt.denominator_df, static_cast<double>(n - 1), 2e-11,
|
||||
"Satterthwaite did not recover OLS residual df");
|
||||
require_near(satt.standard_error, expected_se, 2e-12,
|
||||
"Satterthwaite did not recover OLS standard error");
|
||||
require_near(satt.statistic, expected_t, 2e-11,
|
||||
"Satterthwaite did not recover OLS t statistic");
|
||||
|
||||
const auto kr = spectra::reml::infer_fixed_effects_spectral(
|
||||
y, x, lambda, fitted,
|
||||
spectra::reml::FixedEffectTestMethod::kenward_roger);
|
||||
require(kr.status ==
|
||||
spectra::reml::FixedEffectInferenceStatus::boundary_conditional,
|
||||
"KR boundary inference did not report conditional status");
|
||||
require(kr.coefficient_tests.size() == 1,
|
||||
"KR coefficient test is missing");
|
||||
const auto& kr_test = kr.coefficient_tests.front();
|
||||
require(kr_test.valid, "KR boundary test is invalid: " + kr.error);
|
||||
require_near(kr_test.denominator_df, static_cast<double>(n - 1), 2e-10,
|
||||
"KR did not recover OLS residual df");
|
||||
require_near(kr_test.standard_error, expected_se, 2e-11,
|
||||
"KR did not recover OLS standard error");
|
||||
require_near(kr_test.statistic, expected_t * expected_t, 2e-10,
|
||||
"KR did not recover OLS F statistic");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -259,6 +410,8 @@ int main() {
|
||||
test_score_matches_finite_difference();
|
||||
test_signed_parameterization_and_row_permutation();
|
||||
test_ai_reml_fit_improves_likelihood();
|
||||
test_probability_distributions_against_r();
|
||||
test_fixed_effect_inference_against_dense_reference();
|
||||
test_internal_phenotype_scaling_restores_original_units();
|
||||
test_residual_only_kkt_boundary();
|
||||
std::cout << "test_reml_synthetic: PASS\n";
|
||||
|
||||
@@ -64,14 +64,29 @@ class SpectraRemlCliTests(unittest.TestCase):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "block_000000.summary.tsv").write_text(
|
||||
"\t".join(cli.SUMMARY_HEADER) + "\n"
|
||||
"0\ttrait_a\tconverged\t2\t0\t0\t0\t1\t1\t0.5\t-1\t4\t8\t1e-8\t\n"
|
||||
"1\ttrait_b\tconverged_boundary\t3\t1\t2\t3\t0\t1\t0\t-2\t3\t6\t1e-9\t\n",
|
||||
"0\ttrait_a\tconverged\t2\t0\t0\t0\t1\t1\t0.5\t-1\t4\t8\t1e-8"
|
||||
"\tsatterthwaite\tok\t0\t0\tnan\tnan\tnan\t\t\n"
|
||||
"1\ttrait_b\tconverged_boundary\t3\t1\t2\t3\t0\t1\t0\t-2\t3\t6\t1e-9"
|
||||
"\tsatterthwaite\tboundary_conditional\t2\t1\t8\t2\t0.2\t\t\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
np.asarray([1, 2, 3, 4, 5], dtype="<f8").tofile(root / "block_000000.beta.f64.bin")
|
||||
np.arange(1, 10, dtype="<f8").tofile(root / "block_000000.cov.f64.bin")
|
||||
np.asarray([0.1, 0.2, 0.3, 0.4, 0.5], dtype="<f8").tofile(
|
||||
root / "block_000000.fixed_se.f64.bin"
|
||||
)
|
||||
np.asarray([1, 2, 3, 4, 5], dtype="<f8").tofile(
|
||||
root / "block_000000.fixed_stat.f64.bin"
|
||||
)
|
||||
np.asarray([10, 10, 8, 8, 8], dtype="<f8").tofile(
|
||||
root / "block_000000.fixed_ddf.f64.bin"
|
||||
)
|
||||
np.asarray([0.5, 0.2, 0.1, 0.05, 0.01], dtype="<f8").tofile(
|
||||
root / "block_000000.fixed_p.f64.bin"
|
||||
)
|
||||
(root / "block_000000.complete").write_text(
|
||||
"format\t{}\nblock\t0\ntasks\t2\nbeta_elements\t5\ncov_elements\t9\n".format(cli.BLOCK_FORMAT),
|
||||
"format\t{}\nblock\t0\ntasks\t2\nbeta_elements\t5\ncov_elements\t9\n"
|
||||
"fixed_test_elements\t5\n".format(cli.BLOCK_FORMAT),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -127,6 +142,7 @@ class SpectraRemlCliTests(unittest.TestCase):
|
||||
self.assertEqual([row["task_id"] for row in rows], ["trait_a", "trait_b"])
|
||||
self.assertEqual(json.loads(rows[0]["beta_json"]), [1.0, 2.0])
|
||||
self.assertEqual(len(json.loads(rows[1]["covariance_packed_lower_json"])), 6)
|
||||
self.assertEqual(len(json.loads(rows[1]["fixed_effect_p_value_json"])), 3)
|
||||
|
||||
altered = dict(manifest)
|
||||
altered["created_utc"] = "changed"
|
||||
|
||||
Reference in New Issue
Block a user