Skip to content

Commit 0f4d80d

Browse files
Add functional PCR and preserve fit-time calibration diagnostics (#20)
Functional regression callers currently need their own PCR projection and coefficient mapping, and cannot retrieve the EDF estimates and candidate indices actually used during calibration. Add public fPCR for centered scalar or multivariate responses, using native smooth fPCA, sequential score projection, and OLS refitted for each component prefix. Expose fitted values, prediction, reconstruction, and regression coefficients that map the original centered predictors. Save the actual fPLS and power-fPCA GCV/EDF evaluations, candidate grids, and selected indices during fitting. Accessing diagnostics performs no additional fit or trace estimate, and repeated fits replace prior snapshots. Record fixed fPLS penalties as well. Honor explicit EDF probe counts and seeds, refresh elliptic FEM and spline probe caches when either changes, and retain floating-point residual degrees of freedom in power-fPCA GCV. This can change selections relative to the prior truncated denominator or callers using default random probes. Unsupported explicit EDF policies are rejected rather than silently ignored. Derive areal finite-element basis dimensions from types so the constexpr size does not depend on a runtime object. Include fPCR regression references and registered checks for calibration snapshots, prediction mappings, repeated fits, EDF cache refresh, and analytic areal averages.
1 parent fe63974 commit 0f4d80d

23 files changed

Lines changed: 447859 additions & 114 deletions

‎README.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,38 @@ It is built on top of the [fdaPDE Core Library](https://github.com/fdaPDE/fdaPDE
1212

1313
## Documentation
1414
Documentation can be found on our [documentation site](https://fdapde.github.io/)
15+
16+
## Functional regression models
17+
18+
Include `<fdaPDE/models.h>` to use `fPLS` or `fPCR`. Both take centered functional
19+
predictors through a single GeoFrame layer and centered responses with subjects in
20+
rows; scalar and multivariate responses retain every response column.
21+
22+
`fPLS` saves direction and loading GCV curves, their actual EDF estimates, candidate
23+
tuples and zero-based selected candidate indices during calibration. The public
24+
`direction_gcv_edfs()`, `loading_gcv_edfs()`, `direction_lambda_grid()`,
25+
`loading_lambda_grid()`, `direction_selected_indices()` and
26+
`loading_selected_indices()` accessors perform no solves or EDF estimates. Fixed
27+
fits clear the calibration snapshots and record the fixed penalties; each repeated
28+
fit replaces the previous snapshot.
29+
30+
`fPCR("X", Y, data, penalty)` reuses native power-iteration fPCA. Its `fit` takes a
31+
component count, fixed penalty tuple or GCV candidate tuples, calibration/SVD flags,
32+
iteration limit, tolerance, EDF probe count and seed. `X_latent_scores()` projects
33+
final loadings sequentially; `score_coefficients(h)` refits OLS for every prefix.
34+
`fitted(h)`, `predict(Xnew, h)`, `reconstructed(h)` and `Beta(h)` expose prefix
35+
results. Beta maps the original centered predictors through the functional basis,
36+
including all preceding deflations: `X * Psi * Beta(h) == fitted(h)`. Prefix zero
37+
selects all fitted components. `gcv_values()`, `gcv_edf()`, `lambda_grid()` and
38+
`selected_indices()` expose the evaluations used by its fPCA calibration.
39+
The supported bindings are single-parameter elliptic FEM and spline smoothers.
40+
41+
Power-solver GCV now uses floating-point residual degrees of freedom (`m - edf`)
42+
rather than truncating them to an integer. Explicit seeds and probe counts are
43+
honored on repeated elliptic fits. These corrections can change GCV selections
44+
relative to callers that previously used default random probes. Explicit EDF
45+
controls are supported by the fPCA power policy; other policies reject nondefault
46+
controls; the missing-data path also rejects nondefault EDF controls rather than
47+
ignoring them. This branch's pinned core still provides the legacy one-argument assertion
48+
macro, so new permanent public validation uses exceptions pending the core assertion
49+
API migration.

‎fdaPDE/functional.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424

2525
#include "solvers.h"
2626
#include "src/models/fpca.h"
27+
#include "src/models/fpcr.h"
2728
#include "src/models/fpls.h"
2829

2930
// clang-format on
3031

31-
#endif // __FDAPDE_FUNCTIONAL_H__
32+
#endif // __FDAPDE_FUNCTIONAL_H__

‎fdaPDE/src/models/fpca.h‎

Lines changed: 159 additions & 33 deletions
Large diffs are not rendered by default.

‎fdaPDE/src/models/fpcr.h‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)