Skip to content

Commit fe63974

Browse files
donellipietroAlePalumarcogalliani
authored
Add functional PLS models and stabilize shared smoothing solvers (#19)
## Changes Add functional PLS with regression, Mode A and symmetric-block deflation, finite-element and B-spline smoothing, fixed component-specific penalties, and GCV calibration. Expose scores, directions, loadings, reconstructions and regression coefficients, together with convergence diagnostics and GCV curves. Both fit overloads default to 1000 iterations and an objective tolerance of 1e-8. Separate direction and loading fitting, share component-state initialization, and store predictors with statistical units in rows. Symmetric-block fitting reuses directions as loadings and accepts an empty loading-penalty grid. Add regression coverage for coefficient predictions and repeated fits. The shared-model changes integrate stable's fPCA smoothing-parameter fix, restrict exact-SVD subspace initialization to the requested rank, and correct EDF probe caching and optional-storage initialization in the finite-element smoothers. The Formula parser separates covariates from pipe effects; internal callers use `covs()`. External callers using `Formula::rhs()` need to migrate to `covs()`. ## Core prerequisite Depends on fdaPDE/fdaPDE-core#42. The recorded core commit is `472f9bd374a63fbf851106e5c45f8870e31e8189`: stable plus the table-reader path correction, without the execution module. ## Validation - GCC 15, C++20 and Eigen, with assertions enabled: 10 registered tests pass across fPLS, fPCA, EDF and Formula. - EDF tests exercise all four affected solver paths, repeated calls, increasing the probe count to the number of locations, and shrinking the sample; resized results match freshly initialized models. - fPCA vector-grid tests exercise power, subspace and direct solvers with exact SVD. - Relative and absolute paths read the same known CSV with the core fix. - Supplementary historical suites: 26/29 pass. `gsr.test_03` also fails on the unmodified stable baseline; `de.test_01` and `de.test_02` lack the tracked `square_density` mesh data. These are unchanged validation limitations. - `git diff --check` passes. Stable's current head is included in the branch history. --------- Co-authored-by: AlePalu <alessandropalummo@gmail.com> Co-authored-by: Marco Galliani <105008364+marcogalliani@users.noreply.github.com>
1 parent d4020fa commit fe63974

24 files changed

Lines changed: 6769 additions & 82 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*.so
1010
test/fdapde_test
1111
test/.cache
12+
test/_deps/
1213
test/build/
1314
build/
1415
CMakeFiles/

‎fdaPDE/core‎

‎fdaPDE/functional.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

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

2829
// clang-format on
2930

‎fdaPDE/solvers.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@ namespace fdapde {
3030

3131
struct ls_solver { };
3232
struct de_solver { };
33+
enum class TraceMode { Exact, Hutchinson };
3334

3435
} // namespace fdapde
3536

3637
// least square solvers
3738
#include "src/solvers/fe_ls_elliptic.h"
3839
#include "src/solvers/fe_ls_separable.h"
3940
#include "src/solvers/fe_ls_parabolic.h"
41+
#include "src/solvers/bs_ls_elliptic.h"
4042

4143
// density estimation solvers
4244
#include "src/solvers/fe_de_elliptic.h"

‎fdaPDE/src/formula.h‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,42 @@
2020
namespace fdapde {
2121

2222
class Formula {
23+
// available tokens
24+
struct efx_token {
25+
private:
26+
std::string cov_;
27+
std::string efx_;
28+
public:
29+
efx_token(const std::string& cov, const std::string& efx) : cov_(cov), efx_(efx) { }
30+
// observers
31+
const std::string& cov() const { return cov_; }
32+
const std::string& efx() const { return efx_; }
33+
friend std::ostream& operator<<(std::ostream& os, const efx_token& m) {
34+
os << "(" << m.cov_ << ", " << m.efx_ << ")";
35+
return os;
36+
}
37+
};
38+
2339
std::string lhs_;
24-
std::vector<std::string> rhs_;
40+
std::vector<std::string> covs_;
41+
std::vector<efx_token> efxs_;
2542

2643
void throw_parse_error_(const std::string& msg) {
2744
throw std::runtime_error(std::string("Formula parse error: ") + msg);
2845
}
46+
void analyze_token_(std::string token) {
47+
size_t pos = token.find('|');
48+
if (pos != std::string::npos) {
49+
std::string cov_token, efx_token;
50+
cov_token = token.substr(0, pos);
51+
token.erase(0, pos + 1);
52+
efx_token = token;
53+
efxs_.emplace_back(cov_token, efx_token);
54+
} else {
55+
covs_.push_back(token);
56+
}
57+
return;
58+
}
2959
public:
3060
Formula() noexcept = default;
3161
Formula(const std::string& formula) {
@@ -37,20 +67,21 @@ class Formula {
3767
// rhs parsing logic
3868
std::string rhs = formula.substr(tilde + 1, formula.size() - tilde - 1);
3969
std::erase(rhs, ' ');
40-
if (rhs.empty()) { throw_parse_error_("no rhs found."); }
70+
if (rhs.empty()) { throw_parse_error_("no rhs found."); }
4171
size_t pos = rhs.find('+');
4272
std::string token;
4373
while (pos != std::string::npos) {
4474
token = rhs.substr(0, pos);
45-
rhs_.push_back(token);
75+
analyze_token_(token);
4676
rhs.erase(0, pos + 1);
47-
pos = rhs.find('+');
77+
pos = rhs.find('+');
4878
}
49-
rhs_.push_back(rhs);
79+
analyze_token_(rhs);
5080
}
5181
// observers
5282
const std::string& lhs() const { return lhs_; }
53-
const std::vector<std::string>& rhs() const { return rhs_; }
83+
const std::vector<std::string>& covs() const { return covs_; }
84+
const std::vector<efx_token>& efxs() const { return efxs_; }
5485
};
5586

5687
} // namespace fdapde

‎fdaPDE/src/models/fpca.h‎

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ namespace fdapde {
2525
[[maybe_unused]] constexpr int ComputeXactSVD = 0x0;
2626

2727
// bits 1 - 5 reserved to calibration strategies
28+
[[maybe_unused]] constexpr int NoCalibration = 0x0;
2829
[[maybe_unused]] constexpr int OptimizeGCV = 0x1 << 1;
2930
[[maybe_unused]] constexpr int OptimizeMSRE = 0x2 << 1;
3031

@@ -36,6 +37,14 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
3637
private:
3738
using vector_t = Eigen::Matrix<double, Dynamic, 1>;
3839
using matrix_t = Eigen::Matrix<double, Dynamic, Dynamic>;
40+
41+
struct fit_result {
42+
vector_t f;
43+
vector_t s;
44+
std::vector<double> objective_history;
45+
int iterations = 0;
46+
bool monotone = true;
47+
};
3948
public:
4049
using smoother_t = std::decay_t<VariationalSolver>;
4150
static constexpr int n_lambda = smoother_t::n_lambda;
@@ -64,11 +73,14 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
6473
s_.resize(n_units_, rank);
6574
f_norm_.resize(rank);
6675
lambda_.resize(rank, n_lambda);
76+
objective_history_.resize(rank);
77+
iterations_.assign(rank, 0);
78+
monotone_.assign(rank, true);
6779

6880
int calibration = (flag & 0b11110); // detect calibration strategy
6981
for (int i = 0; i < rank; ++i) {
7082
// select optimal smoothing level for i-th component
71-
std::array<double, n_lambda> opt_lambda;
83+
Eigen::Matrix<double, n_lambda, 1> opt_lambda;
7284
switch (calibration) {
7385
case 0: { // no calibration
7486
fdapde_assert(lambda_grid.size() == n_lambda);
@@ -87,12 +99,15 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
8799
}
88100
}
89101
// fit with optimal lambda
90-
const auto& [f, s] = solve_(X, opt_lambda, V.col(i));
102+
auto result = solve_(X, opt_lambda, V.col(i));
91103
for (int j = 0; j < n_lambda; ++j) { lambda_(i, j) = opt_lambda[j]; }
92104
// store results
93-
f_norm_[i] = std::sqrt(f.dot(smoother_->mass() * f));
94-
f_.col(i) = f / f_norm_[i];
95-
s_.col(i) = s * f_norm_[i];
105+
f_norm_[i] = std::sqrt(result.f.dot(smoother_->mass() * result.f));
106+
f_.col(i) = result.f / f_norm_[i];
107+
s_.col(i) = result.s * f_norm_[i];
108+
objective_history_[i] = std::move(result.objective_history);
109+
iterations_[i] = result.iterations;
110+
monotone_[i] = result.monotone;
96111
// deflate
97112
X = X - s_.col(i) * (smoother_->Psi() * f_.col(i)).transpose();
98113
}
@@ -104,6 +119,9 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
104119
const std::vector<double>& loadings_norm() const { return f_norm_; }
105120
const matrix_t& lambda() const { return lambda_; }
106121
const smoother_t* smoother() const { return smoother_; }
122+
const std::vector<std::vector<double>>& objective_history() const { return objective_history_; }
123+
const std::vector<int>& iterations() const { return iterations_; }
124+
const std::vector<bool>& monotone() const { return monotone_; }
107125
private:
108126
// finds vectors s, f minimizing \norm{X - s * f^\top}_F^2 + P_{\lambda}(f)
109127
template <typename LambdaT, typename InitT>
@@ -114,6 +132,8 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
114132
vector_t s(n_units_);
115133
double Jold = std::numeric_limits<double>::max(), Jnew = 1.0;
116134
int n_iter = 0;
135+
fit_result result;
136+
result.objective_history.reserve(max_iter_);
117137
while (!almost_equal(Jnew, Jold, tol_) && n_iter < max_iter_) {
118138
// s = X * fn / \norm(X * fn)
119139
s = X * fn;
@@ -126,22 +146,31 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
126146
fn = smoother_->Psi() * smoother_->f();
127147
Jold = Jnew;
128148
Jnew = (X - s * fn.transpose()).squaredNorm() + smoother_->ftPf(lambda);
149+
result.objective_history.push_back(Jnew);
150+
result.iterations = n_iter;
151+
if (!std::isfinite(Jnew) ||
152+
(n_iter > 1 && (Jnew - Jold) / (1.0 + std::abs(Jold)) > tol_)) {
153+
result.monotone = false;
154+
}
129155
}
130-
return std::make_pair(smoother_->f(), s);
156+
result.f = smoother_->f();
157+
result.s = std::move(s);
158+
return result;
131159
}
132160
// finds vectors s, f minimizing \norm{X - s * f^\top}_F^2 + P_{\lambda}(f) and returns the GCV index
133161
template <typename LambdaT, typename InitT>
134162
requires(internals::is_subscriptable<LambdaT, int>)
135163
double gcv_(const matrix_t& X, const LambdaT lambda, const InitT& f0) {
136-
const auto& [f, s] = solve_(X, lambda, f0);
164+
const auto result = solve_(X, lambda, f0);
137165
// evaluate GCV index at convergence
138-
std::array<double, n_lambda> lambda_;
139-
for (int i = 0; i < n_lambda; ++i) { lambda_[i] = lambda[i]; }
140-
if (edf_map_.find(lambda_) == edf_map_.end()) { // cache Tr[S]
141-
edf_map_[lambda_] = smoother_->edf();
166+
std::array<double, n_lambda> lambda_vec;
167+
for (int i = 0; i < n_lambda; ++i) { lambda_vec[i] = lambda[i]; }
168+
if (edf_map_.find(lambda_vec) == edf_map_.end()) { // cache Tr[S]
169+
edf_map_[lambda_vec] = smoother_->edf();
142170
}
143-
int dor = n_locs_ - edf_map_.at(lambda_);
144-
return (n_locs_ / std::pow(dor, 2)) * ((smoother_->Psi() * f) - smoother_->response()).squaredNorm();
171+
int dor = n_locs_ - edf_map_.at(lambda_vec);
172+
return (n_locs_ / std::pow(dor, 2)) *
173+
((smoother_->Psi() * result.f) - smoother_->response()).squaredNorm();
145174
}
146175
std::unordered_map<std::array<double, n_lambda>, double, internals::std_array_hash<double, n_lambda>> edf_map_;
147176
int n_locs_ = 0, n_units_ = 0, n_dofs_ = 0;
@@ -150,6 +179,9 @@ template <typename VariationalSolver> class fpca_power_iteration_impl {
150179
matrix_t s_; // PCs scores
151180
std::vector<double> f_norm_; // L^2 norm of estimated PCs
152181
matrix_t lambda_; // selected PCs smoothing level
182+
std::vector<std::vector<double>> objective_history_;
183+
std::vector<int> iterations_;
184+
std::vector<bool> monotone_;
153185

154186
// power iteration algorithm parameters
155187
double tol_ = 1e-6;
@@ -171,6 +203,7 @@ template <typename VariationalSolver> class fpca_subspace_iteration_impl {
171203
fpca_subspace_iteration_impl(VariationalSolver& smoother, int max_iter, double tol) noexcept :
172204
smoother_(std::addressof(smoother)), n_dofs_(smoother.n_dofs()), max_iter_(max_iter), tol_(tol) { }
173205

206+
/// @brief jointly estimates the requested components with fixed or GCV-selected penalties
174207
template <typename DataT> auto fit(const DataT& data, int rank, const std::vector<double>& lambda_grid, int flag) {
175208
fdapde_assert(lambda_grid.size() > 0 && lambda_grid.size() % n_lambda == 0);
176209
matrix_t X = data.transpose();
@@ -182,7 +215,7 @@ template <typename VariationalSolver> class fpca_subspace_iteration_impl {
182215
V = std::move(svd.matrixV());
183216
} else {
184217
Eigen::JacobiSVD<matrix_t> svd(X, Eigen::ComputeThinU | Eigen::ComputeThinV);
185-
V = std::move(svd.matrixV());
218+
V = svd.matrixV().leftCols(rank);
186219
}
187220
// allocate memory
188221
f_.resize(n_dofs_, rank);
@@ -191,7 +224,7 @@ template <typename VariationalSolver> class fpca_subspace_iteration_impl {
191224
lambda_.resize(rank, n_lambda);
192225

193226
int calibration = (flag & 0b11110); // detect calibration strategy
194-
std::array<double, n_lambda> opt_lambda;
227+
Eigen::Matrix<double, n_lambda, 1> opt_lambda;
195228
switch (calibration) {
196229
case 0: { // no calibration
197230
fdapde_assert(lambda_grid.size() == n_lambda);
@@ -306,7 +339,7 @@ template <typename VariationalSolver> class fpca_direct_impl {
306339
lambda_.resize(rank, n_lambda);
307340

308341
int calibration = (flag & 0b11110); // detect calibration strategy
309-
std::array<double, n_lambda> opt_lambda;
342+
Eigen::Matrix<double, n_lambda, 1> opt_lambda;
310343
switch (calibration) {
311344
case 0: { // no calibration
312345
fdapde_assert(lambda_grid.size() == n_lambda);
@@ -528,7 +561,7 @@ template <typename VariationalSolver> class fPCA {
528561
fPCA() noexcept = default;
529562
template <typename GeoFrame, typename Penalty>
530563
fPCA(const std::string& colname, const GeoFrame& gf, Penalty&& penalty) noexcept : smoother_(), data_() {
531-
discretize(penalty.get().penalty);
564+
discretize(penalty.get());
532565
analyze_data(colname, gf);
533566
}
534567
template <typename... Args> void discretize(Args&&... args) {
@@ -583,6 +616,19 @@ template <typename VariationalSolver> class fPCA {
583616
f_norm_ = solver_.loadings_norm();
584617
}
585618
lambda_ = solver_.lambda();
619+
if constexpr (requires {
620+
solver_.objective_history();
621+
solver_.iterations();
622+
solver_.monotone();
623+
}) {
624+
objective_history_ = solver_.objective_history();
625+
iterations_ = solver_.iterations();
626+
monotone_ = solver_.monotone();
627+
} else {
628+
objective_history_.assign(rank, {});
629+
iterations_.assign(rank, 0);
630+
monotone_.assign(rank, true);
631+
}
586632
return std::tie(f_, s_);
587633
}
588634
// observers
@@ -591,6 +637,9 @@ template <typename VariationalSolver> class fPCA {
591637
matrix_t Fn() const { return smoother_.Psi() * f_; }
592638
const std::vector<double>& loadings_norm() const { return f_norm_; }
593639
const matrix_t& lambda() const { return lambda_; }
640+
const std::vector<std::vector<double>>& objective_history() const { return objective_history_; }
641+
const std::vector<int>& iterations() const { return iterations_; }
642+
const std::vector<bool>& monotone() const { return monotone_; }
594643
private:
595644
matrix_t data_; // mapped geoframe data
596645
smoother_t smoother_; // variational solver used in the smoothing step
@@ -601,6 +650,9 @@ template <typename VariationalSolver> class fPCA {
601650
matrix_t s_; // PCs scores
602651
std::vector<double> f_norm_; // L^2 norm of estimated components
603652
matrix_t lambda_; // selected level of smoothing for each component
653+
std::vector<std::vector<double>> objective_history_;
654+
std::vector<int> iterations_;
655+
std::vector<bool> monotone_;
604656
};
605657

606658
// deduction guide

0 commit comments

Comments
 (0)