Handle boundary and line-search fixed-effect inference

This commit is contained in:
2026-08-14 16:54:57 +08:00
parent 48ab77f9ab
commit 9033201948
10 changed files with 220 additions and 41 deletions

View File

@@ -151,14 +151,20 @@ Optional named contrasts are supplied by `--contrast-matrix`,
task-specific covariate columns receive zero weights automatically. Finalized task-specific covariate columns receive zero weights automatically. Finalized
results contain contrast estimates, adjusted standard errors, statistics, results contain contrast estimates, adjusted standard errors, statistics,
numerator and denominator degrees of freedom, and p-values as JSON arrays. numerator and denominator degrees of freedom, and p-values as JSON arrays.
This release uses manifest v2, run-signature v2, block v3, and finalized-output This release uses manifest v2, run-signature v3, block v4, and finalized-output
v2 contracts. It intentionally does not read older contracts. v3 contracts. It intentionally does not read older block, run-signature, or
finalized-output contracts.
Fixed-effect inference defaults to Satterthwaite. Select Kenward-Roger or turn Fixed-effect inference defaults to Satterthwaite. Select Kenward-Roger or turn
inference off with `--fixed-effect-test kenward-roger` or inference off with `--fixed-effect-test kenward-roger` or
`--fixed-effect-test none`. Coefficient-wise standard errors, statistics, `--fixed-effect-test none`. Coefficient-wise standard errors, statistics,
denominator degrees of freedom, and p-values are exported as JSON arrays; tasks denominator degrees of freedom, and p-values are exported as JSON arrays; tasks
with extra covariates also report their joint F test in the summary columns. with extra covariates also report their joint F test in the summary columns.
An accepted `sigma_g2=0` boundary is refitted as ordinary least squares without
the GRM term and is reported as `boundary_ols`. A retained
`line_search_failed` iterate continues through the selected fixed-effect
inference and is reported as `line_search_conditional` while preserving the
optimizer status.
## Recovery and provenance ## Recovery and provenance

View File

@@ -165,7 +165,7 @@ The covariance array uses the row-wise packed lower triangle:
The `.complete` marker is written last and contains tab-separated key/value rows: The `.complete` marker is written last and contains tab-separated key/value rows:
```text ```text
format spectra-reml-block-v3 format spectra-reml-block-v4
block 0 block 0
tasks 256 tasks 256
beta_elements 4096 beta_elements 4096
@@ -198,34 +198,39 @@ numerical_error
The four `fixed_*` arrays have the same offsets and coefficient order as The four `fixed_*` arrays have the same offsets and coefficient order as
`beta`. They contain standard errors, statistics, denominator degrees of `beta`. They contain standard errors, statistics, denominator degrees of
freedom, and p-values. With Satterthwaite inference, a coefficient statistic is freedom, and p-values. With ordinary least-squares or Satterthwaite inference,
a signed t statistic. With Kenward-Roger inference, it is an F statistic with a coefficient statistic is a signed t statistic. With Kenward-Roger inference,
one numerator degree of freedom. For every task with extra covariates, the it is an F statistic with one numerator degree of freedom. For every task with
summary also contains an F test of the joint null that all task-specific fixed extra covariates, the summary also contains a test of the joint null that all
effects are zero. task-specific fixed effects are zero.
The six `contrast_*` arrays share `contrast_test_offset` and follow the The six `contrast_*` arrays share `contrast_test_offset` and follow the
contrast metadata order. A one-row Satterthwaite contrast reports a signed t contrast metadata order. A one-row ordinary least-squares or Satterthwaite
statistic; Kenward-Roger reports an F statistic with one numerator degree of contrast reports a signed t statistic; Kenward-Roger reports an F statistic
freedom. Both methods retain the signed `L beta` estimate and their adjusted with one numerator degree of freedom. All methods retain the signed `L beta`
standard error. Negative `contrast_test_offset` means that no valid contrast estimate and their standard error. Negative `contrast_test_offset` means that
tests were emitted for that task. no valid contrast tests were emitted for that task.
`fixed_test_status` is one of: `fixed_test_status` is one of:
```text ```text
not_requested not_requested
ok ok
boundary_conditional boundary_ols
line_search_conditional
fit_not_converged fit_not_converged
invalid_contrast invalid_contrast
information_singular information_singular
numerical_error numerical_error
``` ```
At `converged_boundary`, inference conditions on the accepted active set At `converged_boundary`, the random term is removed and the fixed model is
`sigma_g2=0`; `fixed_test_status` is `boundary_conditional` and only residual refitted as ordinary least squares. `fixed_test_method` is
variance uncertainty contributes to the small-sample adjustment. `ordinary-least-squares`, `fixed_test_status` is `boundary_ols`, and the
denominator degrees of freedom are `n-rank(X)`. At `line_search_failed`, the
last retained REML iterate is tested with the requested Satterthwaite or
Kenward-Roger method and `fixed_test_status` is
`line_search_conditional`; the optimizer flag remains visible in `status`.
Phenotypes are scaled internally by their task-specific OLS residual RMS before Phenotypes are scaled internally by their task-specific OLS residual RMS before
optimization. Reported fixed effects, fixed-effect covariance, variance optimization. Reported fixed effects, fixed-effect covariance, variance

View File

@@ -13,9 +13,11 @@ struct FixedEffectHypothesis {
std::vector<double> rhs; std::vector<double> rhs;
}; };
// Computes coefficient-wise tests and any supplied general linear hypotheses // Computes coefficient-wise tests and any supplied general linear hypotheses.
// at an already fitted REML solution. Inputs must use the same (possibly GRM- // At an interior or retained line-search iterate it uses the requested REML
// rotated) coordinate system and phenotype scale as the supplied fit. // small-sample method. A sigma_g2=0 boundary fit is refitted and tested as
// ordinary least squares without a GRM term. Inputs must use the same
// orthogonally rotated coordinate system and phenotype scale as the fit.
[[nodiscard]] FixedEffectInferenceResult infer_fixed_effects_spectral( [[nodiscard]] FixedEffectInferenceResult infer_fixed_effects_spectral(
const std::vector<double>& y_star, const ColMajorMatrix& x_star, const std::vector<double>& y_star, const ColMajorMatrix& x_star,
const std::vector<double>& eigenvalues, const RemlResult& fit, const std::vector<double>& eigenvalues, const RemlResult& fit,

View File

@@ -66,6 +66,7 @@ enum class FitStatus {
enum class FixedEffectTestMethod { enum class FixedEffectTestMethod {
none, none,
ordinary_least_squares,
satterthwaite, satterthwaite,
kenward_roger kenward_roger
}; };
@@ -75,7 +76,8 @@ enum class FixedEffectTestMethod {
enum class FixedEffectInferenceStatus { enum class FixedEffectInferenceStatus {
not_requested, not_requested,
ok, ok,
boundary_conditional, boundary_ols,
line_search_conditional,
fit_not_converged, fit_not_converged,
invalid_contrast, invalid_contrast,
information_singular, information_singular,
@@ -99,8 +101,9 @@ struct FixedEffectInferenceResult {
FixedEffectTestMethod method = FixedEffectTestMethod::none; FixedEffectTestMethod method = FixedEffectTestMethod::none;
FixedEffectInferenceStatus status = FixedEffectInferenceStatus status =
FixedEffectInferenceStatus::not_requested; FixedEffectInferenceStatus::not_requested;
// One test per beta, in design-matrix column order. Satterthwaite reports // One test per beta, in design-matrix column order. OLS and
// a signed t statistic; Kenward-Roger reports an F statistic with 1 NumDF. // Satterthwaite report signed t statistics; Kenward-Roger reports an F
// statistic with 1 NumDF.
std::vector<FixedEffectTestResult> coefficient_tests; std::vector<FixedEffectTestResult> coefficient_tests;
// Optional general linear hypotheses requested by the caller. // Optional general linear hypotheses requested by the caller.
std::vector<FixedEffectTestResult> hypothesis_tests; std::vector<FixedEffectTestResult> hypothesis_tests;

View File

@@ -32,9 +32,9 @@ except ImportError as exc: # pragma: no cover
MANIFEST_FORMAT = "spectra-reml-manifest-v2" MANIFEST_FORMAT = "spectra-reml-manifest-v2"
BLOCK_FORMAT = "spectra-reml-block-v3" BLOCK_FORMAT = "spectra-reml-block-v4"
RUN_SIGNATURE_FORMAT = "spectra-reml-run-signature-v2" RUN_SIGNATURE_FORMAT = "spectra-reml-run-signature-v3"
FINALIZE_FORMAT = "spectra-reml-finalize-v2" FINALIZE_FORMAT = "spectra-reml-finalize-v3"
TASK_HEADER = ( TASK_HEADER = (
"task_index", "task_index",
"task_id", "task_id",

View File

@@ -34,7 +34,7 @@
namespace spectra::reml { namespace spectra::reml {
namespace { namespace {
constexpr const char* kOutputFormatVersion = "spectra-reml-block-v3"; constexpr const char* kOutputFormatVersion = "spectra-reml-block-v4";
std::vector<std::string> split_tab(const std::string& line); std::vector<std::string> split_tab(const std::string& line);
std::uint64_t parse_u64(const std::string& text, const char* field, std::uint64_t parse_u64(const std::string& text, const char* field,
@@ -442,7 +442,9 @@ void write_block_atomic(const std::filesystem::path& output_directory,
const auto& inference = fit.fixed_effect_inference; const auto& inference = fit.fixed_effect_inference;
if ((inference.status == FixedEffectInferenceStatus::ok || if ((inference.status == FixedEffectInferenceStatus::ok ||
inference.status == inference.status ==
FixedEffectInferenceStatus::boundary_conditional) && FixedEffectInferenceStatus::boundary_ols ||
inference.status ==
FixedEffectInferenceStatus::line_search_conditional) &&
inference.coefficient_tests.size() == fit.beta.size()) { inference.coefficient_tests.size() == fit.beta.size()) {
fixed_test_offsets[index] = fixed_test_offsets[index] =
static_cast<std::int64_t>(fixed_se_values.size()); static_cast<std::int64_t>(fixed_se_values.size());

View File

@@ -395,6 +395,110 @@ double contrast_variance(const std::vector<double>& contrast,
return quadratic_form(contrast, covariance, p); return quadratic_form(contrast, covariance, p);
} }
struct OlsWorkspace {
std::size_t n = 0;
std::size_t p = 0;
double denominator_df = std::numeric_limits<double>::quiet_NaN();
std::vector<double> beta;
Matrix covariance;
};
OlsWorkspace make_ols_workspace(const std::vector<double>& y,
const ColMajorMatrix& x) {
OlsWorkspace workspace;
workspace.n = x.rows();
workspace.p = x.cols();
if (workspace.n <= workspace.p) {
throw std::runtime_error(
"ordinary least squares requires positive residual degrees of freedom");
}
const std::vector<double> unit_weights(workspace.n, 1.0);
const Matrix normal = weighted_cross_product(x, unit_weights);
Matrix inverse_normal;
std::string error;
if (!invert_positive_definite(normal, workspace.p, inverse_normal, error)) {
throw std::runtime_error(
"ordinary least-squares design is rank deficient: " + error);
}
std::vector<double> rhs(workspace.p, 0.0);
for (std::size_t col = 0; col < workspace.p; ++col) {
for (std::size_t row = 0; row < workspace.n; ++row) {
rhs[col] += x(row, col) * y[row];
}
}
workspace.beta.assign(workspace.p, 0.0);
for (std::size_t row = 0; row < workspace.p; ++row) {
for (std::size_t col = 0; col < workspace.p; ++col) {
workspace.beta[row] +=
at(inverse_normal, workspace.p, row, col) * rhs[col];
}
}
double rss = 0.0;
for (std::size_t row = 0; row < workspace.n; ++row) {
double fitted = 0.0;
for (std::size_t col = 0; col < workspace.p; ++col) {
fitted += x(row, col) * workspace.beta[col];
}
const double residual = y[row] - fitted;
rss += residual * residual;
}
workspace.denominator_df =
static_cast<double>(workspace.n - workspace.p);
const double residual_variance = rss / workspace.denominator_df;
if (!(residual_variance > 0.0) || !std::isfinite(residual_variance)) {
throw std::runtime_error(
"ordinary least-squares residual variance is non-positive");
}
workspace.covariance = std::move(inverse_normal);
for (double& value : workspace.covariance) {
value *= residual_variance;
}
return workspace;
}
FixedEffectTestResult ols_test(
const FixedEffectHypothesis& hypothesis, const OlsWorkspace& workspace,
double rank_tolerance_relative) {
const ReducedHypothesis reduced = reduce_hypothesis(
hypothesis, workspace.covariance, workspace.p,
rank_tolerance_relative);
FixedEffectTestResult result;
result.valid = true;
result.numerator_df = reduced.rank;
result.denominator_df = workspace.denominator_df;
double sum_t_squared = 0.0;
for (std::size_t row = 0; row < reduced.rank; ++row) {
double raw_estimate = 0.0;
for (std::size_t col = 0; col < workspace.p; ++col) {
raw_estimate +=
reduced.contrast(row, col) * workspace.beta[col];
}
const double difference = raw_estimate - reduced.rhs[row];
const double variance = reduced.eigenvalues[row];
if (!(variance > 0.0) || !std::isfinite(variance)) {
throw std::runtime_error(
"ordinary least-squares contrast variance is non-positive");
}
sum_t_squared += difference * difference / variance;
if (reduced.rank == 1) {
result.estimate = raw_estimate;
result.standard_error = std::sqrt(variance);
result.statistic = difference / result.standard_error;
}
}
if (reduced.rank == 1) {
result.p_value = student_t_two_sided_p(
result.statistic, result.denominator_df);
} else {
result.statistic =
sum_t_squared / static_cast<double>(reduced.rank);
result.p_value = f_upper_tail(
result.statistic, static_cast<double>(reduced.rank),
result.denominator_df);
}
return result;
}
double satterthwaite_df(const std::vector<double>& contrast, double satterthwaite_df(const std::vector<double>& contrast,
const InferenceWorkspace& workspace, const InferenceWorkspace& workspace,
double variance) { double variance) {
@@ -632,9 +736,12 @@ FixedEffectInferenceResult infer_fixed_effects_spectral(
return result; return result;
} }
if (fit.status != FitStatus::converged && if (fit.status != FitStatus::converged &&
fit.status != FitStatus::converged_boundary) { fit.status != FitStatus::converged_boundary &&
fit.status != FitStatus::line_search_failed) {
result.status = FixedEffectInferenceStatus::fit_not_converged; result.status = FixedEffectInferenceStatus::fit_not_converged;
result.error = "fixed-effect tests require a converged REML fit"; result.error =
"fixed-effect tests require an interior fit, a zero-GRM boundary "
"fit, or a retained line-search iterate";
return result; return result;
} }
if (y_star.size() != x_star.rows() || eigenvalues.size() != x_star.rows() || if (y_star.size() != x_star.rows() || eigenvalues.size() != x_star.rows() ||
@@ -644,6 +751,26 @@ FixedEffectInferenceResult infer_fixed_effects_spectral(
return result; return result;
} }
try { try {
if (fit.status == FitStatus::converged_boundary) {
result.method = FixedEffectTestMethod::ordinary_least_squares;
const OlsWorkspace workspace = make_ols_workspace(y_star, x_star);
result.coefficient_tests.reserve(x_star.cols());
for (std::size_t coefficient = 0; coefficient < x_star.cols();
++coefficient) {
FixedEffectHypothesis hypothesis;
hypothesis.contrast = ColMajorMatrix(1, x_star.cols());
hypothesis.contrast(0, coefficient) = 1.0;
result.coefficient_tests.push_back(ols_test(
hypothesis, workspace, rank_tolerance_relative));
}
result.hypothesis_tests.reserve(hypotheses.size());
for (const auto& hypothesis : hypotheses) {
result.hypothesis_tests.push_back(ols_test(
hypothesis, workspace, rank_tolerance_relative));
}
result.status = FixedEffectInferenceStatus::boundary_ols;
return result;
}
const InferenceWorkspace workspace = make_workspace( const InferenceWorkspace workspace = make_workspace(
y_star, x_star, eigenvalues, fit, method, y_star, x_star, eigenvalues, fit, method,
covariance_floor_relative); covariance_floor_relative);
@@ -663,8 +790,8 @@ FixedEffectInferenceResult infer_fixed_effects_spectral(
hypothesis, fit.beta, workspace, method, hypothesis, fit.beta, workspace, method,
rank_tolerance_relative)); rank_tolerance_relative));
} }
result.status = workspace.boundary result.status = fit.status == FitStatus::line_search_failed
? FixedEffectInferenceStatus::boundary_conditional ? FixedEffectInferenceStatus::line_search_conditional
: FixedEffectInferenceStatus::ok; : FixedEffectInferenceStatus::ok;
} catch (const std::invalid_argument& exception) { } catch (const std::invalid_argument& exception) {
result.status = FixedEffectInferenceStatus::invalid_contrast; result.status = FixedEffectInferenceStatus::invalid_contrast;

View File

@@ -738,6 +738,8 @@ const char* to_string(FixedEffectTestMethod method) noexcept {
switch (method) { switch (method) {
case FixedEffectTestMethod::none: case FixedEffectTestMethod::none:
return "none"; return "none";
case FixedEffectTestMethod::ordinary_least_squares:
return "ordinary-least-squares";
case FixedEffectTestMethod::satterthwaite: case FixedEffectTestMethod::satterthwaite:
return "satterthwaite"; return "satterthwaite";
case FixedEffectTestMethod::kenward_roger: case FixedEffectTestMethod::kenward_roger:
@@ -752,8 +754,10 @@ const char* to_string(FixedEffectInferenceStatus status) noexcept {
return "not_requested"; return "not_requested";
case FixedEffectInferenceStatus::ok: case FixedEffectInferenceStatus::ok:
return "ok"; return "ok";
case FixedEffectInferenceStatus::boundary_conditional: case FixedEffectInferenceStatus::boundary_ols:
return "boundary_conditional"; return "boundary_ols";
case FixedEffectInferenceStatus::line_search_conditional:
return "line_search_conditional";
case FixedEffectInferenceStatus::fit_not_converged: case FixedEffectInferenceStatus::fit_not_converged:
return "fit_not_converged"; return "fit_not_converged";
case FixedEffectInferenceStatus::invalid_contrast: case FixedEffectInferenceStatus::invalid_contrast:

View File

@@ -195,6 +195,30 @@ void test_ai_reml_fit_improves_likelihood() {
kr.hypothesis_tests.front().valid && kr.hypothesis_tests.front().valid &&
kr.hypothesis_tests.front().numerator_df == 2, kr.hypothesis_tests.front().numerator_df == 2,
"interior KR joint test is invalid"); "interior KR joint test is invalid");
auto line_search_flagged = fitted;
line_search_flagged.status =
spectra::reml::FitStatus::line_search_failed;
line_search_flagged.error = "synthetic retained line-search iterate";
const auto flagged_satterthwaite =
spectra::reml::infer_fixed_effects_spectral(
fixture.y, fixture.x, fixture.lambda, line_search_flagged,
spectra::reml::FixedEffectTestMethod::satterthwaite, {joint});
require(flagged_satterthwaite.status ==
spectra::reml::FixedEffectInferenceStatus::line_search_conditional,
"line-search Satterthwaite inference was not retained and flagged");
require(flagged_satterthwaite.hypothesis_tests.size() == 1 &&
flagged_satterthwaite.hypothesis_tests.front().valid,
"line-search Satterthwaite hypothesis test is invalid");
const auto flagged_kr = spectra::reml::infer_fixed_effects_spectral(
fixture.y, fixture.x, fixture.lambda, line_search_flagged,
spectra::reml::FixedEffectTestMethod::kenward_roger, {joint});
require(flagged_kr.status ==
spectra::reml::FixedEffectInferenceStatus::line_search_conditional,
"line-search KR inference was not retained and flagged");
require(flagged_kr.hypothesis_tests.size() == 1 &&
flagged_kr.hypothesis_tests.front().valid,
"line-search KR hypothesis test is invalid");
} }
void test_probability_distributions_against_r() { void test_probability_distributions_against_r() {
@@ -372,8 +396,11 @@ void test_residual_only_kkt_boundary() {
y, x, lambda, fitted, y, x, lambda, fitted,
spectra::reml::FixedEffectTestMethod::satterthwaite); spectra::reml::FixedEffectTestMethod::satterthwaite);
require(satterthwaite.status == require(satterthwaite.status ==
spectra::reml::FixedEffectInferenceStatus::boundary_conditional, spectra::reml::FixedEffectInferenceStatus::boundary_ols,
"Satterthwaite boundary inference did not report conditional status"); "Satterthwaite request at the boundary did not fall back to OLS");
require(satterthwaite.method ==
spectra::reml::FixedEffectTestMethod::ordinary_least_squares,
"boundary inference did not report ordinary least squares");
require(satterthwaite.coefficient_tests.size() == 1, require(satterthwaite.coefficient_tests.size() == 1,
"Satterthwaite coefficient test is missing"); "Satterthwaite coefficient test is missing");
const auto& satt = satterthwaite.coefficient_tests.front(); const auto& satt = satterthwaite.coefficient_tests.front();
@@ -389,8 +416,11 @@ void test_residual_only_kkt_boundary() {
y, x, lambda, fitted, y, x, lambda, fitted,
spectra::reml::FixedEffectTestMethod::kenward_roger); spectra::reml::FixedEffectTestMethod::kenward_roger);
require(kr.status == require(kr.status ==
spectra::reml::FixedEffectInferenceStatus::boundary_conditional, spectra::reml::FixedEffectInferenceStatus::boundary_ols,
"KR boundary inference did not report conditional status"); "KR request at the boundary did not fall back to OLS");
require(kr.method ==
spectra::reml::FixedEffectTestMethod::ordinary_least_squares,
"boundary KR request did not report ordinary least squares");
require(kr.coefficient_tests.size() == 1, require(kr.coefficient_tests.size() == 1,
"KR coefficient test is missing"); "KR coefficient test is missing");
const auto& kr_test = kr.coefficient_tests.front(); const auto& kr_test = kr.coefficient_tests.front();
@@ -399,8 +429,8 @@ void test_residual_only_kkt_boundary() {
"KR did not recover OLS residual df"); "KR did not recover OLS residual df");
require_near(kr_test.standard_error, expected_se, 2e-11, require_near(kr_test.standard_error, expected_se, 2e-11,
"KR did not recover OLS standard error"); "KR did not recover OLS standard error");
require_near(kr_test.statistic, expected_t * expected_t, 2e-10, require_near(kr_test.statistic, expected_t, 2e-10,
"KR did not recover OLS F statistic"); "boundary fallback did not recover OLS t statistic");
} }
} // namespace } // namespace

View File

@@ -86,7 +86,7 @@ class SpectraRemlCliTests(unittest.TestCase):
"0\ttrait_a\tconverged\t2\t0\t0\t0\t1\t1\t0.5\t-1\t4\t8\t1e-8" "0\ttrait_a\tconverged\t2\t0\t0\t0\t1\t1\t0.5\t-1\t4\t8\t1e-8"
"\tsatterthwaite\tok\t0\t{}\t0\tnan\tnan\tnan\t\t\n" "\tsatterthwaite\tok\t0\t{}\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" "1\ttrait_b\tconverged_boundary\t3\t1\t2\t3\t0\t1\t0\t-2\t3\t6\t1e-9"
"\tsatterthwaite\tboundary_conditional\t2\t{}\t1\t8\t2\t0.2\t\t\n".format( "\tordinary-least-squares\tboundary_ols\t2\t{}\t1\t8\t2\t0.2\t\t\n".format(
first_contrast_offset, second_contrast_offset first_contrast_offset, second_contrast_offset
), ),
encoding="utf-8", encoding="utf-8",