--- title: "Getting Started with evoFE" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Getting Started with evoFE} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 5 ) ``` ## What is evoFE? **evoFE** (Evolutionary Feature Engineering) uses a genetic algorithm to automatically discover useful feature transformations for tabular data. Instead of manually crafting interaction terms, ratios, or binning strategies, you let evolution explore the space of possible transformations and keep the ones that improve predictive performance. The result is an **evo_recipe** -- a reusable transformation pipeline that can be applied to new data at prediction time. ### How it works 1. **Initialisation** -- A population of individuals is created. Each individual is a "recipe" containing a set of feature transformations (genes) and an **active feature mask** that controls which raw input columns are visible. 2. **Evaluation** -- Every individual is scored via cross-validated or split model performance (LightGBM, XGBoost, or any custom evaluator). 3. **Selection** -- The top 50 % survive to breed. 4. **Breeding** -- Survivors are combined (crossover) and randomly altered (mutation) to produce the next generation. Mutations may add/remove/modify genes *or* toggle which raw features are active. 5. **Repeat** -- The cycle continues until the fitness plateaus or the generation budget is exhausted. ## Installation ```r # Install the released version from CRAN install.packages("evoFE") # Or install the development version directly from GitHub # devtools::install_github("tanopereira/evoFE") ``` ## Quick Start -- Binary Classification Let's classify whether a car has an automatic or manual transmission using the `mtcars` dataset. ```{r binary-classification} library(evoFE) data(mtcars) df <- mtcars df$am <- as.integer(df$am) # target: 0 = automatic, 1 = manual set.seed(42) res <- evolve_features( data = df, target_col = "am", task = "classification", evaluator = "xgboost", generations = 5, pop_size = 8, cv_folds = 3, early_stopping_generations = 3, verbose = TRUE ) ``` The returned `evo_recipe` object contains the best individual (feature recipe), the fitted model, and the evolution history. ```{r binary-inspect} # Print high-level overview of the recipe print(res) # View a detailed structured summary summary(res) ``` ### Applying the recipe to new data `predict()` applies the evolved transformations to new data and returns the engineered feature matrix: ```{r binary-predict-features} engineered <- predict(res, df[1:5, ]) head(engineered) ``` `predict_model()` goes one step further -- it applies the transformations **and** runs the trained model to produce predictions: ```{r binary-predict-model} preds <- predict_model(res, df[1:5, ]) preds ``` ## Regression Predict petal length from the iris dataset: ```{r regression} data(iris) set.seed(123) res_reg <- evolve_features( data = iris[, c("Sepal.Length", "Sepal.Width", "Petal.Width", "Petal.Length")], target_col = "Petal.Length", task = "regression", evaluator = "xgboost", generations = 5, pop_size = 8, cv_folds = 3, early_stopping_generations = 3, verbose = TRUE ) cat("Best recipe:", individual_to_recipe_string(res_reg$best_individual), "\n") cat("Fitness (neg RMSE):", res_reg$best_individual$fitness, "\n") ``` ```{r regression-predict} preds_reg <- predict_model(res_reg, iris[1:10, ]) # Compare predictions to actuals data.frame( actual = iris$Petal.Length[1:10], predicted = round(preds_reg, 2) ) ``` ## Multiclass Classification Classify iris species (3 classes). Note `task = "multiclass"`: ```{r multiclass} iris_mc <- iris iris_mc$Species <- as.character(iris_mc$Species) set.seed(99) res_mc <- evolve_features( data = iris, target_col = "Species", task = "multiclass", evaluator = "xgboost", generations = 5, pop_size = 8, cv_folds = 3, early_stopping_generations = 3, verbose = TRUE ) cat("Best recipe:", individual_to_recipe_string(res_mc$best_individual), "\n") ``` For multiclass, `predict_model()` returns a probability matrix -- one column per class: ```{r multiclass-predict} probs <- predict_model(res_mc, iris_mc[c(1, 51, 101), ]) round(probs, 3) ``` ## Transformer Reference evoFE ships with **42 built-in transformers** that the genetic algorithm can select from during evolution. The table below groups them by category. ### Arithmetic (numeric -> numeric) | Transformer | Arity | Description | |:---|:---:|:---| | `log` | unary | Safe natural logarithm: `log1p(abs(x))` | | `sqrt` | unary | Safe square root: `sqrt(abs(x))` | | `reciprocal` | unary | `1/x` (0 where `x == 0`) | | `power` | unary | Signed exponentiation: `sign(x) * |x|^p`, $p \in \{0.5, 1/3, 2, 3\}$ | | `displaced_log` | unary | `log1p(|x + d|)` where `d` is sampled from [10, 1000] | | `add` | multi | Element-wise sum of 2+ columns | | `subtract` | binary | $x_1 - x_2$ | | `multiply` | multi | Element-wise product of 2+ columns | | `divide` | binary | $x_1 / x_2$ (0 where denominator is 0) | | `normalized_difference` | binary | $(x_1 - x_2) / (|x_1| + |x_2| + 10^{-6})$ | | `log_ratio` | binary | $\log(1+|x_1|) - \log(1+|x_2|)$ | ### Rank / Distribution (numeric -> numeric) | Transformer | Description | |:---|:---| | `rank_transform` | ECDF-based percentile rank mapped to [0, 1]; fit on training data, robust to outliers | ### Group-by Aggregations (mixed cat x num -> numeric) These combine a **categorical** grouping column with a **numeric** value column. All are stateful (fit on training data). | Transformer | Description | |:---|:---| | `groupby_mean` | Per-group mean | | `groupby_sd` | Per-group standard deviation | | `groupby_max` | Per-group maximum | | `groupby_min` | Per-group minimum | | `groupby_median` | Per-group median (robust to outliers) | | `groupby_quantile` | Per-group Q1 or Q3 ($q \in \{0.25, 0.75\}$) | | `groupby_ratio` | `value / group_mean` | | `groupby_zscore` | $(value - group\_mean) / group\_sd$ | ### Supervised Categorical Encodings (categorical -> numeric) All are stateful (fit on training data only -- no leakage). | Transformer | Description | |:---|:---| | `target_encode` | Smoothed mean-target encoding for binary classification / regression | | `pooled_target_encode` | Empirical Bayes pooled target encoding with dynamic shrinkage based on target variance | | `target_encode_multiclass` | Class-wise smoothed target encoding for multiclass tasks | | `woe_encode` | Weight of Evidence: `ln(P(event|cat) / P(non-event|cat))` with Laplace smoothing; binary classification only | ### Unsupervised Encoding & Binning | Transformer | Input -> Output | Description | |:---|:---:|:---| | `frequency_encode` | cat -> num | Count of each category level in training data | | `one_hot_encode` | cat -> num | Binary indicator for up to 5 top categories plus an "other" bucket | | `concat` | cat x cat -> cat | Concatenates 2 or 3 categorical columns with underscore separator | | `quantile_binning` | num -> num | Quantile-based bin index (numeric output) | | `quantile_binning_cat` | num -> cat | Quantile-based bin label (categorical output) | | `log_binning` | num -> num | Log-scale bin index (numeric output) | | `log_binning_cat` | num -> cat | Log-scale bin label (categorical output) | | `datetime_extract` | date -> num | Extracted datetime component: year, month, day, hour, day-of-week, or weekend indicator | | `date_diff` | date x date -> num | Signed difference in days between two datetime columns | ### Dimensionality Reduction (numeric -> numeric) All are stateful (fit on training data). | Transformer | Description | |:---|:---| | `pca` | Selected principal component from `prcomp` | | `truncated_svd` | Selected component from truncated SVD | | `random_projection` | Random unit-vector linear combination | | `umap` | UMAP projection component (requires **uwot**) | ### Manifold & Graph Learning (numeric -> categorical or numeric) All are stateful. Clustering is fit on a (optionally downsampled) training set; new data is assigned to clusters via 1-NN lookup. | Transformer | Output | Description | |:---|:---:|:---| | `genie` | categorical | Genie robust hierarchical cluster label (requires **genieclust**) | | `genie_centroid_dist` | numeric | Distance to each Genie cluster centroid | | `umap_genie` | categorical | Genie cluster labels on UMAP embedding (requires **uwot** + **genieclust**) | | `lumbermark` | categorical | Lumbermark MST-based hierarchical cluster label (requires **lumbermark**) | | `lumbermark_centroid_dist` | numeric | Distance to each Lumbermark cluster centroid | | `umap_lumbermark` | categorical | Lumbermark cluster labels on UMAP embedding (requires **uwot** + **lumbermark**) | | `mst_score` | numeric | MST-based anomaly score (requires **quitefastmst**) | | `deadwood` | categorical | Deadwood outlier indicator (requires **deadwood**) | ## Hierarchical Features (Gene Chaining) One of evoFE's powerful capabilities is **hierarchical feature construction**. After a gene has been evaluated and proven useful, subsequent generations can build *on top of* its output. For example: ``` Gen 1: log_ratio(Sepal.Length, Petal.Width) -> tested [OK] Gen 2: divide(Petal.Width, logratio(...)) -> chains from tested gene [OK] ``` **Important safety rule**: a gene can only chain from outputs that have been evaluated in a **previous** generation. A brand-new untested gene is never used as input for another gene in the same individual. This prevents fragile dependency chains built on unproven transformations. ## Hybrid Active Feature Mask Each individual in evoFE not only carries a set of transformation genes, but also an **active feature mask** -- a subset of the original raw input columns that the individual sees. This allows the evolutionary search to simultaneously optimise *which features to include* and *how to transform them*. ### How the mask works At initialisation, the mask is seeded using the **baseline model importances**. Features with higher importance (relative to the average) have a higher probability of being included via a sigmoid function: $$P(\text{include } x_i) = \sigma\!\left(\frac{\text{importance}(x_i) - \bar{\text{importance}}}{\tau}\right)$$ where $\tau$ is the temperature controlled by `mask_temp_factor`. ### Mask mutation operators Three mask-specific mutation operators are randomly triggered during each mutation event: | Operator | Trigger probability | Effect | |:---|:---:|:---| | **Recalculate mask** | `recalculate_mask_prob` (default 0.05) | Redraws the entire mask from scratch using current importances | | **Toggle raw feature(s)** | `raw_toggle_prob` (default 0.15) | Activates or deactivates one or more features; count drawn from a geometric distribution | | **Gene mutation** | `1 - recalculate_mask_prob - raw_toggle_prob` | Standard add/remove/modify gene operation | ### Tuning advice ```r # More aggressive feature selection (wider exploration) recipe <- evolve_features( data = df, target_col = "y", task = "classification", raw_toggle_prob = 0.25, # toggle features more often recalculate_mask_prob = 0.10, # recalculate mask more often mask_temp_factor = 1.0 # flatter importance distribution ) # Conservative -- mostly stick to all features, rely on gene mutations recipe <- evolve_features( data = df, target_col = "y", task = "classification", raw_toggle_prob = 0.05, recalculate_mask_prob = 0.02, mask_temp_factor = 0.3 # sharper -- concentrate on highest-importance features ) ``` ## Island Model The island model partitions the population into independent sub-populations (**islands**) that evolve in parallel. Periodic **migration** exchanges successful recipes and genes between islands, helping the overall search escape local optima while preserving diversity. ### Recipe-level migration (Ring topology) Every `migration_interval` generations, the top `migration_rate` individuals from each island are copied into the neighbouring island's population (ring topology). This preserves co-adapted gene interactions. ### Gene-level migration (Injection) In addition to whole-recipe migration, individual genes from each island's best individual are periodically injected into a neighbour island's mutation pool. When a mutation event fires on the receiving island, there is a `gene_migration_prob` chance that a migrated gene is injected instead of a random new gene. ### Basic island example ```r recipe <- evolve_features( data = df, target_col = "am", task = "classification", evaluator = "xgboost", generations = 10, pop_size = 8, islands = 4, # 4 independent sub-populations migration_interval = 5, # migrate every 5 generations migration_rate = 2, # top-2 individuals migrate gene_migration_prob = 0.2, # 20% chance to inject a migrated gene verbose = TRUE ) ``` ### Row-split islands When `row_split_islands = TRUE`, each island receives a distinct subset of the training rows. This is useful for very large datasets where evaluating every individual on the full dataset is prohibitively slow. ```r recipe <- evolve_features( data = big_df, target_col = "y", task = "regression", islands = 4, row_split_islands = TRUE, # each island sees ~25% of rows evaluation_strategy = "split", split_ratio = c(0.6, 0.2, 0.2) ) ``` When combined with `per_island_validation = TRUE`, each island also uses its own local validation split during the evolutionary search. Only the final tournament (between islands) uses the global validation set. This prevents cross-island data leakage during evolution. ### Heterogeneous transformer pools Each island can search over a different transformer vocabulary: ```r recipe <- evolve_features( data = df, target_col = "am", task = "classification", islands = 2, allowed_transformers = list( "basic", # island 1: arithmetic + encodings only "clustering" # island 2: UMAP, Genie, MST, etc. ) ) ``` ### Multi-Evaluator Island Portfolios Islands can also run different machine learning backends simultaneously. Passing a vector of evaluator names assigns each model family to a dedicated island: ```r recipe <- evolve_features( data = df, target_col = "am", task = "classification", islands = 2, evaluator = c("lightgbm", "xgboost"), generations = 5, pop_size = 6 ) ``` Island 1 discovers features optimized for LightGBM's histogram-based splitting, while Island 2 discovers features optimized for XGBoost's exact/approximate greedy trees. ### Advanced Topologies & Migration Policies For large-scale or structured exploration, evoFE provides graph-theoretic island topologies and adaptive probabilistic migration policies via `migration_config()`: ```r # Torus topology with demand-driven Gibbs pull migration mig_cfg <- migration_config( topology = topology_torus(c(2, 2)), # 4 islands arranged on a 2x2 toroidal grid policy = policy_gibbs_pull(), # stagnant islands actively pull innovations from leading islands interval = 4, rate = 1, gene_prob = 0.25 ) recipe <- evolve_features( data = df, target_col = "am", task = "classification", migration = mig_cfg, generations = 10 ) ``` Available topologies include: - `topology_ring(k)`: Unidirectional ring (default) - `topology_complete(k)`: Fully connected mesh - `topology_torus(dims)`: N-dimensional torus with wrap-around boundaries - `topology_hypercube(dim)`: $2^d$-vertex binary hypercube - `topology_grid(dims)`: N-dimensional Cartesian grid with fixed boundaries - `topology_tiered(tiers)`: Hierarchical fitness-stratified tiers (HFC architecture) - `topology_custom(adjacency_matrix)`: User-defined directed graph Available migration policies include: - `policy_push_uniform()`: Classic uniform broadcast to downstream neighbors - `policy_gibbs_push()`: Softmax-weighted push directing migrants toward lower-fitness islands - `policy_gibbs_pull()`: Demand-driven pull where stagnated islands pull elite solutions from high-performing neighbors - `policy_tiered_admission()`: Fitness-gated upward migration through hierarchical tiers ## Caruana Post-Hoc Island Ensembling Multi-island evolution naturally produces diverse, complementary feature recipes across islands. Rather than picking only the single best recipe, `ensemble_islands()` uses **Caruana greedy forward selection with replacement** to construct an optimal weighted ensemble across all island champions: ```r # 1. Evolve features across 3 islands set.seed(42) multi_recipe <- evolve_features( data = mtcars, target_col = "am", task = "classification", evaluator = "xgboost", islands = 3, generations = 5, pop_size = 6, verbose = FALSE ) # 2. Greedily build the Caruana ensemble from island populations ensemble <- ensemble_islands( multi_recipe, data = mtcars, max_rounds = 10, verbose = TRUE ) # Inspect the ensemble weights and member recipes summary(ensemble) # 3. Predict directly with the ensemble ensemble_preds <- predict_model(ensemble, mtcars[1:5, ]) ensemble_preds ``` `predict_model.evo_ensemble()` automatically routes the new observations through each member recipe's transformation pipeline and computes the weighted blend of model predictions. ## Custom Transformer Registration evoFE makes it easy to register your own custom transformations. Use `create_transformer()` to define your transformer, and `register_transformer()` to make it available during evolution: ```r library(evoFE) # 1. Define a transformer that adds a constant to a numeric variable add_five_trans <- create_transformer( name = "add_five", type = "unary", input_type = "numeric", apply_func = function(data, gene, state = NULL) { data[[gene$input_cols[1]]] + 5 }, name_generator = function(gene) paste0("add5_", gene$input_cols[1]) ) # 2. Register it with the package registry register_transformer("add_five", add_five_trans) # Now "add_five" is part of the transformer pool for all future evolution runs. # You can also restrict to only your custom transformer: recipe <- evolve_features( data = df, target_col = "am", task = "classification", allowed_transformers = c("log", "sqrt", "add_five") ) ``` ### Stateful (fit-on-train) transformers If your transformer needs to fit parameters on the training set (e.g., a custom normalisation), provide a `fit_func`: ```r z_score_trans <- create_transformer( name = "z_score", type = "unary", input_type = "numeric", fit_func = function(data, gene, target_col = NULL) { x <- data[[gene$input_cols[1]]] list(mean = mean(x, na.rm = TRUE), sd = sd(x, na.rm = TRUE)) }, apply_func = function(data, gene, state = NULL) { x <- data[[gene$input_cols[1]]] if (is.null(state) || state$sd == 0) return(rep(0, length(x))) (x - state$mean) / state$sd }, name_generator = function(gene) paste0("zscore_", gene$input_cols[1]) ) register_transformer("z_score", z_score_trans) ``` ## Custom Evaluator Registration evoFE is not limited to LightGBM and XGBoost. You can register any ML backend using `register_evaluator()`: ```r # Register a simple linear model evaluator backed by glmnet register_evaluator( "my_lm", train_func = function(x_train, y_train, x_val = NULL, task = "regression", ...) { model <- lm(y ~ ., data = as.data.frame(cbind(y = y_train, x_train))) preds <- if (!is.null(x_val)) { predict(model, newdata = as.data.frame(x_val)) } else { NULL } list(model = model, predictions = preds, importances = setNames(rep(1, ncol(x_train)), colnames(x_train))) }, predict_func = function(model, x_new, task, ...) { predict(model, newdata = as.data.frame(x_new)) } ) # Use it in evolution recipe <- evolve_features( data = df, target_col = "y", task = "regression", evaluator = "my_lm" ) ``` ## Bayesian Hyperparameter Tuning `make_tunable()` wraps any registered evaluator in a `mlr3mbo` Bayesian Optimisation loop that automatically tunes hyperparameters during each fitness evaluation: ```r # Define parameter search space for XGBoost param_ranges <- list( eta = list(type = "numeric", lower = 0.01, upper = 0.3), max_depth = list(type = "integer", lower = 3, upper = 9), subsample = list(type = "numeric", lower = 0.5, upper = 1.0) ) # Register a tunable version of xgboost make_tunable("xgboost", param_ranges, tuner_name = "xgboost_tuned") # Use it exactly like a regular evaluator recipe <- evolve_features( data = df, target_col = "am", task = "classification", evaluator = "xgboost_tuned", generations = 5, pop_size = 6, mbo_iters = 5, # Bayesian optimisation iterations per fitness eval mbo_init_design = 8 # initial LHS design points ) ``` evoFE ships two pre-configured tunable evaluators: `lightgbm_mbo` and `xgboost_mbo` (registered at package load). ## Understanding the Output `evolve_features()` returns an `evo_recipe` S3 object with: | Field | Description | |:---|:---| | `best_individual` | The winning recipe (list of genes, active column sets, fitness) | | `best_model` | The final LightGBM/XGBoost model trained on all data | | `history` | Full final-generation population (for inspection) | | `fitness_history` | Best fitness per generation (for `plot()`) | | `task` | The task type used | | `evaluator` | The evaluator used | | `metric` | The optimization metric used | | `classes` | Class labels (multiclass only) | ### Inspecting the recipe ```{r inspect-recipe} ind <- res$best_individual # Human-readable recipe string cat(individual_to_recipe_string(ind), "\n") # Number of evolved genes cat("Evolved genes:", length(ind$genes), "\n") # Original columns retained (active feature mask) cat("Numeric cols: ", paste(ind$numeric_cols, collapse = ", "), "\n") cat("Categorical cols:", paste(ind$categorical_cols, collapse = ", "), "\n") # Individual gene details for (g in ind$genes) { cat(sprintf(" %s(%s) -> %s\n", g$transformer_name, paste(g$input_cols, collapse = ", "), g$output_col)) } ``` ## Evaluation Strategies `evoFE` supports multiple evaluation strategies and cross-validation partition schemes designed to balance search speed, compute cost, and strict prevention of data leakage: ### Cross-Validation vs. Train/Val Splits 1. **Cross-Validation (`cv`)**: The default strategy. Evaluates fitness across $K$ folds (`cv_folds` parameter). 2. **Train/Validation/Holdout Split (`split`)**: Fast evaluation on large datasets using `split_ratio = c(0.6, 0.2, 0.2)` or explicit `split_ids`. ```r split_ids <- sample( c("train", "val", "holdout"), nrow(df), replace = TRUE, prob = c(0.6, 0.2, 0.2) ) recipe <- evolve_features( data = df, target_col = "am", task = "classification", split_ids = split_ids # overrides evaluation_strategy -> "split" automatically ) ``` ### Confirmation Holdout & Search-Gap Diagnostic In iterative automated feature engineering, repeatedly evaluating hundreds of candidate pipelines on the same cross-validation folds can lead to *search-level overfitting* (selecting recipes that fit fold artifacts). To safeguard against this, `holdout_frac` sets aside an untouched, stratified holdout sample before the search begins: ```r recipe <- evolve_features( data = df, target_col = "am", task = "classification", evaluator = "xgboost", holdout_frac = 0.20, # 20% untouched holdout generations = 5, pop_size = 8 ) # Inspect the holdout fitness and search gap cat("Selection CV fitness:", recipe$best_individual$fitness, "\n") cat("Confirmation holdout:", recipe$holdout_fitness, "\n") cat("Search gap: ", recipe$search_gap, "\n") ``` The `search_gap` (holdout score minus selection score) provides an unbiased check of generalization. A large negative search gap immediately alerts you to search-level overfitting. ### Leakage-Safe Cross-Validation Strategies Standard random K-fold CV can cause severe optimistic leakage when working with time-series or grouped observations. evoFE provides three fold construction strategies via `cv_strategy`: | Strategy | When to use | Behavior | |:---|:---|:---| | `"random"` | Independent i.i.d. observations | Standard stratified random K-fold partitioning | | `"time"` | Chronological / time-series data | Folds are contiguous chronological blocks ordered by `time_col`. The validation fold is strictly in the future of the training folds, preventing lookahead leakage. | | `"group"` | Grouped / clustered observations (e.g. subjects, patients, stores) | All rows sharing a `group_col` entity are assigned to the same fold using greedy bin-packing, ensuring no subject appears in both train and validation. | ```r # Temporal data -- chronological forward validation recipe_time <- evolve_features( data = financial_df, target_col = "returns", task = "regression", cv_strategy = "time", time_col = "date", cv_folds = 5 ) # Grouped data -- entity-isolated folds recipe_group <- evolve_features( data = patient_df, target_col = "outcome", task = "classification", cv_strategy = "group", group_col = "patient_id", cv_folds = 5 ) ``` ### Multi-Fidelity Evolutionary Screening For large datasets, evaluating every candidate generation at full dataset size can be compute-intensive. Enabling `multi_fidelity = TRUE` uses a warm-up screening schedule: ```r recipe_fast <- evolve_features( data = big_df, target_col = "target", task = "classification", multi_fidelity = TRUE, mf_warmup_frac = 0.40, # first 40% of generations screen on subsamples mf_sample_frac = 0.50, # screen on 50% row subsamples generations = 10, pop_size = 12 ) ``` During warm-up, candidates are screened rapidly on subsampled folds. Crucially, the top-performing half of candidates is **re-evaluated at full fidelity** before selection, ensuring that all fitness comparisons remain 100% apples-to-apples. ## Alternative and Custom Metrics By default, evoFE optimises LogLoss (classification) and RMSE (regression). Pass the `metric` parameter to change this: | Task | Supported metrics | |:---|:---| | `classification` | `"default"` (LogLoss), `"auc"`, `"f1"`, `"ts-refinement"` | | `multiclass` | `"default"` (Multiclass LogLoss), `"auc"`, `"ts-refinement"` | | `regression` | `"default"` (neg RMSE), `"mae"` | | Any | Custom function `function(y_true, y_pred)` returning numeric (higher = better) | ```r # Optimize AUC recipe_auc <- evolve_features( data = df, target_col = "am", task = "classification", metric = "auc", generations = 5, pop_size = 8 ) # Custom metric (MAPE, negated because evoFE maximises) mape_metric <- function(y_true, y_pred) { -mean(abs((y_true - y_pred) / (y_true + 1e-8))) } recipe_mape <- evolve_features( data = iris[, 1:5], target_col = "Petal.Length", task = "regression", metric = mape_metric, generations = 5, pop_size = 8 ) ``` ### TS-Refinement metric TS-Refinement is a calibration-aware classification metric that finds the temperature $T$ minimizing the log-loss of temperature-scaled prediction margins, then reports the unsmoothed log-loss at that temperature: $$\mathcal{L}_\text{TS} = \min_{T > 0} \, \text{LogLoss}\!\left(\sigma\!\left(\frac{z}{T}\right),\, y\right)$$ It is more discriminating than raw log-loss when models differ mainly in calibration rather than ranking quality. Enable it with: ```r recipe <- evolve_features( data = df, target_col = "am", task = "classification", metric = "ts-refinement" ) ``` You can also call `compute_ts_refinement()` directly on your own predictions: ```r score <- compute_ts_refinement( y_true = c(0, 1, 1, 0), y_pred = c(0.2, 0.8, 0.7, 0.3), task = "classification" ) cat("TS-Refinement log-loss:", score, "\n") ``` ## Parameter Reference ### `evolve_features()` -- full parameter table | Parameter | Default | Description | |:---|:---:|:---| | `data` | - | Input data.frame or data.table | | `target_col` | - | Name of the target column | | `task` | `"classification"` | `"classification"`, `"multiclass"`, or `"regression"` | | `generations` | 10 | Maximum evolutionary generations | | `pop_size` | 10 | Individuals per generation | | `cv_folds` | 3 | CV folds (only with `evaluation_strategy = "cv"`) | | `evaluation_strategy` | `"cv"` | `"cv"` or `"split"` | | `split_ratio` | `c(0.6, 0.2, 0.2)` | Train/Val/Holdout proportions for `"split"` | | `split_ids` | `NULL` | User-supplied split assignments (`"train"`, `"val"`, `"holdout"`) | | `holdout_frac` | 0 | Stratified holdout fraction frozen during search for unbiased confirmation | | `cv_strategy` | `"random"` | CV fold construction: `"random"`, `"time"`, or `"group"` | | `time_col` | `NULL` | Timestamp column used when `cv_strategy = "time"` | | `group_col` | `NULL` | Group entity column used when `cv_strategy = "group"` | | `multi_fidelity` | `FALSE` | Enable two-stage row-subsampling evaluation during warm-up | | `mf_sample_frac` | 0.5 | Row fraction kept per fold during multi-fidelity screening | | `mf_warmup_frac` | 0.5 | Fraction of total generations run in multi-fidelity screening mode | | `early_stopping_generations` | 3 | Stop after N generations without improvement | | `evaluator` | `"lightgbm"` | ML backend: `"lightgbm"`, `"xgboost"`, `"catboost"`, `"lm"`, `"keras3"`, or vector of backends per island | | `seed` | `NULL` | Integer RNG seed for fully reproducible evolutionary search (CRAN-safe) | | `dynamic_population` | `TRUE` | Expand population dynamically during stagnation | | `dynamic_population_growth_rate` | 1.5 | Growth multiplier during stagnation | | `dynamic_population_decay_rate` | 0.7 | Decay multiplier when improvement resumes | | `crossover_type` | `"both"` | `"random"`, `"union"`, or `"both"` (50/50 mix) | | `threads` | 2 | Threads for model training and clustering | | `max_clustering_size` | 5000 | Max unique rows passed to clustering transformers | | `verbose` | `TRUE` | Print progress to console | | `metric` | `"default"` | Optimisation metric (`"default"`, `"auc"`, `"f1"`, `"mae"`, `"ts-refinement"`, `"cal_rmse"`, `"cal_mae"`, or custom function) | | `model_all_final_genes` | `FALSE` | Train final model on union of all genes in final population | | `model_all_historical_genes` | `FALSE` | Train final model on union of all genes across all generations | | `allowed_transformers` | `"all"` | Transformer pool: `"all"`, `"basic"`, `"robust"`, `"clustering"`, or a character vector | | `complexity_penalty` | 0 | Multiplier for parsimony penalty (e.g. 1.0 for standard BIC/PAC-Bayes) | | `complexity_mode` | `"bic_dynamic"` | Penalty mode: `"bic_dynamic"`, `"bic"`, `"pac_bayes_dynamic"`, `"pac_bayes"`, or `"none"` | | `complexity_floor` | 0.20 | Minimum safety floor (e.g. 20%) for dynamic penalty relaxation | | `complexity_target` | `"all_features"` | Target to penalize: `"all_features"` (rewards raw feature pruning) or `"genes"` | | `migration` | `NULL` | Optional `evo_migration_config` object from `migration_config()` | | `islands` | 1 | Number of independent sub-populations | | `migration_interval` | 5 | Generations between island migrations | | `migration_rate` | 1 | Top-N individuals migrated from each island | | `gene_migration_prob` | 0.2 | Probability of injecting a migrated gene during mutation | | `migration_topology` | `"ring"` | Topology: `"ring"`, `"gibbs_stagnation"`, `"gibbs_fitness"`, `"dual_gibbs_pull"`, `"random"` | | `row_split_islands` | `FALSE` | Split training rows across islands | | `per_island_validation` | `FALSE` | Use per-island local validation split (requires `row_split_islands = TRUE`) | | `raw_toggle_prob` | 0.15 | Probability of toggling raw feature(s) in active mask during mutation | | `recalculate_mask_prob` | 0.05 | Probability of redrawing entire active mask from importances | | `mask_temp_factor` | 0.5 | Temperature for importance-guided mask sampling | | `record` | `FALSE` | Enable live evolution viewer | | `port` | `NULL` | Port for the live viewer server | | `...` | - | Extra arguments passed to the evaluator's `train_func` | ### Transformer presets for `allowed_transformers` | Preset | # Transformers | When to use | |:---|:---:|:---| | `"all"` | 42 | Default -- full search space | | `"basic"` | ~18 | Fast runs; arithmetic + key encodings only | | `"robust"` | ~24 | Outlier-resistant ops (rank, power, groupby median/quantile, WOE...) without heavy clustering | | `"clustering"` | ~10 | Manifold & graph features only (UMAP, Genie, Lumbermark, MST...) | ## Advanced Options ### Dynamic BIC & PAC-Bayes Complexity Penalties Without regularization, complex feature engineering algorithms risk evolving bloated recipes that overfit cross-validation noise. evoFE provides theoretically grounded complexity penalties that scale with dataset size $N$: #### 1. Bayesian Information Criterion (`complexity_mode = "bic"` / `"bic_dynamic"`) Uses asymptotic BIC scaling per feature: $$p = \lambda \cdot \frac{\ln(N)}{2N}$$ #### 2. PAC-Bayes Generalization Bound (`complexity_mode = "pac_bayes"` / `"pac_bayes_dynamic"`) Uses PAC-Bayes generalization bound scaling per feature: $$p = \lambda \cdot \frac{1}{2\sqrt{N}}$$ #### Dynamic Relaxation & Safety Floor Under dynamic modes (`"bic_dynamic"` and `"pac_bayes_dynamic"`), the penalty dynamically relaxes as evolution converges towards ideal fitness, preventing late-generation stagnation while enforcing a safety floor (`complexity_floor = 0.20`, or 20% of base penalty). #### Feature Count Targets - `complexity_target = "all_features"` (default): Penalizes the total number of active features (retained raw inputs + derived genes). This actively incentivizes the genetic algorithm to prune uninformative raw columns from the active mask. - `complexity_target = "genes"`: Penalizes only the number of newly derived transformation genes. ```r # Enable standard dynamic BIC parsimony with total feature regularization recipe <- evolve_features( data = df, target_col = "am", task = "classification", complexity_penalty = 1.0, # standard 1.0x BIC multiplier complexity_mode = "bic_dynamic", complexity_target = "all_features", # reward pruning redundant raw features complexity_floor = 0.20 # 20% minimum floor ) ``` ### Redundancy Pruning During evolution, evoFE automatically rejects any new feature whose absolute Pearson correlation with an existing feature exceeds a threshold (default `0.95`). This prevents wasting evaluations on near-duplicate columns. The threshold is tunable: ```r # Tighten to 0.90 -- reject anything correlated above 90% options(evoFE.redundancy_cor_threshold = 0.90) # Disable entirely options(evoFE.redundancy_cor_threshold = 1.0) ``` ### Gene Pooling for the Final Model By default the final model is trained only on the **best individual's** genes. Two flags expand this: ```r # Model trains on the union of all unique genes in the final population recipe <- evolve_features( ..., model_all_final_genes = TRUE ) # Model trains on the union of all unique genes across all generations recipe <- evolve_features( ..., model_all_historical_genes = TRUE ) ``` This can help recover useful genes eliminated by stochastic noise. ### Convergence Plot `plot(recipe)` produces a generation-by-generation fitness curve: ```r plot(recipe, type = "fitness") # fitness trajectory plot(recipe, type = "importance") # top feature importances of winning model ``` ### Live Evolution Viewer Enable the real-time browser dashboard: ```r recipe <- evolve_features( data = df, target_col = "am", task = "classification", record = TRUE # opens a browser tab with live streaming updates ) ``` ## Reproducibility Calling `set.seed()` before `evolve_features()` guarantees identical results across runs: ```{r reproducibility} set.seed(42) r1 <- evolve_features(iris[,1:5], "Petal.Length", task = "regression", generations = 3, pop_size = 5, evaluator = "xgboost", verbose = FALSE) set.seed(42) r2 <- evolve_features(iris[,1:5], "Petal.Length", task = "regression", generations = 3, pop_size = 5, evaluator = "xgboost", verbose = FALSE) identical(r1$best_individual$fitness, r2$best_individual$fitness) identical( individual_to_recipe_string(r1$best_individual), individual_to_recipe_string(r2$best_individual) ) ``` ## End-to-End Example: Train/Test Split A realistic workflow with hold-out evaluation: ```{r end-to-end} data(iris) set.seed(1) idx <- sample(nrow(iris), 0.7 * nrow(iris)) train <- iris[idx, ] test <- iris[-idx, ] # Evolve on training data only set.seed(7) recipe <- evolve_features( data = train[, 1:4], # exclude Species target_col = "Petal.Length", task = "regression", evaluator = "xgboost", generations = 5, pop_size = 8, verbose = FALSE ) # Predict on held-out test data test_preds <- predict_model(recipe, test[, 1:4]) # Evaluate rmse <- sqrt(mean((test$Petal.Length - test_preds)^2)) cat(sprintf("Test RMSE: %.4f\n", rmse)) cat(sprintf("Recipe: %s\n", individual_to_recipe_string(recipe$best_individual))) ``` ## Session Info ```{r session-info} sessionInfo() ```