--- title: "Custom strategies" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Custom strategies} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` ppforest2 trains trees by composing six pluggable strategies: | Strategy | Purpose | Built-in | |----------|---------|----------| | **PP** (projection pursuit) | Find the projection that best separates groups | `pp_pda()` — Penalized Discriminant Analysis | | **Vars** (variable selection) | Select which variables are available at each split | `vars_uniform()`, `vars_all()` | | **Threshold** (split cutpoint) | Compute the split cutpoint in projected space | `cutpoint_mean_of_means()` | | **Stop** (stopping rule) | Decide when to stop growing | `stop_pure_node()` | | **Binarize** (binarization) | Reduce multiclass to binary at each node | `binarize_largest_gap()` | | **Grouping** (group partition) | Route observations to children | `grouping_by_label()` | You can add new strategies without modifying the core tree-building logic. This vignette walks through the process. ## How strategies work Each strategy is an R list with a `name` field that identifies it, a `display_name` for summaries, and any parameters the strategy needs. The `name` must match a C++ strategy registered under the same name. ```{r inspect} library(ppforest2) pp_pda(0.5) vars_uniform(n_vars = 2) cutpoint_mean_of_means() ``` When you call `pptr()` or `pprf()`, the strategy lists are passed to C++, where the `name` field dispatches to the corresponding C++ implementation. The actual computation (optimization, variable selection, cutpoint) happens entirely in C++. ## Adding a new strategy Adding a strategy requires work on both sides: 1. **C++**: Implement the strategy class (the computation). 2. **R**: Write a constructor function (the user-facing API). ### Step 1: C++ implementation Each strategy family has a base class with pure virtual methods. Your new strategy inherits from the appropriate base and implements them. For example, a new projection pursuit strategy needs to implement `optimize()` (find the best projection): ```cpp // File: core/src/models/strategies/pp/MyMethod.hpp #pragma once #include "models/strategies/pp/ProjectionPursuit.hpp" #include "models/strategies/Strategy.hpp" #include "utils/JsonValidation.hpp" namespace ppforest2::pp { struct MyMethod : public ProjectionPursuit { explicit MyMethod(float alpha) : alpha_(alpha) {} std::string display_name() const override { return "My method"; } Result optimize( const types::FeatureMatrix& x, const stats::GroupPartition& group_spec) const override { // Find the optimal projector for the data. // Return Result{ projector_vector, index_value }. ... } nlohmann::json to_json() const override { return {{"name", "my_method"}, {"alpha", alpha_}}; } static ProjectionPursuit::Ptr from_json(const nlohmann::json& j) { JsonReader{j, "my_method"}.only_keys({"name", "alpha"}); return my_method(j.at("alpha").get()); } PPFOREST2_REGISTER_STRATEGY(ProjectionPursuit, "my_method") private: const float alpha_; }; inline ProjectionPursuit::Ptr my_method(float alpha) { return std::make_shared(alpha); } } // namespace ppforest2::pp ``` The key pieces: - **`to_json()`** serializes the strategy name and parameters. This is used for model persistence. - **`from_json()`** deserializes from JSON and validates that no unexpected keys are present. - **`PPFOREST2_REGISTER_STRATEGY`** registers the factory so JSON deserialization finds it automatically. - **`display_name()`** returns a human-readable label for summaries. - **Factory function** (`my_method()`) is a convenience wrapper. The same pattern applies to variable selection strategies (`select()`), cutpoint strategies (`cutpoint()`), and the other strategy families. See the C++ documentation for complete interface definitions and examples. After writing the `.cpp` file, add it to `core/src/models/CMakeLists.txt`. ### Step 2: R constructor Write an R function that creates a strategy list. The `name` field must match the C++ registration name exactly. ```{r, eval = FALSE} #' My custom projection pursuit strategy. #' #' @param alpha A tuning parameter. #' @return A \code{pp_strategy} object. #' @export pp_my_method <- function(alpha = 1.0) { if (!is.numeric(alpha) || length(alpha) != 1) stop("`alpha` must be a single number.") structure( list(name = "my_method", display_name = "My method", alpha = alpha), class = "pp_strategy" ) } ``` The constructor should: - **Validate parameters** before they reach C++. Catching errors early with clear messages is better than a C++ exception. - **Set the S3 class** to `pp_strategy`, `vars_strategy`, `cutpoint_strategy`, `stop_strategy`, `binarize_strategy`, or `grouping_strategy`. This is checked by `resolve_strategies()`. - **Include `display_name`** for readable output in `summary()`. - **Use the same parameter names** as `to_json()` in C++. The R list is converted to JSON and passed to `from_json()` on the C++ side. ### Step 3: Use it Once both sides are in place, the new strategy works like any built-in: ```{r, eval = FALSE} # Single tree tree <- pptr(Species ~ ., data = iris, pp = pp_my_method(alpha = 0.5)) # Forest forest <- pprf(Species ~ ., data = iris, pp = pp_my_method(alpha = 0.5), vars = vars_uniform(n_vars = 2)) # Summary shows the strategy summary(tree) ``` The strategy is also available from the CLI: ```bash ppforest2 train -d iris.csv --pp my_method:alpha=0.5 ``` And models trained with the new strategy can be saved and loaded as usual --- the JSON registry handles serialization automatically. ## Strategy families reference ### PP: Projection pursuit Controls how the tree finds the best linear combination of variables at each node. ``` index(x, group_spec, projector) -> scalar optimize(x, group_spec) -> Result{projector, index} ``` `optimize()` is the main method. It receives the data matrix and group partition and returns the best projection vector. `index()` evaluates a given projection (used for variable importance calculations). ### Vars: Variable selection Controls which variables are available to projection pursuit at each split. This is what makes random forests "random". ``` select(x, group_spec, rng) -> Result{selected_indices, original_cols} ``` The returned `VariableSelection::Result` tracks which columns were selected so the reduced-space projector can be expanded back to the full feature space. ### Threshold: Split cutpoint Controls where the split cutpoint is placed in the projected space. ``` cutpoint(group_1, group_2, projector) -> scalar ``` Receives the two groups (already partitioned by projection pursuit) and the projection vector. Returns the cutpoint value. ### Stop: Stopping rule Controls when to stop growing the tree. ``` should_stop(group_partition, depth) -> bool ``` ### Binarize: Binarization Controls how multiclass nodes (>2 groups) are reduced to a binary problem. ``` regroup(projected_x, group_partition) -> Result ``` ### Grouping: Group partition Controls how observations are routed to children after a split. ``` split(partition, lower_group, upper_group) -> Result ``` ## Checklist 1. Create `core/src/models/strategies//MyStrategy.hpp` (and `.cpp` if needed). 2. Inherit from the appropriate base class (`ProjectionPursuit`, `VariableSelection`, `Cutpoint`, `StopRule`, `Binarization`, or `Grouping`). 3. Implement the pure virtual methods. 4. Implement `to_json()` with a `"name"` field. 5. Implement `display_name()` for human-readable summaries. 6. Add `static Ptr from_json()` with key validation. 7. Add `PPFOREST2_REGISTER_STRATEGY(Base, "name")`. 8. Add a factory function in the strategy's namespace. 9. Add the `.cpp` to `core/src/models/CMakeLists.txt`. 10. Write tests in `MyStrategy.test.cpp` (JSON round-trip + functional). 11. Write the R constructor function with validation, `display_name`, and the correct S3 class. 12. Export and document the R function.