---
title: "Projection pursuit with order-2 trend filtering"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Projection pursuit with order-2 trend filtering}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse=TRUE, comment="#>", fig.width=7, fig.height=3.5)
library(geosmooth)
```

Projection pursuit represents a regression surface as a sum of curves along
linear combinations of predictors:

\[
\eta(x)=b+\sum_{j=1}^M h_j(u_j^Tz).
\]

Here `z` denotes transformed predictors, and each direction has unit length.
For continuous outcomes, `eta` is the estimated mean; for binary outcomes it
is the logit of the estimated probability. Each component uses the same
piecewise-quadratic falling-factorial basis as the one-dimensional trend
filter. Order 2 penalizes third divided differences. An infinite penalty
leaves quadratic components; use zero components for a constant model.

## Fixed directions

Start with supplied directions to separate joint curve fitting from direction
search. The example below uses one oblique direction and one coordinate
direction. Directions are rows, expressed in the selected preprocessing
coordinates. `preprocessing="none"` makes their meaning especially direct.

```{r fixed}
set.seed(41)
X <- matrix(runif(72,-1,1),ncol=2)
colnames(X) <- c("x1","x2")
mean.response <- sin(X[,1]+0.6*X[,2]) + 0.3*X[,2]^2
y <- mean.response + 0.05*cos(seq_len(nrow(X)))
directions <- rbind(c(1,0.6), c(0,1))
fixed <- fit.projection.trend.filtering(
    X, y, lambda=0.001, directions=directions, direction.search="fixed",
    preprocessing="none", curve.ridge=0.05)
fixed
fixed$checks
```

The fitter centers each component using training weights and collects the
constant contribution in a common intercept. Backfitting updates one curve
with the other components as an offset. For binary outcomes it minimizes
binomial loss directly; it does not fit a binary model to residuals.

The objective divides loss by total training weight. `lambda` and
`curve.ridge` therefore have the same meaning after multiplying every weight
by a common constant. Component ridge stabilizes decompositions, including
their unpenalized polynomial parts. It is not a component-selection penalty.

## Learning directions

Learned fits examine coordinate directions and random sparse and dense
directions, then refine useful directions with tangent perturbations. Every
accepted joint model must pass the fixed-direction numerical checks.

```{r learned}
learned <- fit.projection.trend.filtering(
    X, y, lambda=0.001, nterms=1, curve.ridge=0.05, seed=17,
    control=list(starts=1, sparse.directions=2, dense.directions=2,
                 refinement.passes=1, refinement.proposals=8))
learned
learned$directions
learned$search.starts
```

This is a small illustration, not a performance comparison. `nterms` is an
upper bound in learned mode: no component is added when the evaluated
candidates fail to improve the objective. Failed candidates remain in
`search.history`; `search.status` describes why search stopped. The
nonconvex search cannot certify a globally optimal set of directions.
Setting `refinement.passes=0` selects from the initial fixed candidate bank.

Exact projection ties retain their separate likelihood contributions.
Distinct locations that become indistinguishable during floating-point
normalization cannot define the existing divided-difference operator and
produce an explicit failed candidate. This can occur in projections of
decimal grids with nearly coincident values. The fitter does not merge or
perturb locations to repair a direction; inspect candidate failures as well
as the selected model.

## Binary probabilities

Use the same interface with `family="binomial"`. Positive component ridge
also prevents separation along a component's polynomial part when both
outcomes are present. A one-class response has no finite unpenalized
intercept and produces an explicit error.

```{r binary}
set.seed(72)
probability <- plogis(-0.4 + 1.5*sin(X[,1]+0.6*X[,2]))
binary <- rbinom(nrow(X), 1, probability)
binary.fit <- fit.projection.trend.filtering(
    X, binary, lambda=0.001, family="binomial", curve.ridge=0.05,
    directions=matrix(c(1,0.6),1), direction.search="fixed",
    preprocessing="none")
head(predict(binary.fit))
```

## Validation and new rows

Cross-validation must learn directions using training responses only.
The wrapper repeats the entire procedure inside every supplied fold.
For repeated visits, supply participant-level folds and `groups` to check
that no participant crosses a training/validation boundary. Observation
weights remain explicit: group labels do not automatically rebalance them.

```{r cv, eval=FALSE}
cv <- cv.projection.trend.filtering(
    X, y, foldid=folds, groups=participant,
    nterms.grid=c(0,1,2), lambda.grid=c(Inf,0.01,0.001),
    curve.ridge.grid=c(0.001,0.01), seed=17)
cv$cv.table
predict(cv, newdata=new.X)
```

Gaussian selection uses weighted MSE; binomial selection uses weighted log
loss. Each candidate must succeed in every validation split. Selection
scores require an outer validation split or an independent test set before
being interpreted as performance estimates for the selected procedure.

```{r prediction}
new.X <- rbind(c(-1.5,0), c(0,0), c(1.5,0))
colnames(new.X) <- colnames(X)
prediction <- predict(binary.fit, new.X)
prediction
attr(prediction, "outside.range")
```

Prediction uses stored training transformations and knots. Each component
extends as a constant beyond its projected training range; the
`outside.range` matrix identifies affected rows and components. This can
matter even when each original coordinate lies in its observed range.
`type="terms"` returns centered components with intercept and offset
attributes. Ordinary prediction returns missing values for an unconverged
joint fit; `allow.unconverged=TRUE` is an explicit diagnostic option.

```{r diagnostic-plot, fig.alt="Estimated binary probabilities plotted against the simulated probabilities for this illustrative fixed-direction fit."}
plot(probability, as.numeric(predict(binary.fit)), pch=19,
     xlab="Simulated probability", ylab="Estimated probability",
     xlim=c(0,1), ylim=c(0,1))
abline(0,1,lty=2,col="gray50")
```

The example examines one fitted dataset. It does not establish accuracy
across signal families or high-dimensional predictor distributions.
