# --------------------------------------------------------------------------- # Model spaces, cross-validation protocol, and seeds. # # HARD CONSTRAINT: classical supervised ML only. NO NEURAL NETWORKS of any # kind. This is a deliberate methodological choice, not a limitation -- the # seminal result in this field was a regularized linear model, feature # engineering rather than estimator capacity is the bottleneck, and TreeSHAP # gives exact attributions, which matters when a process engineer must audit a # scrap decision. # # The `constraints.allow_neural_networks: false` flag below is validated by # src/utils/config.py, which REJECTS the config if it is ever set true. # --------------------------------------------------------------------------- constraints: allow_neural_networks: false target: name: "cycle_life" # Modelling target is log10(cycle life). WHY: cycle life spans 150-2300 # cycles and its error distribution is multiplicative rather than additive; # the source literature models it in log space, so this also keeps the # reproduction comparable. transform: "log10" # --------------------------------------------------------------------------- # Repeated grouped nested cross-validation. Grouping is by physical cell in # every scheme -- a cell must never appear on both sides of a split. # --------------------------------------------------------------------------- cv: group_key: "cell_id" outer: n_splits: 5 # Ten repeats with different seeds. WHY: with n ~ 124-169 cells, a single # 5-fold partition has enough sampling noise that two seeds can disagree # by more than the difference between models being compared. n_repeats: 10 inner: n_splits: 5 n_repeats: 1 # Hyperparameter search and feature selection happen HERE ONLY. purpose: "hyperparameter_search_and_selection_only" splits: # Phase 5 produces three schemes; see src/data/splitter.py. cell_grouped: enabled: true role: "primary evaluation" batch_holdout: enabled: true train_batches: [batch1, batch2] test_batches: [batch3] role: "RQ4 out-of-distribution: qualify on one campaign, deploy on the next" recipe_holdout: enabled: true # Hold out entire charging-policy families, analogous to introducing a new # process recipe. Family definition is derived in Phase 4 from the parsed # policy string and recorded in docs/02_feature_engineering.md. n_folds: 5 role: "RQ4 process-recipe shift" seeds: # Seed everything. Every artifact records the seed that produced it. global: 42 numpy: 42 # One seed per outer-CV repeat, fixed here so that a re-run reproduces the # same ten partitions exactly. outer_repeats: [101, 202, 303, 404, 505, 606, 707, 808, 909, 1010] bootstrap: 7 # --------------------------------------------------------------------------- # REPORTING RULE, NON-NEGOTIABLE: every headline number is mean +/- std across # outer folds, with a bootstrap 95% CI. A single test-set number is grounds for # rejection. # --------------------------------------------------------------------------- bootstrap: n_resamples: 10000 confidence_level: 0.95 method: "percentile" metrics: # Both families are reported everywhere. Reporting only the statistical # family would contradict the thesis of the project. statistical: [rmse_log10, mae_log10, mape_raw, spearman] manufacturing: [escape_rate, overkill_rate, yield, chamber_cycles_per_cell, expected_cost_per_cell] primary: "expected_cost_per_cell" hyperparameter_search: engine: "optuna" n_trials: 50 timeout_seconds: 600 # Optuna runs inside the INNER loop only. Tuning against outer-fold # performance would be tuning on the test set. scope: "inner_fold_only" sampler: "tpe" seed: 42 # --------------------------------------------------------------------------- # Model spaces. Search ranges live here so that no magic number sits in code. # --------------------------------------------------------------------------- models: dummy_mean: enabled: true estimator: "sklearn.dummy.DummyRegressor" role: "floor" params: {strategy: "mean"} dummy_median: enabled: true estimator: "sklearn.dummy.DummyRegressor" role: "floor" params: {strategy: "median"} elastic_net: enabled: true estimator: "sklearn.linear_model.ElasticNet" role: "severson_reproduction_and_baseline" search_space: alpha: {type: "float", low: 1.0e-4, high: 1.0e+1, log: true} l1_ratio: {type: "float", low: 0.01, high: 1.0, log: false} ridge: enabled: true estimator: "sklearn.linear_model.Ridge" search_space: alpha: {type: "float", low: 1.0e-4, high: 1.0e+3, log: true} lasso: enabled: true estimator: "sklearn.linear_model.Lasso" search_space: alpha: {type: "float", low: 1.0e-5, high: 1.0e+1, log: true} huber: enabled: true estimator: "sklearn.linear_model.HuberRegressor" # Robust to the handful of very-long-lived cells that otherwise dominate # squared-error loss. search_space: epsilon: {type: "float", low: 1.05, high: 3.0, log: false} alpha: {type: "float", low: 1.0e-5, high: 1.0e+0, log: true} random_forest: enabled: true estimator: "sklearn.ensemble.RandomForestRegressor" search_space: n_estimators: {type: "int", low: 200, high: 1000} max_depth: {type: "int", low: 2, high: 12} min_samples_leaf: {type: "int", low: 1, high: 8} max_features: {type: "float", low: 0.2, high: 1.0} extra_trees: enabled: true estimator: "sklearn.ensemble.ExtraTreesRegressor" search_space: n_estimators: {type: "int", low: 200, high: 1000} max_depth: {type: "int", low: 2, high: 12} min_samples_leaf: {type: "int", low: 1, high: 8} lightgbm: enabled: true estimator: "lightgbm.LGBMRegressor" search_space: n_estimators: {type: "int", low: 100, high: 1500} learning_rate: {type: "float", low: 0.005, high: 0.3, log: true} # Shallow trees and small leaves throughout: with ~124 training cells a # deep GBDT memorises the training set within a few dozen rounds. num_leaves: {type: "int", low: 4, high: 32} max_depth: {type: "int", low: 2, high: 6} min_child_samples: {type: "int", low: 2, high: 15} subsample: {type: "float", low: 0.6, high: 1.0} colsample_bytree: {type: "float", low: 0.4, high: 1.0} reg_alpha: {type: "float", low: 1.0e-8, high: 10.0, log: true} reg_lambda: {type: "float", low: 1.0e-8, high: 10.0, log: true} xgboost: enabled: true estimator: "xgboost.XGBRegressor" search_space: n_estimators: {type: "int", low: 100, high: 1500} learning_rate: {type: "float", low: 0.005, high: 0.3, log: true} max_depth: {type: "int", low: 2, high: 6} min_child_weight: {type: "float", low: 1.0, high: 10.0} subsample: {type: "float", low: 0.6, high: 1.0} colsample_bytree: {type: "float", low: 0.4, high: 1.0} reg_alpha: {type: "float", low: 1.0e-8, high: 10.0, log: true} reg_lambda: {type: "float", low: 1.0e-8, high: 10.0, log: true} catboost: enabled: true estimator: "catboost.CatBoostRegressor" search_space: iterations: {type: "int", low: 100, high: 1500} learning_rate: {type: "float", low: 0.005, high: 0.3, log: true} depth: {type: "int", low: 2, high: 6} l2_leaf_reg: {type: "float", low: 1.0, high: 30.0, log: true} quantile: # Feeds conformalized quantile regression in Phase 7. enabled: true estimator: "lightgbm.LGBMRegressor" objective: "quantile" quantiles: [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95] grading_route: # --------------------------------------------------------------------- # REVIEWED DECISION (after Phase 3): grade by ORDINAL REGRESSION from # predicted cycle life, not by direct three-class classification. # # WHY. Phase 3 recomputed the realised class balance from data: # A = 11 cells (8.9%), B = 70 (56.5%), C = 43 (34.7%). Eleven grade-A cells # is roughly two per fold under 5-fold grouped CV. A direct three-class # classifier would be estimating a decision boundary for a class it sees # twice per fold, and its per-fold grade-A recall would be dominated by # sampling noise rather than by signal. # # Predicting log10(cycle life) and thresholding at the grade boundaries # avoids the small-class problem STRUCTURALLY rather than compensating for # it: every cell contributes to a single continuous target regardless of # which side of a boundary it falls, so the 11 grade-A cells inform the fit # without having to support a class of their own. The grade boundaries are # ordered and known, which is exactly the situation ordinal treatment is for. # # It also preserves what the decision layer needs: the CONTINUE action in # Phase 8 requires a continuous predictive distribution over cycle life to # compute value of information. A three-class posterior cannot supply that. primary: "ordinal_from_regression" # Direct three-class classification is retained as a SECONDARY, # INTERPRETABILITY-ONLY view and must be labelled as such wherever reported. # It is not the headline grading route and its grade-A metrics carry an # explicit small-class caveat. secondary: enabled: true role: "secondary interpretability view only - not the headline result" estimators: ["lightgbm.LGBMClassifier", "sklearn.linear_model.LogisticRegression"] class_weight: "balanced" min_class_count_warning: 20 # A has 11; reporting must carry the caveat ensemble: enabled: true # Linear meta-learner on OUT-OF-FOLD predictions only. Stacking on in-fold # predictions leaks the base models' training performance into the meta-model. meta_learner: "sklearn.linear_model.RidgeCV" use_out_of_fold_only: true registry: directory: "outputs/models" # Every persisted artifact records the full config and package versions. record_provenance: true