---
title: "tidymodels workflow with psvr"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{tidymodels workflow with psvr}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment  = "#>",
  message  = FALSE,
  warning  = FALSE
)
```

This vignette demonstrates the full tidymodels pipeline with **psvr**: data
splitting, preprocessing, hyperparameter tuning by cross-validation, and final
model evaluation.  We use `psvr_rmspe_rbf()` (LS-SVR with RMSPE loss, RBF
kernel) and tune the regularisation parameter `cost` ($\Gamma$) against MAPE.

```{r libs}
library(psvr)
library(parsnip)
library(rsample)
library(recipes)
library(workflows)
library(tune)
library(dials)
library(yardstick)
library(dplyr)
```

## Data

The synthetic even-function dataset from the package README:
$y = 2 + x_1^2 + 0.5\,x_2^2 + \varepsilon$, $\varepsilon \sim \mathcal{N}(0,\,0.1^2)$.
Targets are strictly positive by construction ($y > 0$).

```{r data}
set.seed(42)
n   <- 200
x1  <- runif(n, -3, 3)
x2  <- runif(n, -3, 3)
y   <- 2 + x1^2 + 0.5 * x2^2 + rnorm(n, sd = 0.1)
dat <- data.frame(y = y, x1 = x1, x2 = x2)
```

## 1 — Split

```{r split}
set.seed(1)
split <- initial_split(dat, prop = 0.75)
train <- training(split)
test  <- testing(split)
```

## 2 — Preprocessing recipe

Centre and scale all predictors so the RBF kernel operates on a
standardised feature space.

```{r recipe}
rec <- recipe(y ~ x1 + x2, data = train) |>
  step_normalize(all_predictors())
```

## 3 — Model spec with `tune()`

Both `cost` (maps to $\Gamma$) and `rbf_sigma` (the RBF bandwidth $\sigma$)
are `tune()` placeholders; the grid search will explore all combinations.

```{r spec}
spec <- psvr_rmspe_rbf(cost = tune(), rbf_sigma = tune()) |>
  set_engine("psvr")
```

## 4 — Workflow

```{r workflow}
wf <- workflow() |>
  add_recipe(rec) |>
  add_model(spec)
```

## 5 — Tune with 5-fold CV

We search over a 15-point Latin hypercube of `cost` and `rbf_sigma` values and
evaluate each fold by MAPE.  Both search ranges are set from the data, and
**neither happens automatically** — `dials` cannot finalize either one, so both
have to be passed explicitly through `param_info`:

- `rbf_sigma_psvr_data()` centres the bandwidth range on the median pairwise
  distance, so it is computed on the **baked** predictors: the heuristic only
  means anything on the scale the model is actually fitted on.
- `cost_psvr_ls_data()` widens the `cost` range.  Here `cost` is $\Gamma$, and
  its registered default of $[-2, 10]$ on the log2 scale ($\Gamma \le 1024$) is
  the $\epsilon$-SVR range — far too low for LS-SVR, where the optimum scales
  with `var(y) * n`.  Left at the default the grid tops out at $\Gamma = 1024$,
  which on this dataset is an order of magnitude below the value selected once
  the range is widened — compare the `cost` column printed below.  The search
  is boundary-trapped: it cannot reach the optimum at all, and nothing warns
  you, because every candidate it did evaluate was legal.
  This one cannot be automated even in principle,
  because `tune` finalizes parameters from the predictors alone and never passes
  the outcome to `dials::finalize()`.

The RMSPE LS-SVR only solves an `(N+1) × (N+1)`
linear system — no iterative solver is involved — so 75
fits complete in seconds.

```{r tune}
set.seed(2)
folds <- vfold_cv(train, v = 5)

# Data-driven rbf_sigma range centred on median pairwise distance
train_baked      <- rec |> prep() |> bake(new_data = train)
rbf_sigma_custom <- rbf_sigma_psvr_data(train_baked |> select(-y))

wf_params <- extract_parameter_set_dials(wf) |>
  update(
    cost      = cost_psvr_ls_data(train$y),
    rbf_sigma = rbf_sigma_custom
  )

tune_res <- tune_grid(
  wf,
  resamples  = folds,
  grid       = 15,
  param_info = wf_params,
  metrics    = metric_set(yardstick::mape)
)
```

Cross-validated MAPE for each candidate (lower is better):

```{r tune-results}
collect_metrics(tune_res)[, c("cost", "rbf_sigma", "mean", "std_err")]
```

## 6 — Select best

```{r best}
best_params <- select_best(tune_res, metric = "mape")
best_params
```

## 7 — Final fit and test-set evaluation

`last_fit()` refits on the full training set with the chosen `cost` and
evaluates once on the held-out test data.

```{r final}
final_wf  <- finalize_workflow(wf, best_params)
final_fit <- last_fit(final_wf, split, metrics = metric_set(yardstick::mape))

collect_metrics(final_fit)
```

Predictions on the test set:

```{r predictions}
preds <- collect_predictions(final_fit)
head(preds[, c(".row", "y", ".pred")])
```

The fitted workflow can also be used directly for new data:

```{r new-data}
new_obs <- data.frame(x1 = c(0, 1, -2), x2 = c(0, 1, 2))
predict(extract_workflow(final_fit), new_data = new_obs)
```

## 8 — Inspecting the fitted psvr model

The tidymodels layer wraps a `psvr_rmspe` object (returned by the engine fit wrapper `psvr_rmspe_rbf_fit()`). Extract it to use `print()` and `coef()` directly.

```{r engine-fit}
# extract_fit_engine() unwraps the parsnip/workflow layer to the raw psvr
# object -- the same class psvr_rmspe() returns when called directly
engine_fit <- extract_fit_engine(extract_workflow(final_fit))
print(engine_fit)
```

```{r engine-coef}
cf <- coef(engine_fit)
# alpha:        N dual variables; weight each training point in
#               f(x) = sum_k alpha_k K(x_k, x) + b
# b:            bias / intercept term
# support_data: all N training inputs (LS-SVR has no sparsity — every training
#               point contributes, so despite the name this is not a subset)
cat(sprintf("b = %.4f  |  alpha range: [%.4f, %.4f]\n",
            cf$b, min(cf$alpha), max(cf$alpha)))
```
