Skip to content

Commit 6491dc8

Browse files
authored
Merge pull request #171 from PLN-team/optim-plnpca
Optim plnpca - explore block Newton with preconditionning and trust-region
2 parents 7ca4090 + e04d117 commit 6491dc8

23 files changed

Lines changed: 769 additions & 307 deletions

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Suggests:
5454
pheatmap,
5555
rmarkdown,
5656
spelling,
57+
quarto,
5758
testthat
5859
LinkingTo:
5960
nloptr,

DEVLOG_2026-07-02-03.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Journal de développement — 2-3 juillet 2026
2+
3+
Branche : `optim-plnpca`
4+
5+
## Contexte
6+
7+
Exploration de méthodes d'accélération pour les procédures d'optimisation de PLNmodels,
8+
puis développement d'un nouvel optimiseur pour PLNPCA. Deux volets :
9+
1. Évaluation de SQUAREM (accélération de point-fixe EM) sur ZIPLN et PLNmixture.
10+
2. Conception, implémentation et intégration d'un **Newton profilé à région de confiance**
11+
pour PLNPCA, qui remplace l'ancien backend `builtin`.
12+
13+
---
14+
15+
## 1. Évaluation SQUAREM (ZIPLN, PLNmixture) — négatif
16+
17+
SQUAREM (Varadhan & Roland 2008) accélère une carte de point-fixe EM lente. Prototypes
18+
non-invasifs (scratchpad) reconstruisant les cartes EM de ZIPLN (`ZIPLNfit$optimize`) et
19+
PLNmixture (`PLNmixturefit$optimize`).
20+
21+
**Verdict** : pas de gain de vitesse.
22+
- ZIPLN / PLNmixture : SQUAREM coupe ~1.5–2× les *cycles externes*, mais chaque cycle coûte
23+
~2–3 évaluations de la carte (VE-step / fits de composantes), et la garde de monotonie
24+
ELBO rejette l'extrapolation → net plus lent (25–40 %). Léger mieux d'ELBO (échappe des
25+
plateaux) mais pas de vitesse.
26+
- PLN / PLNPCA de base : hors-scope (solveur conjoint en C++, pas de carte EM séparable lente).
27+
28+
Conclusion : SQUAREM = éventuelle option de robustesse, jamais un défaut. Le vrai levier pour
29+
PLNPCA est ailleurs (cf. §2).
30+
31+
---
32+
33+
## 2. PLNPCA — Newton profilé à région de confiance (nouveau backend)
34+
35+
### 2.1 Diagnostic de départ
36+
37+
À q fixé, l'optim PLNPCA (nlopt CCSAQ ou l'ancien builtin spectral BB) faisait des **milliers**
38+
d'itérations premier-ordre. L'ascension par blocs (VE-step Newton pour (M,S), puis (B,C))
39+
stagne sur des **points-selles** du paysage non-convexe des loadings ; c'est pourquoi les
40+
méthodes conjointes premier-ordre payent tant d'itérations (prix de la robustesse aux selles).
41+
42+
### 2.2 L'idée
43+
44+
Profiler les paramètres variationnels : `g(B,C) = max_{M,S} ELBO`.
45+
- Le bloc (M,S) est **concave** → VE-step Newton par observation (solve q×q), ~9 passes.
46+
- Le **théorème de l'enveloppe** donne `∇g` gratuitement (= ∂ELBO/∂(B,C) au (M,S) optimal).
47+
- Optimiser `g(B,C)` par un Newton **conscient des selles** (région de confiance + courbure
48+
négative), en utilisant le **complément de Schur** `H_θθ − H_θφ H_φφ⁻¹ H_φθ``H_φφ` est
49+
bloc-diagonal par observation (2q×2q) → produits Hessien-vecteur matrix-free bon marché.
50+
51+
Validé en R d'abord : un pas le long de la courbure négative à la selle escalade vers un
52+
meilleur optimum que le package. Le Newton conscient des selles bat nlopt PARTOUT en ~10–40
53+
itérations externes (vs 463–5406 CCSAQ).
54+
55+
### 2.3 Implémentation C++
56+
57+
- `src/builtin_plnpca.h` : `ve_solve` (VE Newton), `inner_blocks_inv` (bloc interne analytique
58+
2q×2q), `hess_dir` (Hessienne appliquée à une direction — dérivée analytiquement, `dA=A⊙dη`
59+
+ règle du produit, cohérente terme à terme avec `rank_obj_grad`), `precond_diag`
60+
(préconditionneur de Jacobi = diagonale analytique de `L_θθ`, strictement positive).
61+
- `src/wrappers_builtin_optim_plnpca.cpp` : `builtin_optimize_rank` — gradient-enveloppe,
62+
produit Hessien-vecteur de Schur (2 appels `hess_dir` + solve du bloc interne), **Steihaug-CG
63+
préconditionné** (région de confiance en norme-P), boucle TR (test de ratio ρ, mise à jour
64+
du rayon Δ).
65+
66+
**Pièges rencontrés** :
67+
- `PLNPCAfit$optimize` normalise les covariables ; la région de confiance / les produits
68+
Hessien-vecteur ne sont PAS invariants d'échelle → la normalisation DÉGRADE (oaks −1553 ll).
69+
Fix : sauter la normalisation pour ce backend (échelle naturelle).
70+
- Compile : `C2` doit être déclaré AVANT les lambdas `[&]`.
71+
- Les produits Hessien-vecteur coûtent *intrinsèquement* 2 évaluations directionnelles de
72+
gradient (une par direction θ/φ) — l'analytique gagne en propreté (pas d'ε FD), pas en vitesse.
73+
74+
### 2.4 Préconditionnement
75+
76+
Le Jacobi (diagonale de `L_θθ`) absorbe la disparité d'échelle B-vs-C → le Steihaug-CG converge
77+
en bien moins d'itérations (`cg_maxit` abaissé). Sur oaks (grand p), sans préconditionnement
78+
cg=6 donnait −2388 ll (inutilisable) ; avec, cg=8 → parité vitesse + meilleure qualité.
79+
80+
### 2.5 builtin ← trnewton (renommage)
81+
82+
Décision : garder torch (experimental), **supprimer l'ancien builtin spectral BB de PLNPCA** et
83+
exposer le Newton région-de-confiance sous le nom `builtin` (cohérent avec les autres variantes
84+
PLN où builtin = optimiseur maison). nlopt reste le défaut. Renommage complet
85+
(fichiers/namespace/fonction/tag), plus de backend "trnewton" user-facing.
86+
87+
### 2.6 Comparaison des 4 backends
88+
89+
Qualité (Δloglik vs meilleur de ligne, ranks 1:5) : **trnewton(builtin) > builtin-ancien > nlopt
90+
> torch** sur les 15 cas (trichoptera/barents/oaks). torch = pire partout ET le plus lent.
91+
L'ancien builtin spectral était même LE PLUS LENT à l'échelle (microcosm 351s vs nlopt 273s vs
92+
nouveau builtin 155s), sans niche → suppression justifiée.
93+
94+
### 2.7 Robustesse à l'échelle
95+
96+
Bug trouvé sur microcosm (n=880, p=259) : crash (`solve(): solution not found`) et non-convergence
97+
(gtol absolu jamais atteint car ‖g‖ scale en millions). Corrigés :
98+
- convergence **relative** `‖g‖ ≤ gtol·max(‖g₀‖,1)` ;
99+
- solves robustes (`arma::solve` non-levant + fallback ; ridge relatif + `pinv` sur le bloc) ;
100+
- défauts : `maxit_out 60→150`, `gtol 1e-4→1e-3` relatif (point idéal), `cg_maxit=8`.
101+
102+
### 2.8 Gestion de la région de confiance
103+
104+
Diagnostic (trace TR) : rayon initial surdimensionné (3 rejets) + dents de scie (30 rejets/120),
105+
chacun recalculant inutilement gradient/`inner_blocks_inv`/préconditionneur alors que le point ne
106+
bouge pas. Fix : **rejets bon marché** (quantités mises en cache, recalculées seulement à
107+
l'acceptation), seed Δ ÷4, rétrécissement `Δ=0.25·‖s‖`. Résultat : q1/q3 convergent maintenant
108+
(status 3), + rapides + meilleure qualité ; petit/moyen non régressé.
109+
110+
### 2.9 Convergence lente à grand n — cause fondamentale (levier A épuisé)
111+
112+
Instrumentation du Steihaug-CG (exit reason + résidu, dans le trace) : sur microcosm q3, le CG
113+
**ne converge JAMAIS** (0/120), il bute sur la frontière TR (83) ou le cap avec **résidu qui
114+
GROSSIT** (26). **Cause racine : le Hessien réduit est INDÉFINI à l'échelle** (paysage riche en
115+
selles) ET `g(B,C)` est très **non-quadratique aux grands pas** (le profilage de (M,S) est une
116+
carte non-linéaire violente) → pas limités par le rayon TR. Mais les pas restent **précis (ρ≈1)**
117+
et réduisent bien f : la rampe (~100 itér × ~2000 d'amélioration) est **fondamentale**.
118+
- Garde-fou "stopper CG si résidu grossit" → EMPIRE (les itérations errantes produisent quand
119+
même de bons pas). Retiré.
120+
- Préconditionneur diagonal impuissant face à l'indéfini / directions plates non-alignées.
121+
- Seul levier théorique restant = solveur TR indéfini exact (GLTR/Lanczos), ~150 lignes, ROI
122+
incertain (pas déjà exact, balle TR petite par non-quadraticité) → non retenu.
123+
124+
### 2.10 Leviers pragmatiques — pas de gain gratuit
125+
126+
- **Warm-start des rangs** (sequential=TRUE) : NET NÉGATIF pour le TR Newton (oaks 0.55× + pire).
127+
Contrairement à un 1er-ordre, le coût par rang est dans la rampe (paysage indéfini), pas dans la
128+
distance à l'init ; partir du rang q−1 biaise le bassin. Écarté.
129+
- **maxit_out** : pur cadran temps↔qualité (les hauts rangs rampent productivement : q5
130+
+5.8k→+175k de maxit 150 à 350). Défaut 150 = compromis ; documenté que l'utilisateur peut
131+
relever `maxit_out` pour la qualité à l'échelle (ajout doc `@param backend`).
132+
133+
### 2.11 État final
134+
135+
builtin (TR Newton) = meilleure qualité petit/moyen partout ; à l'échelle plus rapide (1.8×) et
136+
meilleur en agrégat, dominant sur tous les rangs si `maxit_out` relevé. La lenteur de convergence
137+
à grand n est comprise et fondamentale. 85 tests PLNPCA passent, nlopt reste défaut.
138+
139+
**Commits** : `0880c681` (backend trnewton) · `98c6c35d` (robustesse échelle) · `a791a163`
140+
(trnewton→builtin) · `c9f38bec` (gestion TR) · `e641fe13` (diagnostics CG). Point d'arrêt naturel.

NEWS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
## New backends and optimizers
44

5-
* **New built-in Newton optimizer** (`backend = "builtin"`) for PLN, ZIPLN and PLNPCA: envelope-theorem Newton steps with strong Wolfe line search, no dependency on NLOPT. Substantially faster and more accurate than nlopt on large datasets with full covariance.
5+
* **New built-in Newton optimizer** (`backend = "builtin"`) for PLN, ZIPLN and PLNnetwork: envelope-theorem Newton steps with strong Wolfe line search, no dependency on NLOPT. Substantially faster and more accurate than nlopt on large datasets with full covariance.
66

7-
* **Backend defaults revisited package-wide**, based on extensive benchmarking: PLN keeps `"nlopt"` (now consistently faster thanks to `profiled = TRUE`, see below); PLNnetwork and ZIPLNnetwork now default to `"builtin"`, which finds a better optimum at a modest speed cost; ZIPLN keeps its `"builtin"` default. All four remain configurable via the `backend` argument; see the corresponding `*_param()` documentation for the trade-offs. The `torch` backend is now clearly marked **experimental** everywhere.
7+
* **PLNPCA's `"builtin"` backend rewritten as a profiled trust-region Newton**: the variational block `(M, S)` is profiled out with a per-observation Newton VE-step, and the loadings `(B, C)` are optimised with a saddle-aware trust-region Newton on the resulting objective (analytic Schur-complement Hessian-vector products, Jacobi-preconditioned Steihaug-CG) — replacing the previous spectral projected-gradient `"builtin"`. It reliably reaches a higher variational bound than `"nlopt"` at comparable-to-better speed; tuning keys `cg_maxit`, `maxit_out`, `ftol_out`, `gtol`, `delta0` in `config_optim` (see `?PLNPCA_param`). `"nlopt"` remains the default for `PLNPCA`.
8+
9+
* **Backend defaults revisited package-wide**, based on extensive benchmarking: PLN and PLNPCA keep `"nlopt"` (PLN now consistently faster thanks to `profiled = TRUE`, see below); PLNnetwork and ZIPLNnetwork now default to `"builtin"`, which finds a better optimum at a modest speed cost; ZIPLN keeps its `"builtin"` default. All backends remain configurable via the `backend` argument; see the corresponding `*_param()` documentation for the trade-offs. The `torch` backend is now clearly marked **experimental** everywhere.
810

911
* **Quality and speed improvements**: `config_optim$profiled = TRUE` is now the default for full-covariance nlopt fits (faster, slightly better loglik); ZIPLN's variational step now optimises `(M, ψ, R)` jointly via Newton instead of sequentially; PLNPCA shares a single SVD initialisation across ranks and can warm-start from a pre-fitted `PLNfit` for large ranks (`inception`/`init_method`, see `?PLNPCA_param`); ZIPLN's starting point no longer relies on `pscl::zeroinfl` (now an internal LM + binomial GLM routine), which is both much faster and a better starting point — `pscl` is no longer a dependency.
1012

R/PLNPCA.R

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,21 @@ PLNPCA <- function(formula, data, subset, weights, ranks = 1:5, control = PLNPCA
5959
#'
6060
#' @param backend optimization backend, either `"nlopt"` (default, NLOPT/CCSAQ, recommended
6161
#' for PLNPCA: conservative per-variable steps reliably find the global basin even when
62-
#' the singular-value ratio d1/sqrt(n) is large), `"builtin"` (joint L-BFGS with strong
63-
#' Wolfe line search on all parameters simultaneously — faster per iteration but may
64-
#' converge to inferior local optima on ill-conditioned datasets),
65-
#' or `"torch"` (**experimental**: automatic differentiation via the torch package; tends to
66-
#' find lower loglik than `"nlopt"` and `"builtin"` on most datasets — not recommended).
62+
#' the singular-value ratio d1/sqrt(n) is large), `"builtin"` (the home-made optimizer, a
63+
#' profiled trust-region Newton), or `"torch"` (**experimental**: automatic differentiation
64+
#' via the torch package; tends to find lower loglik than `"nlopt"` and `"builtin"` on most
65+
#' datasets — not recommended).
66+
#' The `"builtin"` backend profiles out the variational parameters `(M, S)` with a
67+
#' per-observation Newton VE-step and optimises the loadings `(B, C)` with a saddle-aware
68+
#' trust-region Newton on the resulting objective (analytic Schur Hessian-vector products,
69+
#' Jacobi-preconditioned Steihaug-CG). It reliably reaches a higher variational bound than
70+
#' `"nlopt"` on small/moderate data at comparable speed, and is faster on large data;
71+
#' tuning keys in `config_optim`: `cg_maxit`, `maxit_out`, `ftol_out`, `gtol`, `delta0`.
72+
#' On large data (many samples and variables) the reduced-Hessian landscape is indefinite
73+
#' and the outer trust-region iteration converges slowly but keeps improving the bound, so
74+
#' it is capped by `maxit_out` (default 150) rather than by the gradient tolerance: the
75+
#' higher ranks then trade quality for time. For the best variational bound on large data,
76+
#' raise it, e.g. `config_optim = list(maxit_out = 300)`.
6777
#' @inheritParams PLN_param trace config_optim config_post
6878
#' @param init_method character: strategy used to compute the starting point for the shared SVD.
6979
#' - `"LM"` (default): fast multivariate `lm.fit` on log-transformed counts. Good for
@@ -122,7 +132,11 @@ PLNPCA_param <- function(
122132
if (backend == "torch")
123133
message("torch backend is experimental: may converge to suboptimal solutions or fail on some datasets.")
124134
config_opt <- make_config_optim(backend, config_optim, trace,
125-
builtin_default = config_default_plnpca)
135+
builtin_default = config_default_plnpca,
136+
extra = if (backend == "builtin")
137+
list(maxit_out = 150L, ftol_out = 1e-8, cg_maxit = 8L,
138+
gtol = 1e-3, delta0 = 1.0)
139+
else list())
126140
config_opt$sequential <- sequential
127141

128142
structure(list(

R/PLNPCAfit-class.R

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ PLNPCAfit <- R6Class(
253253
} else {
254254
private$optimizer$main <- nlopt_optimize_rank
255255
}
256-
private$optimizer$vestep <- if (control$backend == "builtin") builtin_optimize_vestep_rank else nlopt_optimize_vestep_rank
256+
## The builtin (trust-region Newton) backend has no dedicated VE-step;
257+
## prediction reuses the nlopt VE-step for every backend.
258+
private$optimizer$vestep <- nlopt_optimize_vestep_rank
257259
if (!is.null(control$svdM)) {
258260
svdM <- control$svdM
259261
} else {
@@ -303,7 +305,12 @@ PLNPCAfit <- R6Class(
303305
## Optimization ----------------------
304306
#' @description Call to the C++ optimizer and update of the relevant fields
305307
optimize = function(responses, covariates, offsets, weights, config) {
306-
nrm <- normalize_covariates(covariates)
308+
## The builtin (trust-region Newton) backend is naturally well-conditioned
309+
## (analytic Hessian) and its trust region is not scale-invariant, so covariate
310+
## normalization degrades it: keep the natural scale for that backend.
311+
nrm <- if (identical(private$optimizer$main, builtin_optimize_rank))
312+
list(X_sc = covariates, scales = rep(1, ncol(covariates)))
313+
else normalize_covariates(covariates)
307314
args <- list(data = list(Y = responses, X = nrm$X_sc, O = offsets, w = weights),
308315
params = list(B = sweep(private$B, 1, nrm$scales, "*"),
309316
C = private$C, M = private$M, S2 = private$S2),

R/RcppExports.R

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ builtin_optimize_rank <- function(data, params, config) {
8181
.Call('_PLNmodels_builtin_optimize_rank', PACKAGE = 'PLNmodels', data, params, config)
8282
}
8383

84-
builtin_optimize_vestep_rank <- function(data, params, config) {
85-
.Call('_PLNmodels_builtin_optimize_vestep_rank', PACKAGE = 'PLNmodels', data, params, config)
86-
}
87-
8884
builtin_optimize_vestep_zipln_full <- function(data, params, config) {
8985
.Call('_PLNmodels_builtin_optimize_vestep_zipln_full', PACKAGE = 'PLNmodels', data, params, config)
9086
}

_pkgdown.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ template:
55

66
navbar:
77
structure:
8-
left: [home, reference, slideshow, articles, news]
8+
left: [home, reference, vignettes, articles, news]
99
right: [github]
1010
components:
1111
home: ~
12-
articles:
12+
vignettes:
1313
text: "Vignettes"
1414
menu:
1515
- text: "The trichoptera data set"
@@ -29,6 +29,13 @@ navbar:
2929
- text: "ZIPLN: Zero-inflated models for counts with excess zeros"
3030
href: articles/ZIPLN.html
3131

32+
articles:
33+
text: "Articles"
34+
menu:
35+
- text: "Optimization in PLNmodels"
36+
href: articles/optimization.html
37+
38+
3239
reference:
3340
- title: 'Top-level fitting functions'
3441
desc: >

man/PLNPCA_param.Rd

Lines changed: 15 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)