Skip to content

Commit 69c9fb2

Browse files
authored
Merge pull request #2496 from SCIInstitute/amorris/2495-ppca
Fix #2495 - Fix PPCA noise variance dilution and null-space leakage in MorphologicalDeviationScore
2 parents ec6ad42 + 90142b6 commit 69c9fb2

File tree

2 files changed

+59
-104
lines changed

2 files changed

+59
-104
lines changed
Lines changed: 52 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "MorphologicalDeviationScore.h"
2+
23
#include <Logging.h>
34
namespace shapeworks {
45

@@ -8,11 +9,9 @@ MorphologicalDeviationScore::MorphologicalDeviationScore() = default;
89
bool MorphologicalDeviationScore::SetControlShapes(const Eigen::MatrixXd& X) {
910
try {
1011
bool ppca_status = FitPPCA(X);
11-
precision_matrix_ = ComputePrecisionMatrix();
1212
is_fitted_ = ppca_status;
1313
} catch (std::exception& e) {
14-
SW_ERROR("Exception in setting control shapes for early stopping {}",
15-
e.what());
14+
SW_ERROR("Exception in setting control shapes for early stopping {}", e.what());
1615
return false;
1716
}
1817
return is_fitted_;
@@ -27,108 +26,79 @@ bool MorphologicalDeviationScore::FitPPCA(const Eigen::MatrixXd& X) {
2726
Eigen::MatrixXd X_c = X.rowwise() - mean_; // (n x d)
2827

2928
try {
30-
Eigen::JacobiSVD<Eigen::MatrixXd> svd(
31-
X_c, Eigen::ComputeThinU | Eigen::ComputeThinV);
29+
Eigen::JacobiSVD<Eigen::MatrixXd> svd(X_c, Eigen::ComputeThinU | Eigen::ComputeThinV);
3230
Eigen::VectorXd s = svd.singularValues(); // (r,)
3331
Eigen::MatrixXd V = svd.matrixV(); // (d x r)
3432
Eigen::VectorXd eigvals = (s.array().square()) / (n - 1);
3533

36-
// save_matrix(X, std::string(DEBUG_FILES_PTH) + "/X.npy");
37-
// save_matrix(X_c, std::string(DEBUG_FILES_PTH) + "/X_c.npy");
38-
// save_matrix(V, std::string(DEBUG_FILES_PTH) + "/V.npy");
39-
// save_vector(eigvals, std::string(DEBUG_FILES_PTH) + "/eigvals.npy");
40-
4134
Eigen::VectorXd cumsum = eigvals;
4235
for (int i = 1; i < eigvals.size(); ++i) cumsum[i] += cumsum[i - 1];
4336
Eigen::VectorXd cumvar = cumsum / eigvals.sum();
44-
n_components_ = 0;
45-
while (n_components_ < cumvar.size() && cumvar[n_components_] <= retained_variance_ratio_)
46-
++n_components_;
47-
int q = n_components_;
48-
principal_components_variance_ = eigvals.head(q); // (q,)
49-
components_ = V.leftCols(q); // (d x q)
50-
// save_matrix(components_, std::string(DEBUG_FILES_PTH) + "/components.npy");
51-
// save_vector(principal_components_variance_,
52-
// std::string(DEBUG_FILES_PTH) + "/eigvals.npy");
53-
noise_variance_ = eigvals.tail(eigvals.size() - q).sum() / double(d - q);
54-
return true;
37+
int q = 0;
38+
while (q < cumvar.size() && cumvar[q] <= retained_variance_ratio_) ++q;
5539

56-
} catch (std::exception& e) {
57-
SW_ERROR(
58-
"SVD computation failed for MorphologicalDeviationScore: {}",
59-
e.what());
60-
return false;
61-
}
62-
}
40+
if (q == 0) {
41+
SW_ERROR("PPCA: no components retained at {}% variance", retained_variance_ratio_ * 100);
42+
return false;
43+
}
6344

64-
//---------------------------------------------------------------------------
65-
Eigen::MatrixXd MorphologicalDeviationScore::ComputeCovarianceMatrix() {
66-
const int d = components_.rows();
67-
const int q = components_.cols();
45+
// Determine effective rank: trim structurally zero eigenvalues
46+
const double eps = 1e-10;
47+
int rank = eigvals.size();
48+
while (rank > q && eigvals[rank - 1] < eps) {
49+
--rank;
50+
}
6851

69-
if (q == 0) {
70-
return noise_variance_ * Eigen::MatrixXd::Identity(d, d);
71-
}
52+
if (rank <= q) {
53+
SW_ERROR("PPCA: effective rank ({}) <= retained components ({}), cannot estimate noise variance", rank, q);
54+
return false;
55+
}
7256

73-
Eigen::MatrixXd diag_lambda = principal_components_variance_.asDiagonal(); // (q x q)
74-
Eigen::MatrixXd cov =
75-
components_ *
76-
(diag_lambda - noise_variance_ * Eigen::MatrixXd::Identity(q, q)) *
77-
components_.transpose() +
78-
noise_variance_ * Eigen::MatrixXd::Identity(d, d); // (d x d)
79-
return cov;
80-
}
57+
// Noise variance: average of excluded-but-real eigenvalues
58+
// Uses (rank - q) denominator instead of (d - q) to avoid dilution
59+
// by structurally zero dimensions beyond the data rank
60+
noise_variance_ = eigvals.segment(q, rank - q).sum() / double(rank - q);
8161

82-
//---------------------------------------------------------------------------
83-
Eigen::MatrixXd MorphologicalDeviationScore::ComputePrecisionMatrix() {
84-
try {
85-
const int d = components_.rows();
86-
const int q = components_.cols();
62+
// Store full rank-dimensional basis for projection-based scoring
63+
all_components_ = V.leftCols(rank); // (d x rank)
8764

88-
Eigen::MatrixXd A_inv = (1 / noise_variance_) * Eigen::MatrixXd::Identity(d, d);
65+
// Build per-dimension precision weights
66+
precision_weights_.resize(rank);
67+
for (int i = 0; i < q; ++i) {
68+
precision_weights_[i] = 1.0 / eigvals[i];
69+
}
70+
for (int i = q; i < rank; ++i) {
71+
precision_weights_[i] = 1.0 / noise_variance_;
72+
}
8973

90-
Eigen::MatrixXd U = components_;
91-
Eigen::MatrixXd C_inv = principal_components_variance_.cwiseInverse().asDiagonal();
92-
Eigen::MatrixXd V = components_.transpose();
93-
// Woodbury matrix identity
94-
// (A + U C V)^(-1)
95-
// = A^(-1)
96-
// - A^(-1) * U * (C^(-1) + V * A^(-1) * U)^(-1) * V * A^(-1)
74+
SW_LOG("PPCA fit: n={}, d={}, rank={}, q={}, noise_var={:.6f}", n, d, rank, q, noise_variance_);
9775

98-
Eigen::MatrixXd precision = A_inv - A_inv * U * (C_inv + V * A_inv * U).inverse() * V * A_inv;
99-
// save_matrix(precision, std::string(DEBUG_FILES_PTH)+"/precision.npy");
76+
return true;
10077

101-
return precision;
10278
} catch (std::exception& e) {
103-
SW_ERROR(
104-
"Failed to compute precision matrix in the early stopping score function: {}",
105-
e.what());
106-
return Eigen::MatrixXd();
79+
SW_ERROR("SVD computation failed for MorphologicalDeviationScore: {}", e.what());
80+
return false;
10781
}
10882
}
10983

11084
//---------------------------------------------------------------------------
111-
Eigen::VectorXd MorphologicalDeviationScore::GetMorphoDevScore(
112-
const Eigen::MatrixXd& X) {
85+
Eigen::VectorXd MorphologicalDeviationScore::GetMorphoDevScore(const Eigen::MatrixXd& X) {
11386
try {
114-
11587
if (!is_fitted_) {
116-
throw std::runtime_error(
117-
"PPCA model is not fitted on control shapes.");
88+
throw std::runtime_error("PPCA model is not fitted on control shapes.");
11889
}
119-
const int n = X.rows();
120-
// Eigen::MatrixXd X_c = X.rowwise() - mean_;
121-
// Eigen::VectorXd dist(n);
122-
// for (int i = 0; i < n; ++i) {
123-
// Eigen::RowVectorXd xi = X_c.row(i);
124-
// dist(i) = std::sqrt(
125-
// std::max(0.0, (xi * precision_matrix_ * xi.transpose())(0)));
126-
// }
127-
128-
Eigen::MatrixXd X_bar = X.rowwise() - mean_;
129-
Eigen::MatrixXd Y = X_bar * precision_matrix_; // (n_samples × n_features)
130-
Eigen::VectorXd sq_mahal = (X_bar.array() * Y.array()).rowwise().sum(); // quadratic forms
131-
Eigen::VectorXd mahalanobis = sq_mahal.array().sqrt(); // final distances
90+
91+
// Project into the rank-dimensional subspace, eliminating
92+
// structurally zero dimensions that cannot be estimated from data
93+
Eigen::MatrixXd X_bar = X.rowwise() - mean_; // (n x d)
94+
Eigen::MatrixXd Z = X_bar * all_components_; // (n x rank)
95+
96+
// Apply per-dimension precision weights: 1/lambda_i for retained PCs,
97+
// 1/noise_variance for excluded-but-real PCs
98+
Eigen::MatrixXd Z_weighted = Z.array().rowwise() * precision_weights_.transpose().array(); // (n x rank)
99+
100+
Eigen::VectorXd sq_mahal = (Z.array() * Z_weighted.array()).rowwise().sum(); // (n,)
101+
Eigen::VectorXd mahalanobis = sq_mahal.array().max(0.0).sqrt();
132102
return mahalanobis;
133103

134104
} catch (std::exception& e) {
@@ -140,4 +110,4 @@ Eigen::VectorXd MorphologicalDeviationScore::GetMorphoDevScore(
140110
}
141111
}
142112

143-
} // namespace shapeworks
113+
} // namespace shapeworks
Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#pragma once
22

33
#include <Eigen/Dense>
4-
// #include "cnpy.h"
54

65
namespace shapeworks {
76
class MorphologicalDeviationScore {
@@ -18,28 +17,14 @@ class MorphologicalDeviationScore {
1817
bool is_fitted_ = false;
1918

2019
// Fitted model parameters
21-
Eigen::RowVectorXd mean_; // (1 x d)
22-
Eigen::MatrixXd components_; // (d x q)
23-
Eigen::VectorXd principal_components_variance_; // (q,)
24-
int n_components_ = 0;
20+
Eigen::RowVectorXd mean_; // (1 x d)
2521
double noise_variance_ = 0.0;
2622
double retained_variance_ratio_ = 0.95;
27-
// Derived matrices
28-
Eigen::MatrixXd precision_matrix_; // (d x d)
29-
// Helper functions
23+
24+
// Full-rank basis and per-dimension precision weights for scoring
25+
Eigen::MatrixXd all_components_; // (d x rank) — all non-zero eigenvectors
26+
Eigen::VectorXd precision_weights_; // (rank,) — 1/lambda_i or 1/noise_variance
27+
3028
bool FitPPCA(const Eigen::MatrixXd& X);
31-
Eigen::MatrixXd ComputeCovarianceMatrix();
32-
Eigen::MatrixXd ComputePrecisionMatrix();
33-
// inline static void save_vector(const Eigen::VectorXd& v,
34-
// const std::string& fname) {
35-
// cnpy::npy_save(fname, v.data(), {(size_t)v.size()}, "w");
36-
// };
37-
// inline static void save_matrix(const Eigen::MatrixXd m,
38-
// const std::string& fname) {
39-
// Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic, Eigen::RowMajor>
40-
// X_rm = m;
41-
// cnpy::npy_save(fname, X_rm.data(), {(size_t)m.rows(), (size_t)m.cols()},
42-
// "w");
43-
// };
4429
};
45-
} // namespace shapeworks
30+
} // namespace shapeworks

0 commit comments

Comments
 (0)