Skip to main content

feature_engineering/
feature_engineering.rs

1//! Feature Engineering Optimization with Genetic Algorithms
2//!
3//! Demonstrates using fx_durable_ga to optimize feature selection and preprocessing
4//! pipelines for time series forecasting.
5//!
6//! **IMPORTANT!**
7//! This library requires fx-durable-ga-example-feature-engineering to be installed
8//! and available on the PATH as `feng`. The crate is available at:
9//! https://github.com/jakob-lilliemarck/fx-durable-ga-example-feature-engineering
10//!
11//! This example optimizes:
12//! - Which 7 features to use from available columns (TEMP, PRES, DEWP, etc.)
13//! - Preprocessing pipeline for each feature (max 2 transforms: ZSCORE, ROC, STD)
14//! - Neural network hyperparameters (hidden size, learning rate, sequence length)
15//!
16//! Search space: ~2.8 × 10^26 possible configurations
17//! (11^7 source columns × 3^7 pipeline lengths × 12^7 transform_1 × 12^7 transform_2 × 6 hidden sizes × 3 learning rates × 10 sequence lengths)
18
19use anyhow::Result;
20use fx_durable_ga::{
21    bootstrap,
22    models::{
23        Crossover, Distribution, Encodeable, Evaluator, FitnessGoal, GeneBounds, Mutagen,
24        MutationRate, Request, Schedule, Selector, Temperature, Terminated,
25    },
26    register_event_handlers, register_job_handlers,
27};
28use fx_mq_jobs::FX_MQ_JOBS_SCHEMA_NAME;
29use fx_mq_jobs::Queries;
30use serde::Deserialize;
31use sqlx::postgres::PgPoolOptions;
32use std::time::Duration;
33use std::{env, sync::Arc};
34use tracing::Level;
35use uuid::Uuid;
36
37const WORKERS: usize = 5;
38const FITNESS_TARGET: f64 = 1.0;
39
40/// Available source columns for features
41const SOURCE_COLUMNS: &[&str] = &[
42    "TEMP", "PRES", "DEWP", "RAIN", "WSPM", "PM2.5", "PM10", "SO2", "NO2", "CO", "O3",
43];
44
45/// Transform types with their parameters
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Transform {
48    ZScore10,
49    ZScore24,
50    ZScore48,
51    ZScore96,
52    Roc1,
53    Roc4,
54    Roc8,
55    Roc12,
56    Std10,
57    Std24,
58    Std48,
59    Std96,
60}
61
62impl Transform {
63    fn from_gene(gene: i64) -> Option<Self> {
64        match gene {
65            0 => Some(Self::ZScore10),
66            1 => Some(Self::ZScore24),
67            2 => Some(Self::ZScore48),
68            3 => Some(Self::ZScore96),
69            4 => Some(Self::Roc1),
70            5 => Some(Self::Roc4),
71            6 => Some(Self::Roc8),
72            7 => Some(Self::Roc12),
73            8 => Some(Self::Std10),
74            9 => Some(Self::Std24),
75            10 => Some(Self::Std48),
76            11 => Some(Self::Std96),
77            _ => None,
78        }
79    }
80
81    fn to_gene(self) -> i64 {
82        match self {
83            Self::ZScore10 => 0,
84            Self::ZScore24 => 1,
85            Self::ZScore48 => 2,
86            Self::ZScore96 => 3,
87            Self::Roc1 => 4,
88            Self::Roc4 => 5,
89            Self::Roc8 => 6,
90            Self::Roc12 => 7,
91            Self::Std10 => 8,
92            Self::Std24 => 9,
93            Self::Std48 => 10,
94            Self::Std96 => 11,
95        }
96    }
97
98    fn to_string(self) -> String {
99        match self {
100            Self::ZScore10 => "ZSCORE(10)".to_string(),
101            Self::ZScore24 => "ZSCORE(24)".to_string(),
102            Self::ZScore48 => "ZSCORE(48)".to_string(),
103            Self::ZScore96 => "ZSCORE(96)".to_string(),
104            Self::Roc1 => "ROC(1)".to_string(),
105            Self::Roc4 => "ROC(4)".to_string(),
106            Self::Roc8 => "ROC(8)".to_string(),
107            Self::Roc12 => "ROC(12)".to_string(),
108            Self::Std10 => "STD(10)".to_string(),
109            Self::Std24 => "STD(24)".to_string(),
110            Self::Std48 => "STD(48)".to_string(),
111            Self::Std96 => "STD(96)".to_string(),
112        }
113    }
114}
115
116/// A single feature with its source and preprocessing pipeline
117#[derive(Debug, Clone)]
118struct Feature {
119    source: String,
120    transforms: Vec<Transform>,
121}
122
123impl Feature {
124    fn to_cli_arg(&self, name: &str) -> String {
125        let pipeline = self
126            .transforms
127            .iter()
128            .map(|t| t.to_string())
129            .collect::<Vec<_>>()
130            .join(" ");
131
132        if pipeline.is_empty() {
133            format!("{}={}", name, self.source)
134        } else {
135            format!("{}={}:{}", name, self.source, pipeline)
136        }
137    }
138}
139
140/// Configuration for feature engineering optimization
141#[derive(Debug, Clone)]
142struct FeatureConfig {
143    features: Vec<Feature>,
144    hidden_size: usize,
145    learning_rate: f64,
146    sequence_length: usize,
147}
148
149impl Encodeable for FeatureConfig {
150    const NAME: &'static str = "feature_engineering";
151
152    type Phenotype = FeatureConfig;
153
154    fn morphology() -> Vec<GeneBounds> {
155        let mut bounds = Vec::new();
156
157        // 7 features, each with:
158        // - source column (0-10)
159        // - pipeline_length (0-2)
160        // - transform_1 (0-11)
161        // - transform_2 (0-11)
162        for _ in 0..7 {
163            bounds.push(GeneBounds::integer(0, 10, 11).unwrap()); // source
164            bounds.push(GeneBounds::integer(0, 2, 3).unwrap()); // pipeline_length (max 2)
165            bounds.push(GeneBounds::integer(0, 11, 12).unwrap()); // transform_1
166            bounds.push(GeneBounds::integer(0, 11, 12).unwrap()); // transform_2
167        }
168
169        // Hyperparameters
170        bounds.push(GeneBounds::integer(0, 5, 6).unwrap()); // hidden_size: [4, 8, 16, 32, 64, 128]
171        bounds.push(GeneBounds::integer(0, 2, 3).unwrap()); // learning_rate: [1e-4, 5e-4, 1e-3]
172        bounds.push(GeneBounds::integer(0, 9, 10).unwrap()); // sequence_length: [10..100 step 10]
173
174        bounds
175    }
176
177    fn encode(&self) -> Vec<i64> {
178        let mut genes = Vec::new();
179
180        // Encode 7 features
181        for feature in &self.features {
182            // Source column
183            let source_idx = SOURCE_COLUMNS
184                .iter()
185                .position(|&col| col == feature.source)
186                .unwrap_or(0) as i64;
187            genes.push(source_idx);
188
189            // Pipeline length (max 2)
190            genes.push(feature.transforms.len().min(2) as i64);
191
192            // Transforms (pad with 0 if fewer than 2)
193            for i in 0..2 {
194                if i < feature.transforms.len() {
195                    genes.push(feature.transforms[i].to_gene());
196                } else {
197                    genes.push(0);
198                }
199            }
200        }
201
202        // Encode hyperparameters
203        let hidden_size_idx = match self.hidden_size {
204            4 => 0,
205            8 => 1,
206            16 => 2,
207            32 => 3,
208            64 => 4,
209            128 => 5,
210            _ => 2, // default to 16
211        };
212        genes.push(hidden_size_idx);
213
214        let lr_idx = if self.learning_rate <= 1e-4 {
215            0
216        } else if self.learning_rate <= 5e-4 {
217            1
218        } else {
219            2
220        };
221        genes.push(lr_idx);
222
223        let seq_len_idx = ((self.sequence_length / 10).saturating_sub(1)).min(9) as i64;
224        genes.push(seq_len_idx);
225
226        genes
227    }
228
229    fn decode(genes: &[i64]) -> Self::Phenotype {
230        let mut features = Vec::new();
231
232        // Decode 7 features (4 genes per feature now: source, length, t1, t2)
233        for i in 0..7 {
234            let base_idx = i * 4;
235
236            let source_idx = genes[base_idx].clamp(0, 10) as usize;
237            let source = SOURCE_COLUMNS[source_idx].to_string();
238
239            let pipeline_length = genes[base_idx + 1].clamp(0, 2) as usize;
240
241            let mut transforms = Vec::new();
242            for j in 0..pipeline_length {
243                if let Some(transform) = Transform::from_gene(genes[base_idx + 2 + j]) {
244                    transforms.push(transform);
245                }
246            }
247
248            features.push(Feature { source, transforms });
249        }
250
251        // Decode hyperparameters (genes are now at index 28, 29, 30)
252        let hidden_size = match genes[28] {
253            0 => 4,
254            1 => 8,
255            2 => 16,
256            3 => 32,
257            4 => 64,
258            5 => 128,
259            _ => 32,
260        };
261
262        let learning_rate = match genes[29] {
263            0 => 1e-4,
264            1 => 5e-4,
265            2 => 1e-3,
266            _ => 5e-4,
267        };
268
269        let sequence_length = ((genes[30].clamp(0, 9) + 1) * 10) as usize;
270
271        FeatureConfig {
272            features,
273            hidden_size,
274            learning_rate,
275            sequence_length,
276        }
277    }
278}
279
280struct FeatureEvaluator;
281
282#[derive(Deserialize)]
283struct ResultOutput {
284    validation_loss: f64,
285}
286
287impl Evaluator<FeatureConfig> for FeatureEvaluator {
288    fn fitness<'a>(
289        &self,
290        genotype_id: Uuid,
291        phenotype: FeatureConfig,
292        _: &Request,
293        _: &'a Box<dyn Terminated>,
294    ) -> futures::future::BoxFuture<'a, Result<f64, anyhow::Error>> {
295        Box::pin(async move {
296            let model_save_path = format!("./model_storage/{}", genotype_id);
297            let mut args = vec![
298                "train".to_string(),
299                "--hidden-size".to_string(),
300                phenotype.hidden_size.to_string(),
301                "--learning-rate".to_string(),
302                phenotype.learning_rate.to_string(),
303                "--sequence-length".to_string(),
304                phenotype.sequence_length.to_string(),
305                "--prediction-horizon".to_string(),
306                "1".to_string(),
307                "--batch-size".to_string(),
308                "100".to_string(),
309                "--epochs".to_string(),
310                "25".to_string(),
311                "--model-save-path".to_string(),
312                model_save_path,
313            ];
314
315            // Add time features (always included)
316            args.push("--feature".to_string());
317            args.push("hour_sin=hour:SIN(24)".to_string());
318            args.push("--feature".to_string());
319            args.push("hour_cos=hour:COS(24)".to_string());
320            args.push("--feature".to_string());
321            args.push("month_sin=month:SIN(12)".to_string());
322            args.push("--feature".to_string());
323            args.push("month_cos=month:COS(12)".to_string());
324
325            // Add optimized features
326            for (i, feature) in phenotype.features.iter().enumerate() {
327                let feature_name = format!("feat_{}", i);
328                args.push("--feature".to_string());
329                args.push(feature.to_cli_arg(&feature_name));
330            }
331
332            // Add target (always TEMP)
333            args.push("--target".to_string());
334            args.push("target_temp=TEMP".to_string());
335
336            // Spawn the binary
337            let output = tokio::process::Command::new("feng")
338                .args(&args)
339                .output()
340                .await
341                .expect("Failed to run feng");
342
343            // Print stderr logs
344            let stderr = String::from_utf8_lossy(&output.stderr);
345            if !stderr.is_empty() {
346                eprintln!("feng stderr: {}", stderr);
347            }
348
349            // Check exit code
350            if !output.status.success() {
351                let stdout = String::from_utf8_lossy(&output.stdout);
352                return Err(anyhow::anyhow!(
353                    "feng failed with exit code {:?}. stdout: {}, stderr: {}",
354                    output.status.code(),
355                    stdout,
356                    stderr
357                ));
358            }
359
360            // Parse stdout as JSON (only the last line contains the JSON result)
361            let stdout = String::from_utf8_lossy(&output.stdout);
362            let last_line = stdout.lines().last().unwrap_or("");
363            let result: ResultOutput = serde_json::from_str(last_line).map_err(|e| {
364                anyhow::anyhow!(
365                    "Failed to parse JSON from feng: {}. Last line was: {}",
366                    e,
367                    last_line
368                )
369            })?;
370
371            Ok(result.validation_loss)
372        })
373    }
374}
375
376#[tokio::main]
377async fn main() -> Result<()> {
378    dotenv::from_filename(".env.local").ok();
379    tracing_subscriber::fmt()
380        .pretty()
381        .with_thread_ids(true)
382        .with_max_level(Level::INFO)
383        .init();
384
385    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
386    let pool = PgPoolOptions::new()
387        .max_connections(10)
388        .connect(&database_url)
389        .await?;
390
391    // Run all default migrations
392    fx_durable_ga::migrations::run_default_migrations(&pool).await?;
393
394    let service = Arc::new(
395        bootstrap(pool.clone())
396            .await?
397            .register::<FeatureConfig, _>(FeatureEvaluator)
398            .await?
399            .build(),
400    );
401
402    let mut registry = fx_event_bus::EventHandlerRegistry::new();
403    register_event_handlers(
404        Arc::new(Queries::new(FX_MQ_JOBS_SCHEMA_NAME)),
405        service.clone(),
406        &mut registry,
407    );
408    let mut listener = fx_event_bus::Listener::new(pool.clone(), registry);
409    tokio::spawn(async move { listener.listen(None).await });
410
411    let host_id = Uuid::parse_str("00000000-0000-0000-0000-123456789abc")?;
412    let mut jobs_listener = fx_mq_jobs::Listener::new(
413        pool.clone(),
414        register_job_handlers(&service, fx_mq_jobs::RegistryBuilder::new()),
415        WORKERS,
416        host_id,
417        Duration::from_secs(600),
418    )
419    .await?;
420    tokio::spawn(async move { jobs_listener.listen().await });
421
422    let request_id = service
423        .new_optimization_request(
424            FeatureConfig::NAME,
425            FeatureConfig::HASH,
426            FitnessGoal::minimize(FITNESS_TARGET)?,
427            Schedule::generational(40, 10),
428            Selector::tournament(5, 45)?,
429            Mutagen::new(Temperature::constant(0.7)?, MutationRate::constant(0.35)?),
430            Crossover::uniform(0.5)?,
431            Distribution::latin_hypercube(40),
432            None::<()>,
433        )
434        .await?;
435
436    // Poll for completion every 15 seconds (timeout after 1 hour)
437    let timeout = Duration::from_secs(7200);
438    let poll_interval = Duration::from_secs(15);
439    let start = std::time::Instant::now();
440
441    loop {
442        if service.is_request_concluded(request_id).await? {
443            println!("\nOptimization completed!");
444            break;
445        }
446
447        if start.elapsed() > timeout {
448            println!("\nOptimization timed out after 1 hour.");
449            break;
450        }
451
452        tokio::time::sleep(poll_interval).await;
453    }
454
455    // Get and print the best configuration
456    if let Some((genotype, fitness)) = service.get_best_genotype(request_id).await? {
457        let config = FeatureConfig::decode(&genotype.genome());
458        println!("\n=== Best Configuration ===");
459        println!("Fitness (MSE): {:.6}", fitness);
460        println!("RMSE: {:.6}°C", fitness.sqrt());
461        println!("\nHyperparameters:");
462        println!("  Hidden Size: {}", config.hidden_size);
463        println!("  Learning Rate: {}", config.learning_rate);
464        println!("  Sequence Length: {}", config.sequence_length);
465        println!("\nFeatures:");
466        for (i, feature) in config.features.iter().enumerate() {
467            let pipeline = feature
468                .transforms
469                .iter()
470                .map(|t| t.to_string())
471                .collect::<Vec<_>>()
472                .join(" → ");
473            if pipeline.is_empty() {
474                println!("  feat_{}: {}", i, feature.source);
475            } else {
476                println!("  feat_{}: {} → {}", i, feature.source, pipeline);
477            }
478        }
479    } else {
480        println!("No genotypes were evaluated.");
481    }
482
483    Ok(())
484}