|
| 1 | +// This file is part of fdaPDE, a C++ library for physics-informed |
| 2 | +// spatial and functional data analysis. |
| 3 | +// |
| 4 | +// This program is free software: you can redistribute it and/or modify |
| 5 | +// it under the terms of the GNU General Public License as published by |
| 6 | +// the Free Software Foundation, either version 3 of the License, or |
| 7 | +// (at your option) any later version. |
| 8 | +// |
| 9 | +// This program is distributed in the hope that it will be useful, |
| 10 | +// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | +// GNU General Public License for more details. |
| 13 | +// |
| 14 | +// You should have received a copy of the GNU General Public License |
| 15 | +// along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 16 | + |
| 17 | +#ifndef __FPCR_H__ |
| 18 | +#define __FPCR_H__ |
| 19 | + |
| 20 | +#include "header_check.h" |
| 21 | + |
| 22 | +namespace fdapde { |
| 23 | + |
| 24 | +/// @brief regresses centered scalar or multivariate responses on sequential smooth principal component scores |
| 25 | +/// predictor data is bound through GeoFrame with locations in rows and statistical units in columns |
| 26 | +/// each component prefix refits OLS; beta coefficients map the original centered predictors after sequential deflation |
| 27 | +/// supported bindings are single-parameter elliptic finite-element and spline smoothers |
| 28 | +/// calibration uses the native fPCA power solver with fixed penalties or explicitly controlled stochastic EDF estimates |
| 29 | +/// |
| 30 | +/// @code |
| 31 | +/// fPCR model("X", Y, data, fe_ls_elliptic(penalty, load)); |
| 32 | +/// model.fit(3, std::vector<double> {0.01, 0.1, 1.0}, ComputeXactSVD | OptimizeGCV, 100, 1e-8, 100, 42); |
| 33 | +/// auto fitted = model.fitted(2); |
| 34 | +/// auto beta = model.Beta(2); |
| 35 | +/// @endcode |
| 36 | +template <typename VariationalSolver> class fPCR { |
| 37 | + private: |
| 38 | + using smoother_t = std::decay_t<VariationalSolver>; |
| 39 | + using matrix_t = Eigen::Matrix<double, Dynamic, Dynamic>; |
| 40 | + static constexpr int n_lambda = smoother_t::n_lambda; |
| 41 | + static_assert(n_lambda == 1, "fPCR requires a single-parameter elliptic finite-element or spline smoother"); |
| 42 | + |
| 43 | + fPCA<smoother_t> fpca_; |
| 44 | + matrix_t X_, Y_; |
| 45 | + matrix_t scores_, directions_, loading_directions_, projection_scales_, sampled_directions_; |
| 46 | + std::vector<double> projection_errors_; |
| 47 | + std::vector<matrix_t> score_coefficients_, beta_; |
| 48 | + int n_comp_ = 0; |
| 49 | + public: |
| 50 | + /// @brief creates an uninitialized model without discretized penalties or data |
| 51 | + fPCR() noexcept = default; |
| 52 | + |
| 53 | + /// @brief discretizes the principal component penalty and binds centered predictors and responses |
| 54 | + template <typename GeoFrame, typename Penalty> |
| 55 | + fPCR(const std::string& colname, const matrix_t& Y, const GeoFrame& gf, Penalty&& penalty) { |
| 56 | + fpca_.discretize(penalty.get()); |
| 57 | + analyze_data(colname, Y, gf); |
| 58 | + } |
| 59 | + |
| 60 | + /// @brief discretizes the principal component smoothing penalty |
| 61 | + template <typename... Args> void discretize(Args&&... args) { fpca_.discretize(std::forward<Args>(args)...); } |
| 62 | + |
| 63 | + /// @brief binds finite centered predictors and all response columns to the discretized model |
| 64 | + template <typename GeoFrame> void analyze_data(const std::string& colname, const matrix_t& Y, const GeoFrame& gf) { |
| 65 | + if (gf.n_layers() != 1) throw std::invalid_argument("fPCR requires a single functional data layer"); |
| 66 | + matrix_t X = gf[0].data().template col<double>(colname).as_matrix().transpose(); |
| 67 | + if ( |
| 68 | + X.rows() == 0 || X.cols() == 0 || Y.rows() != X.rows() || Y.cols() == 0 || !X.allFinite() || !Y.allFinite()) { |
| 69 | + throw std::invalid_argument("fPCR requires finite predictor and response matrices with matching units"); |
| 70 | + } |
| 71 | + fpca_.analyze_data(colname, gf); |
| 72 | + X_ = std::move(X); |
| 73 | + Y_ = Y; |
| 74 | + clear_(); |
| 75 | + } |
| 76 | + |
| 77 | + /// @brief fits native smooth principal components, sequential score projections and OLS for every prefix |
| 78 | + /// fixed calibration requires one shared penalty tuple; GCV candidates are consecutive tuples of solver width |
| 79 | + void fit( |
| 80 | + int n_comp, // number of sequential principal components |
| 81 | + const std::vector<double>& lambda_grid, // shared fixed penalty tuple or consecutive GCV candidate tuples |
| 82 | + int flag = ComputeXactSVD, // SVD initialization and NoCalibration or OptimizeGCV option bits |
| 83 | + int max_iter = 20, // maximum power updates per candidate or retained component |
| 84 | + double tol = 1e-6, // relative objective stopping tolerance |
| 85 | + int edf_r = 100, // number of Hutchinson probes for GCV effective degrees of freedom |
| 86 | + int seed = random_seed // seed shared across the fit's cached EDF estimates |
| 87 | + ) { |
| 88 | + clear_(); |
| 89 | + const int calibration = flag & 0b11110; |
| 90 | + if ( |
| 91 | + n_comp < 1 || n_comp > std::min(X_.rows(), X_.cols()) || max_iter < 1 || !std::isfinite(tol) || tol <= 0 || |
| 92 | + edf_r < 1 || lambda_grid.empty() || lambda_grid.size() % n_lambda != 0 || |
| 93 | + (calibration != NoCalibration && calibration != OptimizeGCV) || |
| 94 | + (calibration == NoCalibration && lambda_grid.size() != n_lambda)) { |
| 95 | + throw std::invalid_argument("invalid fPCR component count, solver controls or calibration grid"); |
| 96 | + } |
| 97 | + |
| 98 | + // reuse the native power solver's component fits and calibration diagnostics |
| 99 | + fpca_.fit(n_comp, lambda_grid, flag, fpca_power_solver(max_iter, tol), edf_r, seed); |
| 100 | + const matrix_t sampled_loadings = fpca_.Fn(); |
| 101 | + scores_.resize(X_.rows(), n_comp); |
| 102 | + directions_.resize(fpca_.F().rows(), n_comp); |
| 103 | + loading_directions_.resize(fpca_.F().rows(), n_comp); |
| 104 | + sampled_directions_.resize(X_.cols(), n_comp); |
| 105 | + projection_scales_.resize(n_comp, 1); |
| 106 | + projection_errors_.resize(n_comp); |
| 107 | + score_coefficients_.resize(n_comp); |
| 108 | + beta_.resize(n_comp); |
| 109 | + matrix_t residual = X_; |
| 110 | + |
| 111 | + // project final loadings on successive residuals and undo previous deflations in the original-X score map |
| 112 | + for (int h = 0; h < n_comp; ++h) { |
| 113 | + const matrix_t raw_score = residual * sampled_loadings.col(h); |
| 114 | + const double projected_norm = raw_score.norm(); |
| 115 | + const double fitted_norm = fpca_.S().col(h).norm(); |
| 116 | + if ( |
| 117 | + !std::isfinite(projected_norm) || projected_norm <= 0 || !std::isfinite(fitted_norm) || |
| 118 | + fitted_norm <= 0) { |
| 119 | + throw std::runtime_error("fPCR cannot project a zero or non-finite component"); |
| 120 | + } |
| 121 | + const double scale = fitted_norm / projected_norm; |
| 122 | + projection_scales_(h, 0) = scale; |
| 123 | + loading_directions_.col(h) = scale * fpca_.F().col(h); |
| 124 | + directions_.col(h) = loading_directions_.col(h); |
| 125 | + const matrix_t coupling = sampled_loadings.leftCols(h).transpose() * (scale * sampled_loadings.col(h)); |
| 126 | + directions_.col(h) -= directions_.leftCols(h) * coupling; |
| 127 | + sampled_directions_.col(h) = scale * sampled_loadings.col(h); |
| 128 | + sampled_directions_.col(h) -= sampled_directions_.leftCols(h) * coupling; |
| 129 | + scores_.col(h) = scale * raw_score; |
| 130 | + projection_errors_[h] = (scores_.col(h) - fpca_.S().col(h)).cwiseAbs().maxCoeff(); |
| 131 | + residual.noalias() -= scores_.col(h) * sampled_loadings.col(h).transpose(); |
| 132 | + } |
| 133 | + if (!scores_.allFinite() || !directions_.allFinite()) { |
| 134 | + throw std::runtime_error("fPCR score projection or direction is non-finite"); |
| 135 | + } |
| 136 | + |
| 137 | + // refit OLS on each prefix because sequential smooth principal component scores need not be orthogonal |
| 138 | + for (int h = 1; h <= n_comp; ++h) { |
| 139 | + score_coefficients_[h - 1] = scores_.leftCols(h).colPivHouseholderQr().solve(Y_); |
| 140 | + beta_[h - 1] = directions_.leftCols(h) * score_coefficients_[h - 1]; |
| 141 | + if (!score_coefficients_[h - 1].allFinite() || !beta_[h - 1].allFinite()) { |
| 142 | + throw std::runtime_error("fPCR score regression produced non-finite coefficients"); |
| 143 | + } |
| 144 | + } |
| 145 | + n_comp_ = n_comp; |
| 146 | + } |
| 147 | + |
| 148 | + /// @brief returns functional L2-normalized principal component loading coefficients |
| 149 | + const matrix_t& X_loadings() const { return fpca_.F(); } |
| 150 | + /// @brief returns principal component loading coefficients through the native fPCA accessor |
| 151 | + const matrix_t& F() const { return fpca_.F(); } |
| 152 | + /// @brief evaluates loading coefficients at the training observation locations |
| 153 | + matrix_t Fn() const { return fpca_.Fn(); } |
| 154 | + /// @brief returns native power-iteration scores before final-loading sequential projection |
| 155 | + const matrix_t& S() const { return fpca_.S(); } |
| 156 | + /// @brief returns sequential final-loading scores with subjects in rows and components in columns |
| 157 | + const matrix_t& X_latent_scores() const { return scores_; } |
| 158 | + /// @brief returns sequential scores through the fPLS-compatible latent accessor |
| 159 | + const matrix_t& X_latent() const { return scores_; } |
| 160 | + /// @brief returns coefficient-space directions mapping the original predictors to sequential scores |
| 161 | + const matrix_t& X_space_directions() const { return directions_; } |
| 162 | + /// @brief returns scaled final loadings used for score projection on each component's residual |
| 163 | + const matrix_t& loading_directions() const { return loading_directions_; } |
| 164 | + /// @brief returns the score rescaling factor per component as a single-column matrix |
| 165 | + const matrix_t& projection_scales() const { return projection_scales_; } |
| 166 | + /// @brief returns maximum absolute differences between projected and native fitted scores per component |
| 167 | + const std::vector<double>& projection_errors() const { return projection_errors_; } |
| 168 | + /// @brief returns the separately fitted OLS coefficients for a prefix, with one column per response |
| 169 | + const matrix_t& score_coefficients(int h = 0) const { return score_coefficients_[components_(h) - 1]; } |
| 170 | + /// @brief returns response loadings of the full score regression, with response variables in rows |
| 171 | + matrix_t Y_loadings() const { return score_coefficients().transpose(); } |
| 172 | + /// @brief returns original-X functional regression coefficients for a prefix, with one column per response |
| 173 | + const matrix_t& B(int h = 0) const { return beta_[components_(h) - 1]; } |
| 174 | + /// @brief returns original-X regression coefficients through the fPLS-compatible accessor |
| 175 | + const matrix_t& Beta(int h = 0) const { return B(h); } |
| 176 | + /// @brief returns training response predictions for a prefix using its own OLS fit |
| 177 | + matrix_t fitted(int h = 0) const { |
| 178 | + h = components_(h); |
| 179 | + return scores_.leftCols(h) * score_coefficients_[h - 1]; |
| 180 | + } |
| 181 | + /// @brief projects finite centered predictors at the training locations onto sequential scores for a prefix |
| 182 | + matrix_t transform(const matrix_t& X, int h = 0) const { |
| 183 | + h = components_(h); |
| 184 | + if (X.cols() != X_.cols() || !X.allFinite()) { |
| 185 | + throw std::invalid_argument("fPCR projection requires finite predictors at the training locations"); |
| 186 | + } |
| 187 | + return X * sampled_directions_.leftCols(h); |
| 188 | + } |
| 189 | + /// @brief predicts every response column from centered new predictors using a separately fitted prefix |
| 190 | + matrix_t predict(const matrix_t& X, int h = 0) const { |
| 191 | + h = components_(h); |
| 192 | + return transform(X, h) * score_coefficients_[h - 1]; |
| 193 | + } |
| 194 | + /// @brief reconstructs training predictors at the observation locations from a component prefix |
| 195 | + matrix_t reconstructed(int h = 0) const { |
| 196 | + h = components_(h); |
| 197 | + return scores_.leftCols(h) * fpca_.Fn().leftCols(h).transpose(); |
| 198 | + } |
| 199 | + /// @brief returns the functional norms transferred to native principal component scores |
| 200 | + const std::vector<double>& loadings_norm() const { return fpca_.loadings_norm(); } |
| 201 | + /// @brief returns the selected penalty tuple per component |
| 202 | + const matrix_t& lambda() const { return fpca_.lambda(); } |
| 203 | + /// @brief returns actual GCV evaluations per component in candidate order; empty after fixed calibration |
| 204 | + const std::vector<std::vector<double>>& gcv_values() const { return fpca_.gcv_values(); } |
| 205 | + /// @brief returns the EDF estimates used by the actual GCV evaluations; empty after fixed calibration |
| 206 | + const std::vector<std::vector<double>>& gcv_edf() const { return fpca_.gcv_edf(); } |
| 207 | + /// @brief returns candidate penalty tuples in evaluation order; empty after fixed calibration |
| 208 | + const matrix_t& lambda_grid() const { return fpca_.lambda_grid(); } |
| 209 | + /// @brief returns the zero-based first minimizing candidate per component; empty after fixed calibration |
| 210 | + const std::vector<int>& selected_indices() const { return fpca_.selected_indices(); } |
| 211 | + /// @brief returns retained principal component objective traces |
| 212 | + const std::vector<std::vector<double>>& objective_history() const { return fpca_.objective_history(); } |
| 213 | + /// @brief returns completed power updates per retained principal component |
| 214 | + const std::vector<int>& iterations() const { return fpca_.iterations(); } |
| 215 | + /// @brief reports whether each principal component objective avoided relative increases beyond tolerance |
| 216 | + const std::vector<bool>& monotone() const { return fpca_.monotone(); } |
| 217 | + private: |
| 218 | + /// @brief clears the regression state before rebinding observations or starting a new fit |
| 219 | + void clear_() { |
| 220 | + n_comp_ = 0; |
| 221 | + scores_.resize(0, 0); |
| 222 | + directions_.resize(0, 0); |
| 223 | + loading_directions_.resize(0, 0); |
| 224 | + sampled_directions_.resize(0, 0); |
| 225 | + projection_scales_.resize(0, 0); |
| 226 | + projection_errors_.clear(); |
| 227 | + score_coefficients_.clear(); |
| 228 | + beta_.clear(); |
| 229 | + } |
| 230 | + |
| 231 | + /// @brief resolves zero to all fitted components and validates a requested prefix |
| 232 | + int components_(int h) const { |
| 233 | + if (h == 0) h = n_comp_; |
| 234 | + if (h < 1 || h > n_comp_) throw std::out_of_range("fPCR component prefix is outside the fitted model"); |
| 235 | + return h; |
| 236 | + } |
| 237 | +}; |
| 238 | + |
| 239 | +/// @brief deduces the principal component smoother from its variational penalty packet |
| 240 | +template <typename GeoFrame, typename Penalty> |
| 241 | +fPCR( |
| 242 | + const std::string& colname, const Eigen::Matrix<double, Dynamic, Dynamic>& Y, const GeoFrame& gf, Penalty&& penalty) |
| 243 | + -> fPCR<typename std::decay_t<Penalty>::solver_t>; |
| 244 | + |
| 245 | +} // namespace fdapde |
| 246 | + |
| 247 | +#endif // __FPCR_H__ |
0 commit comments